feat: Enhance plugin runtime configuration and hook management

- Added `inactive_plugins` field to `RunnerReadyPayload` and `ReloadPluginResultPayload` to track plugins that are not activated due to being disabled or unmet dependencies.
- Introduced `InspectPluginConfigPayload` and `InspectPluginConfigResultPayload` for inspecting plugin configuration metadata.
- Implemented `PluginActivationStatus` enum to better represent plugin activation states.
- Updated `_activate_plugin` method to return activation status and handle inactive plugins accordingly.
- Added hooks for send service to allow modification of messages before and after sending.
- Created new runtime routes for listing hook specifications in the WebUI.
- Refactored plugin configuration handling to utilize runtime inspection for better accuracy and flexibility.
- Enhanced error handling and logging for plugin configuration operations.
This commit is contained in:
DrSmoothl
2026-04-02 21:16:31 +08:00
parent 56f7184c4d
commit 7d0d429640
22 changed files with 2698 additions and 1120 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
data/
data1/
mai_knowledge/knowledge.json
mongodb/
NapCat.Framework.Windows.Once/
NapCat.Framework.Windows.OneKey/

View File

@@ -1,887 +0,0 @@
{
"1": [
{
"id": "know_1_1774770946.623486",
"content": "备战中考",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:55:46.623486"
},
{
"id": "know_1_1774771765.051286",
"content": "性别为女性",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:09:25.051286"
},
{
"id": "know_1_1774771851.333504",
"content": "用户是I人内向型人格",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:10:51.333504"
},
{
"id": "know_1_1774771894.517183",
"content": "用户名为小千,被他人称为“宝宝”,结合语境推测为女性",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:11:34.517183"
},
{
"id": "know_1_1774771923.859455",
"content": "小千是I人内向型人格",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:12:03.859455"
},
{
"id": "know_1_1774771993.479732",
"content": "小千是女性",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:13:13.479732"
},
{
"id": "know_1_1774772079.496335",
"content": "用户名为小千,被他人称为“宝宝”,推测为女性或处于亲密社交语境中(注:性别非明确陈述,但基于昵称高频使用及语境,高置信度归纳为女性或女性化称呼偏好,若严格遵循“明确表达”则此项存疑。鉴于指令要求“高置信度可归纳”,且群内互动模式符合典型女性向昵称习惯,此处提取为倾向性事实)",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:14:39.496335"
},
{
"id": "know_1_1774773435.68612",
"content": "用户名为小千",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:37:15.686120"
},
{
"id": "know_1_1774773676.69252",
"content": "用户自称猫娘(二次元人设)",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:41:16.692520"
}
],
"2": [
{
"id": "know_2_1774768612.298128",
"content": "性格自信,常以“真理在我这边”自居",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:16:52.298128"
},
{
"id": "know_2_1774768645.029561",
"content": "性格自信且带有自嘲精神,喜欢用轻松调侃的方式应对他人评价",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:17:25.029561"
},
{
"id": "know_2_1774771068.355999",
"content": "喜欢用夸张、幽默或古风修辞表达观点",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:57:48.355999"
},
{
"id": "know_2_1774771397.764996",
"content": "性格幽默,喜欢使用夸张比喻和古风表达",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:03:17.764996"
},
{
"id": "know_2_1774771471.03367",
"content": "幽默风趣,喜欢使用夸张比喻和玩梗",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:04:31.033670"
},
{
"id": "know_2_1774771765.052285",
"content": "性格不孤僻,社交圈较广",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:09:25.052285"
},
{
"id": "know_2_1774771851.33601",
"content": "用户表现出社恐倾向,喜欢回避社交互动",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:10:51.336010"
},
{
"id": "know_2_1774771894.520185",
"content": "性格偏向内向I人有社恐倾向喜欢回避社交压力",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:11:34.520185"
},
{
"id": "know_2_1774771958.585244",
"content": "小千是内向型人格I人",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:12:38.585244"
},
{
"id": "know_2_1774771993.481732",
"content": "小千性格内向I人",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:13:13.481732"
}
],
"3": [
{
"id": "know_3_1774773676.695521",
"content": "喜欢冰淇淋",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:41:16.695521"
}
],
"4": [],
"5": [],
"6": [
{
"id": "know_6_1774768486.451792",
"content": "正在搭建 RAG 测试集",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:14:46.451792"
},
{
"id": "know_6_1774768517.122405",
"content": "熟悉 NapCat、RAG 等技术工具及互联网梗文化",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:15:17.122405"
},
{
"id": "know_6_1774769406.247087",
"content": "喜欢动漫风格插画",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:30:06.247087"
},
{
"id": "know_6_1774770487.207364",
"content": "关注显卡硬件参数(如显存、型号)及深度学习/炼丹应用",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:48:07.207364"
},
{
"id": "know_6_1774770487.209372",
"content": "对游戏光影效果感兴趣",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:48:07.209372"
},
{
"id": "know_6_1774770603.063873",
"content": "喜欢玩《我的世界》和VRChat",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:50:03.063873"
},
{
"id": "know_6_1774770654.654349",
"content": "关注显卡硬件参数如4090、48G显存、5090",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:50:54.654349"
},
{
"id": "know_6_1774770654.655356",
"content": "使用VRChat进行社交娱乐",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:50:54.655356"
},
{
"id": "know_6_1774770734.287947",
"content": "关注显卡硬件如4090、3050及AI炼丹技术",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:52:14.287947"
},
{
"id": "know_6_1774770734.289944",
"content": "玩《我的世界》并配置光影效果",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:52:14.289944"
},
{
"id": "know_6_1774770734.291944",
"content": "计划游玩VRChat",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:52:14.291944"
},
{
"id": "know_6_1774771033.111011",
"content": "喜欢玩VRChat",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:57:13.111011"
},
{
"id": "know_6_1774771068.358999",
"content": "关注VRChat等虚拟现实游戏及硬件性能话题",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:57:48.358999"
},
{
"id": "know_6_1774771233.980219",
"content": "使用VRChatVRC",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:00:33.980219"
},
{
"id": "know_6_1774771397.766996",
"content": "对VRChatVRC及虚拟形象社交感兴趣",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:03:17.766996"
},
{
"id": "know_6_1774771471.03567",
"content": "对VRChat等虚拟社交游戏感兴趣",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:04:31.035670"
},
{
"id": "know_6_1774771894.521183",
"content": "熟悉二次元文化、动漫角色及互联网流行梗Meme",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:11:34.521183"
},
{
"id": "know_6_1774771923.861534",
"content": "小千玩CS:GO游戏",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:12:03.861534"
},
{
"id": "know_6_1774771958.587243",
"content": "回声者_Echoderd喜欢玩CS:GO游戏",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:12:38.587243"
},
{
"id": "know_6_1774771993.483732",
"content": "小千喜欢二次元文化及动漫游戏圈梗",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:13:13.483732"
},
{
"id": "know_6_1774772079.499335",
"content": "熟悉并喜爱二次元文化、动漫角色及互联网梗图(如阴间美学、病娇系、黑长直萌妹等风格)",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:14:39.499335"
},
{
"id": "know_6_1774772112.716455",
"content": "小千关注CS:GO游戏及中考备考话题",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:15:12.716455"
},
{
"id": "know_6_1774772154.873237",
"content": "用户玩CS:GO游戏",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:15:54.873237"
},
{
"id": "know_6_1774772186.438797",
"content": "玩CS:GO游戏",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:16:26.438797"
},
{
"id": "know_6_1774772730.867535",
"content": "熟悉《我的青春恋爱物语果然有问题》及二次元表情包文化",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:25:30.867535"
},
{
"id": "know_6_1774773338.849271",
"content": "熟悉《原神》等二次元游戏及网络梗文化",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:35:38.849271"
},
{
"id": "know_6_1774773371.406209",
"content": "关注高分屏字体显示效果",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:36:11.406209"
},
{
"id": "know_6_1774773401.48921",
"content": "熟悉电脑显示技术(如高分屏字体选择)",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:36:41.489210"
},
{
"id": "know_6_1774773435.688119",
"content": "关注高分屏显示效果与字体选择(无衬线/衬线体)",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:37:15.688119"
},
{
"id": "know_6_1774773608.256103",
"content": "关注屏幕字体与分辨率(无衬线/有衬线)",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:40:08.256103"
},
{
"id": "know_6_1774773645.671546",
"content": "关注屏幕分辨率与字体显示效果(高分屏/无衬线体)",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:40:45.671546"
},
{
"id": "know_6_1774773676.698035",
"content": "关注字体设计(无衬线体)",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:41:16.698035"
},
{
"id": "know_6_1774773740.83822",
"content": "喜欢二次元文化及 VTuber 风格内容",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:42:20.838220"
}
],
"7": [
{
"id": "know_7_1774768517.120403",
"content": "从事 RAG 测试集搭建或相关技术工作",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:15:17.120403"
},
{
"id": "know_7_1774768573.741823",
"content": "从事 RAG检索增强生成测试集搭建相关工作",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:16:13.741823"
},
{
"id": "know_7_1774770603.062873",
"content": "备战中考",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:50:03.062873"
},
{
"id": "know_7_1774771471.036668",
"content": "正在备战中考的学生",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:04:31.036668"
},
{
"id": "know_7_1774771923.862535",
"content": "小千正在备战中考",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:12:03.862535"
},
{
"id": "know_7_1774771958.588749",
"content": "回声者_Echoderd正在备战中考",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:12:38.588749"
},
{
"id": "know_7_1774772112.714455",
"content": "小千使用AI模型进行对话",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:15:12.714455"
},
{
"id": "know_7_1774772154.870238",
"content": "用户正在备战中考",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:15:54.870238"
},
{
"id": "know_7_1774773185.194069",
"content": "使用 NapCat 框架",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:33:05.194069"
},
{
"id": "know_7_1774773338.851275",
"content": "使用 NapCat 框架,具备技术平台认知能力",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:35:38.851275"
},
{
"id": "know_7_1774773371.403696",
"content": "熟悉 NapCat 框架",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:36:11.403696"
}
],
"8": [
{
"id": "know_8_1774770946.624486",
"content": "日常逛游戏地图",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:55:46.624486"
},
{
"id": "know_8_1774771397.769034",
"content": "备考中考期间仍保持日常游戏娱乐习惯",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:03:17.769034"
},
{
"id": "know_8_1774771851.338018",
"content": "用户有备考中考的学习任务",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:10:51.338018"
},
{
"id": "know_8_1774771894.523189",
"content": "备考中(备战中考)",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:11:34.523189"
},
{
"id": "know_8_1774771993.484733",
"content": "小千有打CS:GO的游戏习惯",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:13:13.484733"
},
{
"id": "know_8_1774772079.501334",
"content": "有在高压环境下如中考前进行游戏娱乐CS:GO的习惯自称或认同“摆烂”的生活态度",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:14:39.501334"
},
{
"id": "know_8_1774772154.875743",
"content": "用户在备考期间有打游戏摸鱼的习惯",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:15:54.875743"
},
{
"id": "know_8_1774773435.690121",
"content": "习惯使用表情包表达情绪或进行网络互动",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:37:15.690121"
},
{
"id": "know_8_1774773676.701034",
"content": "备战中考",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:41:16.701034"
}
],
"9": [],
"10": [
{
"id": "know_10_1774768486.452792",
"content": "沟通风格带有调侃和自信,习惯用反问句表达观点",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:14:46.452792"
},
{
"id": "know_10_1774768517.121403",
"content": "沟通风格带有较强的好胜心和防御性,习惯用反问和调侃回应质疑",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:15:17.121403"
},
{
"id": "know_10_1774768573.742824",
"content": "沟通风格幽默,擅长使用逻辑闭环和反问句式进行辩论或调侃",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:16:13.742824"
},
{
"id": "know_10_1774768612.299126",
"content": "沟通风格幽默风趣,擅长使用网络梗和表情包互动",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:16:52.299126"
},
{
"id": "know_10_1774768612.299845",
"content": "偶尔会文绉绉地表达(自称“文青病犯了”),但能迅速切换回口语化",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:16:52.299845"
},
{
"id": "know_10_1774768645.028561",
"content": "沟通风格幽默风趣,偶尔会文青病发作使用古风表达",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:17:25.028561"
},
{
"id": "know_10_1774769406.249584",
"content": "沟通中常使用文言文或半文言表达",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:30:06.249584"
},
{
"id": "know_10_1774769406.251097",
"content": "习惯用反问句和夸张语气进行互动",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:30:06.251097"
},
{
"id": "know_10_1774770487.211056",
"content": "沟通风格幽默,常使用网络梗和夸张表达",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:48:07.211056"
},
{
"id": "know_10_1774771471.038677",
"content": "沟通风格轻松随意,善于接话和调侃",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:04:31.038677"
},
{
"id": "know_10_1774771765.053285",
"content": "沟通风格活泼,喜欢使用语气词和表情符号撒娇",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:09:25.053285"
},
{
"id": "know_10_1774772079.503333",
"content": "沟通风格幽默调侃,擅长用反话(如“烦到了”)和夸张修辞(如“耳朵起茧子”、“要报警了”)表达情绪",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:14:39.503333"
},
{
"id": "know_10_1774773338.853274",
"content": "沟通风格幽默风趣,擅长玩梗与自嘲",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:35:38.853274"
},
{
"id": "know_10_1774773371.408719",
"content": "喜欢用幽默调侃的方式回应他人",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:36:11.408719"
},
{
"id": "know_10_1774773401.491209",
"content": "沟通风格幽默风趣,擅长玩梗和角色扮演",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:36:41.491209"
},
{
"id": "know_10_1774773435.693121",
"content": "沟通风格幽默、喜欢玩梗和自嘲,擅长接话茬",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:37:15.693121"
},
{
"id": "know_10_1774773532.488374",
"content": "沟通风格幽默,喜欢使用网络梗和表情包活跃气氛",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:38:52.488374"
},
{
"id": "know_10_1774773532.490959",
"content": "在争论中倾向于据理力争,并自嘲或调侃对方阅读理解能力",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:38:52.490959"
},
{
"id": "know_10_1774773569.709356",
"content": "喜欢用幽默、夸张和自嘲的方式活跃气氛",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:39:29.709356"
}
],
"11": [
{
"id": "know_11_1774771068.360999",
"content": "乐于接受并学习新的技术技巧(如加速器用法)",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:57:48.360999"
}
],
"12": [
{
"id": "know_12_1774770654.657355",
"content": "面对网络延迟问题倾向于寻找加速器解决方案",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:50:54.657355"
},
{
"id": "know_12_1774773185.196068",
"content": "备战中考",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:33:05.196068"
},
{
"id": "know_12_1774773740.836223",
"content": "面对压力或冲突时,倾向于通过撒娇、耍赖和寻求盟友支持来应对",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:42:20.836223"
}
]
}

View File

@@ -13,12 +13,13 @@ import pytest
from src.plugin_runtime.component_query import component_query_service
from src.plugin_runtime.protocol.envelope import (
Envelope,
InspectPluginConfigPayload,
MessageType,
RegisterPluginPayload,
ValidatePluginConfigPayload,
)
from src.plugin_runtime.runner.runner_main import PluginRunner
from src.webui.routers.plugin.config_routes import update_plugin_config
from src.webui.routers.plugin.config_routes import get_plugin_config, get_plugin_config_schema, update_plugin_config
from src.webui.routers.plugin.schemas import UpdatePluginConfigRequest
@@ -56,6 +57,61 @@ class _DemoConfigPlugin:
self.received_config = config
def get_default_config(self) -> Dict[str, Any]:
"""返回测试插件的默认配置。
Returns:
Dict[str, Any]: 默认配置字典。
"""
return {"plugin": {"enabled": True, "retry_count": 3}}
def get_webui_config_schema(
self,
*,
plugin_id: str = "",
plugin_name: str = "",
plugin_version: str = "",
plugin_description: str = "",
plugin_author: str = "",
) -> Dict[str, Any]:
"""返回测试插件的 WebUI 配置 Schema。
Args:
plugin_id: 插件 ID。
plugin_name: 插件名称。
plugin_version: 插件版本。
plugin_description: 插件描述。
plugin_author: 插件作者。
Returns:
Dict[str, Any]: 测试配置 Schema。
"""
del plugin_name, plugin_description, plugin_author
return {
"plugin_id": plugin_id,
"plugin_info": {
"name": "Demo",
"version": plugin_version,
"description": "",
"author": "",
},
"sections": {
"plugin": {
"fields": {
"enabled": {
"type": "boolean",
"label": "启用",
"default": True,
"ui_type": "switch",
}
}
}
},
"layout": {"type": "auto", "tabs": []},
}
class _StrictConfigPlugin:
"""用于测试配置校验错误的伪插件。"""
@@ -173,6 +229,63 @@ async def test_runner_validate_plugin_config_handler_returns_normalized_config(m
assert response.payload["normalized_config"] == {"plugin": {"enabled": False, "retry_count": 3}}
@pytest.mark.asyncio
async def test_runner_inspect_plugin_config_handler_supports_unloaded_plugin(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Runner 应支持对未加载插件执行冷检查。"""
plugin = _DemoConfigPlugin()
runner = PluginRunner(
host_address="ipc://unused",
session_token="session-token",
plugin_dirs=[],
)
meta = SimpleNamespace(
plugin_id="demo.plugin",
plugin_dir="/tmp/demo-plugin",
instance=plugin,
manifest=SimpleNamespace(
name="Demo",
description="",
author=SimpleNamespace(name="tester"),
),
version="1.0.0",
)
purged_plugins: list[tuple[str, str]] = []
monkeypatch.setattr(
runner,
"_resolve_plugin_meta_for_config_request",
lambda plugin_id: (meta, True, None) if plugin_id == "demo.plugin" else (None, False, "not-found"),
)
monkeypatch.setattr(
runner._loader,
"purge_plugin_modules",
lambda plugin_id, plugin_dir: purged_plugins.append((plugin_id, plugin_dir)),
)
envelope = Envelope(
request_id=1,
message_type=MessageType.REQUEST,
method="plugin.inspect_config",
plugin_id="demo.plugin",
payload=InspectPluginConfigPayload(
config_data={"plugin": {"enabled": False}},
use_provided_config=True,
).model_dump(),
)
response = await runner._handle_inspect_plugin_config(envelope)
assert response.error is None
assert response.payload["success"] is True
assert response.payload["enabled"] is False
assert response.payload["normalized_config"] == {"plugin": {"enabled": False, "retry_count": 3}}
assert response.payload["default_config"] == {"plugin": {"enabled": True, "retry_count": 3}}
assert purged_plugins == [("demo.plugin", "/tmp/demo-plugin")]
@pytest.mark.asyncio
async def test_runner_validate_plugin_config_handler_returns_error_on_invalid_config(
monkeypatch: pytest.MonkeyPatch,
@@ -251,3 +364,73 @@ async def test_update_plugin_config_prefers_runtime_validation(
with config_path.open("rb") as handle:
saved_config = tomllib.load(handle)
assert saved_config == {"plugin": {"enabled": False, "retry_count": 3}}
@pytest.mark.asyncio
async def test_webui_config_endpoints_use_runtime_inspection_for_unloaded_plugin(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""WebUI 在插件未加载时也应从代码定义返回配置与 Schema。"""
async def _mock_inspect_plugin_config(
plugin_id: str,
config_data: Optional[Dict[str, Any]] = None,
*,
use_provided_config: bool = False,
) -> SimpleNamespace | None:
"""返回运行时冷检查结果。
Args:
plugin_id: 插件 ID。
config_data: 可选配置。
use_provided_config: 是否使用传入配置。
Returns:
SimpleNamespace | None: 冷检查结果。
"""
del config_data, use_provided_config
if plugin_id != "demo.plugin":
return None
return SimpleNamespace(
config_schema={
"plugin_id": "demo.plugin",
"plugin_info": {
"name": "Demo",
"version": "1.0.0",
"description": "",
"author": "",
},
"sections": {"plugin": {"fields": {}}},
"layout": {"type": "auto", "tabs": []},
},
normalized_config={"plugin": {"enabled": True, "retry_count": 3}},
enabled=True,
)
fake_runtime_manager = SimpleNamespace(inspect_plugin_config=_mock_inspect_plugin_config)
monkeypatch.setattr(
"src.webui.routers.plugin.config_routes.require_plugin_token",
lambda session: session or "session-token",
)
monkeypatch.setattr(
"src.webui.routers.plugin.config_routes.find_plugin_path_by_id",
lambda plugin_id: tmp_path if plugin_id == "demo.plugin" else None,
)
monkeypatch.setattr(
"src.plugin_runtime.integration.get_plugin_runtime_manager",
lambda: fake_runtime_manager,
)
schema_response = await get_plugin_config_schema("demo.plugin", maibot_session="session-token")
config_response = await get_plugin_config("demo.plugin", maibot_session="session-token")
assert schema_response["success"] is True
assert schema_response["schema"]["plugin_id"] == "demo.plugin"
assert config_response == {
"success": True,
"config": {"plugin": {"enabled": True, "retry_count": 3}},
"message": "配置文件不存在,已返回默认配置",
}

View File

@@ -5,7 +5,7 @@
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Awaitable, Callable, Dict, List, Optional
from typing import Any, Awaitable, Callable, Dict, List, Optional, Sequence
import asyncio
import json
@@ -1405,6 +1405,57 @@ class TestComponentRegistry:
assert warnings
assert "plugin_a.broken" in warnings[0]
def test_register_hook_handler_rejects_unknown_hook(self):
from src.plugin_runtime.host.component_registry import ComponentRegistrationError, ComponentRegistry
from src.plugin_runtime.host.hook_spec_registry import HookSpecRegistry
reg = ComponentRegistry(hook_spec_registry=HookSpecRegistry())
with pytest.raises(ComponentRegistrationError, match="未注册的 Hook"):
reg.register_component(
"broken_hook",
"hook_handler",
"plugin_a",
{
"hook": "chat.receive.unknown",
"mode": "blocking",
},
)
def test_register_plugin_components_is_atomic_when_hook_invalid(self):
from src.plugin_runtime.host.component_registry import ComponentRegistrationError, ComponentRegistry
from src.plugin_runtime.host.hook_spec_registry import HookSpec, HookSpecRegistry
hook_spec_registry = HookSpecRegistry()
hook_spec_registry.register_hook_spec(HookSpec(name="chat.receive.before_process"))
reg = ComponentRegistry(hook_spec_registry=hook_spec_registry)
reg.register_plugin_components(
"plugin_a",
[
{"name": "cmd_old", "component_type": "command", "metadata": {"command_pattern": r"^/old"}},
],
)
with pytest.raises(ComponentRegistrationError, match="未注册的 Hook"):
reg.register_plugin_components(
"plugin_a",
[
{
"name": "hook_ok",
"component_type": "hook_handler",
"metadata": {"hook": "chat.receive.before_process", "mode": "blocking"},
},
{
"name": "hook_bad",
"component_type": "hook_handler",
"metadata": {"hook": "chat.receive.missing", "mode": "blocking"},
},
],
)
assert reg.get_component("plugin_a.cmd_old") is not None
assert reg.get_component("plugin_a.hook_ok") is None
def test_query_by_type(self):
from src.plugin_runtime.host.component_registry import ComponentRegistry
@@ -2142,6 +2193,18 @@ class TestPluginRuntimeHookEntry:
assert result.kwargs["session_id"] == "s-1"
assert ("b1", "builtin_guard") in call_log
def test_manager_lists_builtin_hook_specs(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""PluginRuntimeManager 应暴露内置 Hook 规格清单。"""
_ComponentRegistry, PluginRuntimeManager = self._import_manager_modules(monkeypatch)
manager = PluginRuntimeManager()
hook_names = {spec.name for spec in manager.list_hook_specs()}
assert "chat.receive.before_process" in hook_names
assert "send_service.before_send" in hook_names
assert "maisaka.planner.after_response" in hook_names
class TestRPCServer:
"""RPC Server 代际保护测试"""
@@ -2974,6 +3037,16 @@ class TestIntegration:
self._registered_plugins = {plugin_id: object() for plugin_id in plugins}
self.config_updates = []
async def inspect_plugin_config(
self,
plugin_id: str,
config_data: Optional[Dict[str, Any]] = None,
use_provided_config: bool = False,
) -> SimpleNamespace:
"""返回测试用的配置解析结果。"""
del config_data, use_provided_config
return SimpleNamespace(enabled=True, normalized_config={"enabled": True}, plugin_id=plugin_id)
async def notify_plugin_config_updated(
self,
plugin_id,
@@ -2997,6 +3070,110 @@ class TestIntegration:
assert manager._builtin_supervisor.config_updates == [("test.alpha", {"enabled": True}, "", "self")]
assert manager._third_party_supervisor.config_updates == []
@pytest.mark.asyncio
async def test_handle_plugin_config_changes_loads_unloaded_enabled_plugin(self, monkeypatch, tmp_path):
from src.plugin_runtime import integration as integration_module
from src.config.file_watcher import FileChange
import json
thirdparty_root = tmp_path / "plugins"
alpha_dir = thirdparty_root / "alpha"
alpha_dir.mkdir(parents=True)
(alpha_dir / "config.toml").write_text("[plugin]\nenabled = true\n", encoding="utf-8")
(alpha_dir / "plugin.py").write_text("def create_plugin():\n return object()\n", encoding="utf-8")
(alpha_dir / "_manifest.json").write_text(json.dumps(build_test_manifest("test.alpha")), encoding="utf-8")
monkeypatch.chdir(tmp_path)
class FakeSupervisor:
def __init__(self, plugin_dirs):
self._plugin_dirs = plugin_dirs
self._registered_plugins = {}
async def inspect_plugin_config(
self,
plugin_id: str,
config_data: Optional[Dict[str, Any]] = None,
use_provided_config: bool = False,
) -> SimpleNamespace:
"""返回测试用的启用配置快照。"""
del config_data, use_provided_config
return SimpleNamespace(enabled=True, normalized_config={"plugin": {"enabled": True}}, plugin_id=plugin_id)
manager = integration_module.PluginRuntimeManager()
manager._started = True
manager._third_party_supervisor = FakeSupervisor([thirdparty_root])
load_calls = []
async def fake_load_plugin_globally(plugin_id: str, reason: str = "manual") -> bool:
"""记录自动加载调用。"""
load_calls.append((plugin_id, reason))
return True
monkeypatch.setattr(manager, "load_plugin_globally", fake_load_plugin_globally)
await manager._handle_plugin_config_changes(
"test.alpha",
[FileChange(change_type=1, path=alpha_dir / "config.toml")],
)
assert load_calls == [("test.alpha", "config_enabled")]
@pytest.mark.asyncio
async def test_handle_plugin_config_changes_unloads_loaded_disabled_plugin(self, monkeypatch, tmp_path):
from src.plugin_runtime import integration as integration_module
from src.config.file_watcher import FileChange
import json
builtin_root = tmp_path / "src" / "plugins" / "built_in"
alpha_dir = builtin_root / "alpha"
alpha_dir.mkdir(parents=True)
(alpha_dir / "config.toml").write_text("[plugin]\nenabled = false\n", encoding="utf-8")
(alpha_dir / "plugin.py").write_text("def create_plugin():\n return object()\n", encoding="utf-8")
(alpha_dir / "_manifest.json").write_text(json.dumps(build_test_manifest("test.alpha")), encoding="utf-8")
monkeypatch.chdir(tmp_path)
class FakeSupervisor:
def __init__(self, plugin_dirs, plugins):
self._plugin_dirs = plugin_dirs
self._registered_plugins = {plugin_id: object() for plugin_id in plugins}
async def inspect_plugin_config(
self,
plugin_id: str,
config_data: Optional[Dict[str, Any]] = None,
use_provided_config: bool = False,
) -> SimpleNamespace:
"""返回测试用的禁用配置快照。"""
del config_data, use_provided_config
return SimpleNamespace(
enabled=False,
normalized_config={"plugin": {"enabled": False}},
plugin_id=plugin_id,
)
manager = integration_module.PluginRuntimeManager()
manager._started = True
manager._builtin_supervisor = FakeSupervisor([builtin_root], ["test.alpha"])
reload_calls = []
async def fake_reload_plugins_globally(plugin_ids: Sequence[str], reason: str = "manual") -> bool:
"""记录自动卸载调用。"""
reload_calls.append((list(plugin_ids), reason))
return True
monkeypatch.setattr(manager, "reload_plugins_globally", fake_reload_plugins_globally)
await manager._handle_plugin_config_changes(
"test.alpha",
[FileChange(change_type=1, path=alpha_dir / "config.toml")],
)
assert reload_calls == [(["test.alpha"], "config_disabled")]
@pytest.mark.asyncio
async def test_handle_main_config_reload_only_notifies_subscribers(self, monkeypatch):
from src.plugin_runtime import integration as integration_module
@@ -3108,6 +3285,55 @@ class TestIntegration:
subscription["paths"][0] for subscription in manager._plugin_file_watcher.subscriptions
} == {alpha_dir / "config.toml", beta_dir / "config.toml"}
def test_refresh_plugin_config_watch_subscriptions_includes_unloaded_plugins(self, tmp_path):
from src.plugin_runtime import integration as integration_module
import json
thirdparty_root = tmp_path / "plugins"
alpha_dir = thirdparty_root / "alpha"
beta_dir = thirdparty_root / "beta"
alpha_dir.mkdir(parents=True)
beta_dir.mkdir(parents=True)
(alpha_dir / "plugin.py").write_text("def create_plugin():\n return object()\n", encoding="utf-8")
(beta_dir / "plugin.py").write_text("def create_plugin():\n return object()\n", encoding="utf-8")
(alpha_dir / "_manifest.json").write_text(json.dumps(build_test_manifest("test.alpha")), encoding="utf-8")
(beta_dir / "_manifest.json").write_text(json.dumps(build_test_manifest("test.beta")), encoding="utf-8")
class FakeWatcher:
def __init__(self):
self.subscriptions = []
def subscribe(
self,
callback: Any,
*,
paths: Optional[Sequence[Path]] = None,
change_types: Any = None,
) -> str:
"""记录新的监听订阅。"""
del callback, change_types
subscription_id = f"sub-{len(self.subscriptions) + 1}"
self.subscriptions.append({"id": subscription_id, "paths": tuple(paths or ())})
return subscription_id
def unsubscribe(self, subscription_id: str) -> bool:
"""兼容 watcher 取消订阅接口。"""
del subscription_id
return True
class FakeSupervisor:
def __init__(self, plugin_dirs, plugins):
self._plugin_dirs = plugin_dirs
self._registered_plugins = {plugin_id: object() for plugin_id in plugins}
manager = integration_module.PluginRuntimeManager()
manager._plugin_file_watcher = FakeWatcher()
manager._third_party_supervisor = FakeSupervisor([thirdparty_root], ["test.alpha"])
manager._refresh_plugin_config_watch_subscriptions()
assert set(manager._plugin_config_watcher_subscriptions.keys()) == {"test.alpha", "test.beta"}
@pytest.mark.asyncio
async def test_component_reload_plugin_returns_failure_when_reload_rolls_back(self, monkeypatch):
from src.plugin_runtime import integration as integration_module

View File

@@ -1,6 +1,8 @@
"""聊天消息入口与主链路调度。"""
from contextlib import suppress
from copy import deepcopy
from typing import Any, Dict, Optional
from typing import Any, Dict, List, Optional
import os
import traceback
@@ -13,12 +15,15 @@ from src.common.utils.utils_message import MessageUtils
from src.common.utils.utils_session import SessionUtils
from src.config.config import global_config
from src.platform_io.route_key_factory import RouteKeyFactory
from src.core.announcement_manager import global_announcement_manager
from src.plugin_runtime.component_query import component_query_service
from src.plugin_runtime.hook_payloads import deserialize_session_message, serialize_session_message
from src.plugin_runtime.hook_schema_utils import build_object_schema
from src.plugin_runtime.host.hook_dispatcher import HookDispatchResult
from src.plugin_runtime.host.hook_spec_registry import HookSpec, HookSpecRegistry
from .message import SessionMessage
from .chat_manager import chat_manager
from .message import SessionMessage
# 定义日志配置
@@ -29,7 +34,137 @@ PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../..
logger = get_logger("chat")
def register_chat_hook_specs(registry: HookSpecRegistry) -> List[HookSpec]:
"""注册聊天消息主链内置 Hook 规格。
Args:
registry: 目标 Hook 规格注册中心。
Returns:
List[HookSpec]: 实际注册的 Hook 规格列表。
"""
return registry.register_hook_specs(
[
HookSpec(
name="chat.receive.before_process",
description="在入站消息执行 `SessionMessage.process()` 之前触发,可拦截或改写消息。",
parameters_schema=build_object_schema(
{
"message": {
"type": "object",
"description": "当前入站消息的序列化 SessionMessage。",
},
},
required=["message"],
),
default_timeout_ms=8000,
allow_abort=True,
allow_kwargs_mutation=True,
),
HookSpec(
name="chat.receive.after_process",
description="在入站消息完成轻量预处理后触发,可改写文本、消息体或中止后续链路。",
parameters_schema=build_object_schema(
{
"message": {
"type": "object",
"description": "已完成 `process()` 的序列化 SessionMessage。",
},
},
required=["message"],
),
default_timeout_ms=8000,
allow_abort=True,
allow_kwargs_mutation=True,
),
HookSpec(
name="chat.command.before_execute",
description="在命令匹配成功、实际执行前触发,可拦截命令或改写命令上下文。",
parameters_schema=build_object_schema(
{
"message": {
"type": "object",
"description": "当前命令消息的序列化 SessionMessage。",
},
"command_name": {
"type": "string",
"description": "命中的命令名称。",
},
"plugin_id": {
"type": "string",
"description": "命令所属插件 ID。",
},
"matched_groups": {
"type": "object",
"description": "命令正则命名捕获结果。",
},
},
required=["message", "command_name", "plugin_id", "matched_groups"],
),
default_timeout_ms=5000,
allow_abort=True,
allow_kwargs_mutation=True,
),
HookSpec(
name="chat.command.after_execute",
description="在命令执行结束后触发,可调整返回文本和是否继续主链处理。",
parameters_schema=build_object_schema(
{
"message": {
"type": "object",
"description": "当前命令消息的序列化 SessionMessage。",
},
"command_name": {
"type": "string",
"description": "命令名称。",
},
"plugin_id": {
"type": "string",
"description": "命令所属插件 ID。",
},
"matched_groups": {
"type": "object",
"description": "命令正则命名捕获结果。",
},
"success": {
"type": "boolean",
"description": "命令执行是否成功。",
},
"response": {
"type": "string",
"description": "命令返回文本。",
},
"intercept_message_level": {
"type": "integer",
"description": "命令拦截等级。",
},
"continue_process": {
"type": "boolean",
"description": "命令执行后是否继续后续消息处理。",
},
},
required=[
"message",
"command_name",
"plugin_id",
"matched_groups",
"success",
"intercept_message_level",
"continue_process",
],
),
default_timeout_ms=5000,
allow_abort=False,
allow_kwargs_mutation=True,
),
]
)
class ChatBot:
"""聊天机器人入口协调器。"""
def __init__(self) -> None:
"""初始化聊天机器人入口。"""
@@ -44,6 +179,66 @@ class ChatBot:
self._started = True
@staticmethod
def _get_runtime_manager() -> Any:
"""获取插件运行时管理器。
Returns:
Any: 插件运行时管理器单例。
"""
from src.plugin_runtime.integration import get_plugin_runtime_manager
return get_plugin_runtime_manager()
@staticmethod
def _coerce_int(value: Any, default: int) -> int:
"""将任意值安全转换为整数。
Args:
value: 待转换的值。
default: 转换失败时的默认值。
Returns:
int: 转换后的整数结果。
"""
try:
return int(value)
except (TypeError, ValueError):
return default
async def _invoke_message_hook(
self,
hook_name: str,
message: SessionMessage,
**kwargs: Any,
) -> tuple[HookDispatchResult, SessionMessage]:
"""触发携带会话消息的命名 Hook。
Args:
hook_name: 目标 Hook 名称。
message: 当前会话消息。
**kwargs: 需要附带传递的额外参数。
Returns:
tuple[HookDispatchResult, SessionMessage]: Hook 聚合结果以及可能被改写后的消息对象。
"""
hook_result = await self._get_runtime_manager().invoke_hook(
hook_name,
message=serialize_session_message(message),
**kwargs,
)
mutated_message = message
raw_message = hook_result.kwargs.get("message")
if raw_message is not None:
try:
mutated_message = deserialize_session_message(raw_message)
except Exception as exc:
logger.warning(f"Hook {hook_name} 返回的 message 无法反序列化,已忽略: {exc}")
return hook_result, mutated_message
async def _process_commands(self, message: SessionMessage) -> tuple[bool, Optional[str], bool]:
"""使用统一组件注册表处理命令。
@@ -71,6 +266,25 @@ class ChatBot:
return False, None, True
message.is_command = True
before_result, message = await self._invoke_message_hook(
"chat.command.before_execute",
message,
command_name=command_name,
plugin_id=plugin_name,
matched_groups=dict(matched_groups),
)
if before_result.aborted:
logger.info(f"命令 {command_name} 被 Hook 中止,跳过命令执行")
return True, None, False
hook_kwargs = before_result.kwargs
command_name = str(hook_kwargs.get("command_name", command_name) or command_name)
plugin_name = str(hook_kwargs.get("plugin_id", plugin_name) or plugin_name)
matched_groups = (
dict(hook_kwargs["matched_groups"])
if isinstance(hook_kwargs.get("matched_groups"), dict)
else dict(matched_groups)
)
# 获取插件配置
plugin_config = component_query_service.get_plugin_config(plugin_name)
@@ -82,27 +296,43 @@ class ChatBot:
plugin_config=plugin_config,
matched_groups=matched_groups,
)
self._mark_command_message(message, intercept_message_level)
# 记录命令执行结果
if success:
logger.info(f"命令执行成功: {command_name} (拦截等级: {intercept_message_level})")
else:
logger.warning(f"命令执行失败: {command_name} - {response}")
# 根据命令的拦截设置决定是否继续处理消息
return (
True,
response,
not bool(intercept_message_level),
) # 找到命令根据intercept_message决定是否继续
except Exception as e:
logger.error(f"执行命令时出错: {command_name} - {e}")
continue_process = not bool(intercept_message_level)
except Exception as exc:
logger.error(f"执行命令时出错: {command_name} - {exc}")
logger.error(traceback.format_exc())
success = False
response = str(exc)
intercept_message_level = 1
continue_process = False
# 命令出错时,根据命令的拦截设置决定是否继续处理消息
return True, str(e), False # 出错时继续处理消息
after_result, message = await self._invoke_message_hook(
"chat.command.after_execute",
message,
command_name=command_name,
plugin_id=plugin_name,
matched_groups=dict(matched_groups),
success=success,
response=response,
intercept_message_level=intercept_message_level,
continue_process=continue_process,
)
after_kwargs = after_result.kwargs
success = bool(after_kwargs.get("success", success))
raw_response = after_kwargs.get("response", response)
response = None if raw_response is None else str(raw_response)
intercept_message_level = self._coerce_int(
after_kwargs.get("intercept_message_level", intercept_message_level),
intercept_message_level,
)
continue_process = bool(after_kwargs.get("continue_process", continue_process))
self._mark_command_message(message, intercept_message_level)
if success:
logger.info(f"命令执行成功: {command_name} (拦截等级: {intercept_message_level})")
else:
logger.warning(f"命令执行失败: {command_name} - {response}")
return True, response, continue_process
return False, None, True
@@ -138,6 +368,17 @@ class ChatBot:
cmd_result: Optional[str],
continue_process: bool,
) -> bool:
"""处理命令链结果并决定是否终止主消息链。
Args:
message: 当前命令消息。
cmd_result: 命令响应文本。
continue_process: 是否继续后续主链处理。
Returns:
bool: ``True`` 表示已经终止后续主链。
"""
if continue_process:
return False
@@ -145,9 +386,18 @@ class ChatBot:
logger.info(f"命令处理完成,跳过后续消息处理: {cmd_result}")
return True
async def handle_notice_message(self, message: SessionMessage):
async def handle_notice_message(self, message: SessionMessage) -> bool:
"""处理通知类消息。
Args:
message: 当前通知消息。
Returns:
bool: 当前消息是否为通知消息。
"""
if message.message_id != "notice":
return
return False
message.is_notify = True
logger.debug("notice消息")
@@ -203,9 +453,12 @@ class ChatBot:
return True
async def echo_message_process(self, raw_data: Dict[str, Any]) -> None:
"""处理消息回送 ID 对应关系。
Args:
raw_data: 平台适配器上报的原始回送载荷。
"""
用于专门处理回送消息ID的函数
"""
message_data: Dict[str, Any] = raw_data.get("content", {})
if not message_data:
return
@@ -218,18 +471,10 @@ class ChatBot:
logger.debug(f"收到回送消息ID: {mmc_message_id} -> {actual_message_id}")
async def message_process(self, message_data: Dict[str, Any]) -> None:
"""处理转化后的统一格式消息
这个函数本质是预处理一些数据,根据配置信息和消息内容,预处理消息,并分发到合适的消息处理器中
heart_flow模式使用思维流系统进行回复
- 包含思维流状态管理
- 在回复前进行观察和状态更新
- 回复后更新思维流状态
- 消息过滤
- 记忆激活
- 意愿计算
- 消息生成和发送
- 表情包处理
- 性能计时
"""处理统一格式的入站消息字典。
Args:
message_data: 适配器整理后的统一消息字典。
"""
try:
# 确保所有任务已启动
@@ -253,7 +498,13 @@ class ChatBot:
logger.error(f"预处理消息失败: {e}")
traceback.print_exc()
async def receive_message(self, message: SessionMessage):
async def receive_message(self, message: SessionMessage) -> None:
"""处理单条入站会话消息。
Args:
message: 待处理的会话消息。
"""
try:
group_info = message.message_info.group_info
user_info = message.message_info.user_info
@@ -272,6 +523,19 @@ class ChatBot:
)
message.session_id = session_id # 正确初始化session_id
before_process_result, message = await self._invoke_message_hook(
"chat.receive.before_process",
message,
)
if before_process_result.aborted:
logger.info(f"消息 {message.message_id} 在预处理前被 Hook 中止")
return
group_info = message.message_info.group_info
user_info = message.message_info.user_info
additional_config = message.message_info.additional_config
if isinstance(additional_config, dict):
account_id, scope = RouteKeyFactory.extract_components(additional_config)
# TODO: 修复事件预处理部分
# continue_flag, modified_message = await events_manager.handle_mai_events(
@@ -294,6 +558,16 @@ class ChatBot:
enable_heavy_media_analysis=False,
enable_voice_transcription=False,
)
after_process_result, message = await self._invoke_message_hook(
"chat.receive.after_process",
message,
)
if after_process_result.aborted:
logger.info(f"消息 {message.message_id} 在预处理后被 Hook 中止")
return
group_info = message.message_info.group_info
user_info = message.message_info.user_info
# 平台层的 @ 检测由底层 is_mentioned_bot_in_message 统一处理;此处不做用户名硬编码匹配

View File

@@ -3,7 +3,7 @@
from dataclasses import dataclass
from datetime import datetime
from time import perf_counter
from typing import List, Optional, Sequence
from typing import Any, List, Optional, Sequence
import asyncio
import json
@@ -26,6 +26,15 @@ from src.llm_models.model_client.base_client import BaseClient
from src.llm_models.payload_content.message import Message, MessageBuilder, RoleType
from src.llm_models.payload_content.resp_format import RespFormat, RespFormatType
from src.llm_models.payload_content.tool_option import ToolCall, ToolDefinitionInput, ToolOption, normalize_tool_options
from src.plugin_runtime.hook_payloads import (
deserialize_prompt_messages,
deserialize_tool_calls,
serialize_prompt_messages,
serialize_tool_calls,
serialize_tool_definitions,
)
from src.plugin_runtime.hook_schema_utils import build_object_schema
from src.plugin_runtime.host.hook_spec_registry import HookSpec, HookSpecRegistry
from src.services.llm_service import LLMServiceClient
from .builtin_tools import get_builtin_tools
@@ -58,6 +67,123 @@ class ToolFilterSelection(BaseModel):
logger = get_logger("maisaka_chat_loop")
def register_maisaka_hook_specs(registry: HookSpecRegistry) -> List[HookSpec]:
"""注册 Maisaka 规划器内置 Hook 规格。
Args:
registry: 目标 Hook 规格注册中心。
Returns:
List[HookSpec]: 实际注册的 Hook 规格列表。
"""
return registry.register_hook_specs(
[
HookSpec(
name="maisaka.planner.before_request",
description="在 Maisaka 向模型发起规划请求前触发,可改写消息窗口与工具定义。",
parameters_schema=build_object_schema(
{
"messages": {
"type": "array",
"description": "即将发给模型的 PromptMessage 列表。",
},
"tool_definitions": {
"type": "array",
"description": "当前候选工具定义列表。",
},
"selected_history_count": {
"type": "integer",
"description": "当前选中的上下文消息数量。",
},
"built_message_count": {
"type": "integer",
"description": "实际发送给模型的消息数量。",
},
"selection_reason": {
"type": "string",
"description": "上下文选择说明。",
},
"session_id": {
"type": "string",
"description": "当前会话 ID。",
},
},
required=[
"messages",
"tool_definitions",
"selected_history_count",
"built_message_count",
"selection_reason",
"session_id",
],
),
default_timeout_ms=6000,
allow_abort=False,
allow_kwargs_mutation=True,
),
HookSpec(
name="maisaka.planner.after_response",
description="在 Maisaka 收到模型响应后触发,可调整文本结果与工具调用列表。",
parameters_schema=build_object_schema(
{
"response": {
"type": "string",
"description": "模型返回的文本内容。",
},
"tool_calls": {
"type": "array",
"description": "模型返回的工具调用列表。",
},
"selected_history_count": {
"type": "integer",
"description": "当前选中的上下文消息数量。",
},
"built_message_count": {
"type": "integer",
"description": "实际发送给模型的消息数量。",
},
"selection_reason": {
"type": "string",
"description": "上下文选择说明。",
},
"session_id": {
"type": "string",
"description": "当前会话 ID。",
},
"prompt_tokens": {
"type": "integer",
"description": "输入 Token 数。",
},
"completion_tokens": {
"type": "integer",
"description": "输出 Token 数。",
},
"total_tokens": {
"type": "integer",
"description": "总 Token 数。",
},
},
required=[
"response",
"tool_calls",
"selected_history_count",
"built_message_count",
"selection_reason",
"session_id",
"prompt_tokens",
"completion_tokens",
"total_tokens",
],
),
default_timeout_ms=6000,
allow_abort=False,
allow_kwargs_mutation=True,
),
]
)
class MaisakaChatLoopService:
"""负责 Maisaka 主对话循环、系统提示词和终端渲染。"""
@@ -105,6 +231,35 @@ class MaisakaChatLoopService:
return self._personality_prompt
@staticmethod
def _get_runtime_manager() -> Any:
"""获取插件运行时管理器。
Returns:
Any: 插件运行时管理器单例。
"""
from src.plugin_runtime.integration import get_plugin_runtime_manager
return get_plugin_runtime_manager()
@staticmethod
def _coerce_int(value: Any, default: int) -> int:
"""将任意值安全转换为整数。
Args:
value: 待转换的输入值。
default: 转换失败时的默认值。
Returns:
int: 转换后的整数结果。
"""
try:
return int(value)
except (TypeError, ValueError):
return default
def _build_personality_prompt(self) -> str:
"""构造人格提示词。"""
@@ -580,6 +735,26 @@ class MaisakaChatLoopService:
else:
all_tools = [*get_builtin_tools(), *self._extra_tools]
before_request_result = await self._get_runtime_manager().invoke_hook(
"maisaka.planner.before_request",
messages=serialize_prompt_messages(built_messages),
tool_definitions=serialize_tool_definitions(all_tools),
selected_history_count=len(selected_history),
built_message_count=len(built_messages),
selection_reason=selection_reason,
session_id=self._session_id,
)
before_request_kwargs = before_request_result.kwargs
raw_messages = before_request_kwargs.get("messages")
if isinstance(raw_messages, list):
try:
built_messages = deserialize_prompt_messages(raw_messages)
except Exception as exc:
logger.warning(f"Hook maisaka.planner.before_request 返回的 messages 无法反序列化,已忽略: {exc}")
raw_tool_definitions = before_request_kwargs.get("tool_definitions")
if isinstance(raw_tool_definitions, list):
all_tools = [item for item in raw_tool_definitions if isinstance(item, dict)]
ordered_panels = PromptCLIVisualizer.build_prompt_panels(
built_messages,
image_display_mode=global_config.maisaka.terminal_image_display_mode,
@@ -625,33 +800,63 @@ class MaisakaChatLoopService:
)
logger.info(f"本轮Prompt统计: {prompt_stats_text}")
final_response = generation_result.response or ""
final_tool_calls = list(generation_result.tool_calls or [])
after_response_result = await self._get_runtime_manager().invoke_hook(
"maisaka.planner.after_response",
response=final_response,
tool_calls=serialize_tool_calls(final_tool_calls),
selected_history_count=len(selected_history),
built_message_count=len(built_messages),
selection_reason=selection_reason,
session_id=self._session_id,
prompt_tokens=generation_result.prompt_tokens,
completion_tokens=generation_result.completion_tokens,
total_tokens=generation_result.total_tokens,
)
after_response_kwargs = after_response_result.kwargs
if "response" in after_response_kwargs:
final_response = str(after_response_kwargs.get("response") or "")
raw_tool_calls = after_response_kwargs.get("tool_calls")
if isinstance(raw_tool_calls, list):
try:
final_tool_calls = deserialize_tool_calls(raw_tool_calls)
except Exception as exc:
logger.warning(f"Hook maisaka.planner.after_response 返回的 tool_calls 无法反序列化,已忽略: {exc}")
prompt_tokens = self._coerce_int(after_response_kwargs.get("prompt_tokens"), generation_result.prompt_tokens)
completion_tokens = self._coerce_int(
after_response_kwargs.get("completion_tokens"),
generation_result.completion_tokens,
)
total_tokens = self._coerce_int(after_response_kwargs.get("total_tokens"), generation_result.total_tokens)
tool_call_summaries = [
{
"调用编号": getattr(tool_call, "call_id", getattr(tool_call, "id", None)),
"工具名": getattr(tool_call, "func_name", getattr(tool_call, "name", None)),
"参数": getattr(tool_call, "args", getattr(tool_call, "arguments", None)),
}
for tool_call in (generation_result.tool_calls or [])
for tool_call in final_tool_calls
]
logger.info(
f"Maisaka 规划器返回结果: 内容={generation_result.response or ''!r} "
f"Maisaka 规划器返回结果: 内容={final_response!r} "
f"工具调用={tool_call_summaries}"
)
raw_message = AssistantMessage(
content=generation_result.response or "",
content=final_response,
timestamp=datetime.now(),
tool_calls=generation_result.tool_calls or [],
tool_calls=final_tool_calls,
)
return ChatResponse(
content=generation_result.response,
tool_calls=generation_result.tool_calls or [],
content=final_response or None,
tool_calls=final_tool_calls,
raw_message=raw_message,
selected_history_count=len(selected_history),
prompt_tokens=generation_result.prompt_tokens,
prompt_tokens=prompt_tokens,
built_message_count=len(built_messages),
completion_tokens=generation_result.completion_tokens,
total_tokens=generation_result.total_tokens,
completion_tokens=completion_tokens,
total_tokens=total_tokens,
)
@staticmethod

View File

@@ -6,6 +6,7 @@
from __future__ import annotations
from copy import deepcopy
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Optional, Tuple, cast
from src.common.logger import get_logger
@@ -908,5 +909,27 @@ class ComponentQueryService:
return None
return dict(registration.config_schema)
def list_hook_specs(self) -> list[dict[str, Any]]:
"""返回当前运行时公开的 Hook 规格清单。
Returns:
list[dict[str, Any]]: 可直接序列化给 WebUI 的 Hook 规格列表。
"""
runtime_manager = self._get_runtime_manager()
return [
{
"name": spec.name,
"description": spec.description,
"parameters_schema": deepcopy(spec.parameters_schema),
"default_timeout_ms": spec.default_timeout_ms,
"allow_blocking": spec.allow_blocking,
"allow_observe": spec.allow_observe,
"allow_abort": spec.allow_abort,
"allow_kwargs_mutation": spec.allow_kwargs_mutation,
}
for spec in runtime_manager.list_hook_specs()
]
component_query_service = ComponentQueryService()

View File

@@ -0,0 +1,46 @@
"""内置命名 Hook 目录注册器。"""
from __future__ import annotations
from collections.abc import Callable
from typing import List
from src.plugin_runtime.host.hook_spec_registry import HookSpec, HookSpecRegistry
HookSpecRegistrar = Callable[[HookSpecRegistry], List[HookSpec]]
"""单个业务模块向注册中心写入 Hook 规格的注册器签名。"""
def _get_builtin_hook_spec_registrars() -> List[HookSpecRegistrar]:
"""返回当前内置 Hook 规格注册器列表。
Returns:
List[HookSpecRegistrar]: 已启用的内置 Hook 注册器列表。
"""
from src.chat.message_receive.bot import register_chat_hook_specs
from src.maisaka.chat_loop_service import register_maisaka_hook_specs
from src.services.send_service import register_send_service_hook_specs
return [
register_chat_hook_specs,
register_send_service_hook_specs,
register_maisaka_hook_specs,
]
def register_builtin_hook_specs(registry: HookSpecRegistry) -> List[HookSpec]:
"""向注册中心写入全部内置 Hook 规格。
Args:
registry: 目标 Hook 规格注册中心。
Returns:
List[HookSpec]: 本次完成注册后的全部内置 Hook 规格。
"""
registered_specs: List[HookSpec] = []
for registrar in _get_builtin_hook_spec_registrars():
registered_specs.extend(registrar(registry))
return registered_specs

View File

@@ -0,0 +1,178 @@
"""运行时 Hook 载荷序列化辅助。"""
from __future__ import annotations
from typing import Any, Dict, List, Sequence
from src.chat.message_receive.message import SessionMessage
from src.common.data_models.llm_service_data_models import PromptMessage
from src.llm_models.payload_content.message import Message
from src.llm_models.payload_content.tool_option import ToolCall, ToolDefinitionInput, normalize_tool_options
from src.plugin_runtime.host.message_utils import PluginMessageUtils
def serialize_session_message(message: SessionMessage) -> Dict[str, Any]:
"""将会话消息序列化为 Hook 可传输载荷。
Args:
message: 待序列化的会话消息。
Returns:
Dict[str, Any]: 可通过插件运行时传输的消息字典。
"""
return dict(PluginMessageUtils._session_message_to_dict(message))
def deserialize_session_message(raw_message: Any) -> SessionMessage:
"""从 Hook 载荷恢复会话消息。
Args:
raw_message: Hook 返回的消息字典。
Returns:
SessionMessage: 恢复后的会话消息对象。
Raises:
ValueError: 消息结构不合法时抛出。
"""
if not isinstance(raw_message, dict):
raise ValueError("Hook 返回的 `message` 必须是字典")
return PluginMessageUtils._build_session_message_from_dict(raw_message)
def serialize_tool_calls(tool_calls: Sequence[ToolCall] | None) -> List[Dict[str, Any]]:
"""将工具调用列表序列化为 Hook 可传输载荷。
Args:
tool_calls: 原始工具调用列表。
Returns:
List[Dict[str, Any]]: 序列化后的工具调用列表。
"""
if not tool_calls:
return []
return [
{
"id": tool_call.call_id,
"function": {
"name": tool_call.func_name,
"arguments": dict(tool_call.args or {}),
},
}
for tool_call in tool_calls
]
def deserialize_tool_calls(raw_tool_calls: Any) -> List[ToolCall]:
"""从 Hook 载荷恢复工具调用列表。
Args:
raw_tool_calls: Hook 返回的工具调用列表。
Returns:
List[ToolCall]: 恢复后的工具调用列表。
Raises:
ValueError: 结构不合法时抛出。
"""
if raw_tool_calls in (None, []):
return []
if not isinstance(raw_tool_calls, list):
raise ValueError("Hook 返回的 `tool_calls` 必须是列表")
normalized_tool_calls: List[ToolCall] = []
for raw_tool_call in raw_tool_calls:
if not isinstance(raw_tool_call, dict):
raise ValueError("Hook 返回的工具调用项必须是字典")
function_info = raw_tool_call.get("function", {})
if isinstance(function_info, dict):
function_name = function_info.get("name")
function_arguments = function_info.get("arguments")
else:
function_name = raw_tool_call.get("name")
function_arguments = raw_tool_call.get("arguments")
call_id = raw_tool_call.get("id") or raw_tool_call.get("call_id")
if not isinstance(call_id, str) or not isinstance(function_name, str):
raise ValueError("Hook 返回的工具调用缺少 `id` 或函数名称")
normalized_tool_calls.append(
ToolCall(
call_id=call_id,
func_name=function_name,
args=function_arguments if isinstance(function_arguments, dict) else {},
)
)
return normalized_tool_calls
def serialize_prompt_messages(messages: Sequence[Message]) -> List[PromptMessage]:
"""将 LLM 消息列表序列化为 Hook 可传输载荷。
Args:
messages: 原始 LLM 消息列表。
Returns:
List[PromptMessage]: 序列化后的消息字典列表。
"""
serialized_messages: List[PromptMessage] = []
for message in messages:
serialized_message: PromptMessage = {
"role": message.role.value,
"content": message.content,
}
if message.tool_call_id:
serialized_message["tool_call_id"] = message.tool_call_id
if message.tool_calls:
serialized_message["tool_calls"] = serialize_tool_calls(message.tool_calls)
serialized_messages.append(serialized_message)
return serialized_messages
def deserialize_prompt_messages(raw_messages: Any) -> List[Message]:
"""从 Hook 载荷恢复 LLM 消息列表。
Args:
raw_messages: Hook 返回的消息列表。
Returns:
List[Message]: 恢复后的 LLM 消息列表。
Raises:
ValueError: 结构不合法时抛出。
"""
if not isinstance(raw_messages, list):
raise ValueError("Hook 返回的 `messages` 必须是列表")
from src.services.llm_service import _build_message_from_dict
normalized_messages: List[Message] = []
for raw_message in raw_messages:
if not isinstance(raw_message, dict):
raise ValueError("Hook 返回的消息项必须是字典")
normalized_messages.append(_build_message_from_dict(raw_message))
return normalized_messages
def serialize_tool_definitions(tool_definitions: Sequence[ToolDefinitionInput]) -> List[Dict[str, Any]]:
"""将工具定义列表序列化为 Hook 可传输载荷。
Args:
tool_definitions: 原始工具定义列表。
Returns:
List[Dict[str, Any]]: 序列化后的工具定义列表。
"""
normalized_tool_options = normalize_tool_options(list(tool_definitions))
if not normalized_tool_options:
return []
return [tool_option.to_openai_function_schema() for tool_option in normalized_tool_options]

View File

@@ -0,0 +1,31 @@
"""Hook 参数模型构造辅助。"""
from __future__ import annotations
from copy import deepcopy
from typing import Any, Dict, Sequence
def build_object_schema(
properties: Dict[str, Dict[str, Any]],
*,
required: Sequence[str] | None = None,
) -> Dict[str, Any]:
"""构造对象级 JSON Schema。
Args:
properties: 字段定义映射。
required: 必填字段名列表。
Returns:
Dict[str, Any]: 标准化后的对象级 Schema。
"""
schema: Dict[str, Any] = {
"type": "object",
"properties": deepcopy(properties),
}
normalized_required = [str(item).strip() for item in (required or []) if str(item).strip()]
if normalized_required:
schema["required"] = normalized_required
return schema

View File

@@ -18,9 +18,37 @@ import re
from src.common.logger import get_logger
from src.core.tooling import build_tool_detailed_description
from .hook_spec_registry import HookSpecRegistry
logger = get_logger("plugin_runtime.host.component_registry")
class ComponentRegistrationError(ValueError):
"""组件注册失败异常。"""
def __init__(
self,
message: str,
*,
component_name: str = "",
component_type: str = "",
plugin_id: str = "",
) -> None:
"""初始化组件注册失败异常。
Args:
message: 原始错误信息。
component_name: 组件名称。
component_type: 组件类型。
plugin_id: 插件 ID。
"""
self.component_name = str(component_name or "").strip()
self.component_type = str(component_type or "").strip()
self.plugin_id = str(plugin_id or "").strip()
super().__init__(message)
class ComponentTypes(str, Enum):
ACTION = "ACTION"
COMMAND = "COMMAND"
@@ -359,7 +387,14 @@ class ComponentRegistry:
供业务层查询可用组件、匹配命令、调度 action/event 等。
"""
def __init__(self) -> None:
def __init__(self, hook_spec_registry: Optional[HookSpecRegistry] = None) -> None:
"""初始化组件注册表。
Args:
hook_spec_registry: 可选的 Hook 规格注册中心;提供后会在注册
HookHandler 时执行规格校验。
"""
# 全量索引
self._components: Dict[str, ComponentEntry] = {} # full_name -> comp
@@ -370,6 +405,7 @@ class ComponentRegistry:
# 按插件索引
self._by_plugin: Dict[str, List[ComponentEntry]] = {}
self._hook_spec_registry = hook_spec_registry
@staticmethod
def _convert_action_metadata_to_tool_metadata(
@@ -475,77 +511,211 @@ class ComponentRegistry:
type_dict.clear()
self._by_plugin.clear()
# ====== 注册 / 注销 ======
def register_component(self, name: str, component_type: str, plugin_id: str, metadata: Dict[str, Any]) -> bool:
"""注册单个组件
@staticmethod
def _is_legacy_action_component(component: ComponentEntry) -> bool:
"""判断组件是否为兼容旧 Action 的 Tool 条目。
Args:
name: 组件名称不含插件id前缀
component_type: 组件类型(如 `ACTION`、`COMMAND` 等)
plugin_id: 插件id
metadata: 组件元数据
component: 待判断的组件条目。
Returns:
success (bool): 是否成功注册(失败原因通常是组件类型无效)
bool: 是否为兼容旧 Action 组件。
"""
if not isinstance(component, ToolEntry):
return False
return str(component.metadata.get("legacy_component_type", "") or "").strip().upper() == "ACTION"
def _validate_hook_handler_entry(self, component: HookHandlerEntry) -> None:
"""校验 HookHandler 是否满足已注册的 Hook 规格。
Args:
component: 待校验的 HookHandler 条目。
Raises:
ComponentRegistrationError: HookHandler 声明不合法时抛出。
"""
if self._hook_spec_registry is None:
return
hook_spec = self._hook_spec_registry.get_hook_spec(component.hook)
if hook_spec is None:
raise ComponentRegistrationError(
f"HookHandler {component.full_name} 声明了未注册的 Hook: {component.hook}",
component_name=component.name,
component_type=component.component_type.value,
plugin_id=component.plugin_id,
)
if component.is_blocking and not hook_spec.allow_blocking:
raise ComponentRegistrationError(
f"HookHandler {component.full_name} 不能注册为 blockingHook {component.hook} 不允许 blocking 处理器",
component_name=component.name,
component_type=component.component_type.value,
plugin_id=component.plugin_id,
)
if component.is_observe and not hook_spec.allow_observe:
raise ComponentRegistrationError(
f"HookHandler {component.full_name} 不能注册为 observeHook {component.hook} 不允许 observe 处理器",
component_name=component.name,
component_type=component.component_type.value,
plugin_id=component.plugin_id,
)
if component.error_policy == "abort" and not hook_spec.allow_abort:
raise ComponentRegistrationError(
f"HookHandler {component.full_name} 不能使用 error_policy=abortHook {component.hook} 不允许 abort",
component_name=component.name,
component_type=component.component_type.value,
plugin_id=component.plugin_id,
)
def _build_component_entry(
self,
name: str,
component_type: str,
plugin_id: str,
metadata: Dict[str, Any],
) -> ComponentEntry:
"""根据声明构造组件条目。
Args:
name: 组件名称。
component_type: 组件类型。
plugin_id: 插件 ID。
metadata: 组件元数据。
Returns:
ComponentEntry: 已构造并完成校验的组件条目。
Raises:
ComponentRegistrationError: 组件声明不合法时抛出。
"""
try:
normalized_type = self._normalize_component_type(component_type)
normalized_metadata = dict(metadata)
if normalized_type == ComponentTypes.ACTION:
normalized_metadata = self._convert_action_metadata_to_tool_metadata(name, normalized_metadata)
comp = ToolEntry(name, ComponentTypes.TOOL.value, plugin_id, normalized_metadata)
component = ToolEntry(name, ComponentTypes.TOOL.value, plugin_id, normalized_metadata)
elif normalized_type == ComponentTypes.COMMAND:
comp = CommandEntry(name, normalized_type.value, plugin_id, normalized_metadata)
component = CommandEntry(name, normalized_type.value, plugin_id, normalized_metadata)
elif normalized_type == ComponentTypes.TOOL:
comp = ToolEntry(name, normalized_type.value, plugin_id, normalized_metadata)
component = ToolEntry(name, normalized_type.value, plugin_id, normalized_metadata)
elif normalized_type == ComponentTypes.EVENT_HANDLER:
comp = EventHandlerEntry(name, normalized_type.value, plugin_id, normalized_metadata)
component = EventHandlerEntry(name, normalized_type.value, plugin_id, normalized_metadata)
elif normalized_type == ComponentTypes.HOOK_HANDLER:
comp = HookHandlerEntry(name, normalized_type.value, plugin_id, normalized_metadata)
component = HookHandlerEntry(name, normalized_type.value, plugin_id, normalized_metadata)
self._validate_hook_handler_entry(component)
elif normalized_type == ComponentTypes.MESSAGE_GATEWAY:
comp = MessageGatewayEntry(name, normalized_type.value, plugin_id, normalized_metadata)
component = MessageGatewayEntry(name, normalized_type.value, plugin_id, normalized_metadata)
else:
raise ValueError(f"组件类型 {component_type} 不存在")
except ValueError:
logger.error(f"组件类型 {component_type} 不存在")
return False
raise ComponentRegistrationError(
f"组件类型 {component_type} 不存在",
component_name=name,
component_type=component_type,
plugin_id=plugin_id,
)
except ComponentRegistrationError:
raise
except Exception as exc:
raise ComponentRegistrationError(
str(exc),
component_name=name,
component_type=component_type,
plugin_id=plugin_id,
) from exc
if comp.full_name in self._components:
logger.warning(f"组件 {comp.full_name} 已存在,覆盖")
old_comp = self._components[comp.full_name]
# 从 _by_plugin 列表中移除旧条目,防止幽灵组件堆积
old_list = self._by_plugin.get(old_comp.plugin_id)
if old_list is not None:
with contextlib.suppress(ValueError):
old_list.remove(old_comp)
# 从旧类型索引中移除,防止类型变更时幽灵残留
if old_type_dict := self._by_type.get(old_comp.component_type):
old_type_dict.pop(comp.full_name, None)
return component
self._components[comp.full_name] = comp
self._by_type[comp.component_type][comp.full_name] = comp
self._by_plugin.setdefault(plugin_id, []).append(comp)
def _remove_existing_component_entry(self, component: ComponentEntry) -> None:
"""移除同名旧组件条目。
Args:
component: 即将写入的新组件条目。
"""
if component.full_name not in self._components:
return
logger.warning(f"组件 {component.full_name} 已存在,覆盖")
old_component = self._components[component.full_name]
old_list = self._by_plugin.get(old_component.plugin_id)
if old_list is not None:
with contextlib.suppress(ValueError):
old_list.remove(old_component)
if old_type_dict := self._by_type.get(old_component.component_type):
old_type_dict.pop(component.full_name, None)
def _add_component_entry(self, component: ComponentEntry) -> None:
"""写入单个组件条目到全部索引。
Args:
component: 待写入的组件条目。
"""
self._remove_existing_component_entry(component)
self._components[component.full_name] = component
self._by_type[component.component_type][component.full_name] = component
self._by_plugin.setdefault(component.plugin_id, []).append(component)
# ====== 注册 / 注销 ======
def register_component(self, name: str, component_type: str, plugin_id: str, metadata: Dict[str, Any]) -> bool:
"""注册单个组件。
Args:
name: 组件名称(不含插件 ID 前缀)。
component_type: 组件类型(如 ``ACTION``、``COMMAND`` 等)。
plugin_id: 插件 ID。
metadata: 组件元数据。
Returns:
bool: 注册成功时恒为 ``True``。
Raises:
ComponentRegistrationError: 组件声明不合法时抛出。
"""
component = self._build_component_entry(name, component_type, plugin_id, metadata)
self._add_component_entry(component)
return True
def register_plugin_components(self, plugin_id: str, components: List[Dict[str, Any]]) -> int:
"""批量注册一个插件的所有组件,返回成功注册数
"""批量替换一个插件的组件集合
该方法会先完整校验所有组件声明,只有全部通过后才会替换旧组件,
从而避免插件进入半注册状态。
Args:
plugin_id (str): 插件id
components (List[Dict[str, Any]]): 组件字典列表,每个组件包含 name, component_type, metadata 等字段
plugin_id: 插件 ID。
components: 组件声明字典列表
Returns:
count (int): 成功注册的组件数量
int: 实际注册的组件数量
Raises:
ComponentRegistrationError: 任一组件声明不合法时抛出。
"""
count = 0
for comp_data in components:
ok = self.register_component(
name=comp_data.get("name", ""),
component_type=comp_data.get("component_type", ""),
plugin_id=plugin_id,
metadata=comp_data.get("metadata", {}),
prepared_components: List[ComponentEntry] = []
for component_data in components:
prepared_components.append(
self._build_component_entry(
name=str(component_data.get("name", "") or ""),
component_type=str(component_data.get("component_type", "") or ""),
plugin_id=plugin_id,
metadata=component_data.get("metadata", {})
if isinstance(component_data.get("metadata"), dict)
else {},
)
)
if ok:
count += 1
return count
self.remove_components_by_plugin(plugin_id)
for component in prepared_components:
self._add_component_entry(component)
return len(prepared_components)
def remove_components_by_plugin(self, plugin_id: str) -> int:
"""移除某个插件的所有组件,返回移除数量。
@@ -652,6 +822,17 @@ class ComponentRegistry:
except ValueError:
logger.error(f"组件类型 {component_type} 不存在")
raise
if comp_type == ComponentTypes.ACTION:
action_components = [
component
for component in self._by_type.get(ComponentTypes.TOOL, {}).values()
if self._is_legacy_action_component(component)
]
if enabled_only:
return [component for component in action_components if self.check_component_enabled(component, session_id)]
return action_components
type_dict = self._by_type.get(comp_type, {})
if enabled_only:
return [c for c in type_dict.values() if self.check_component_enabled(c, session_id)]
@@ -854,6 +1035,34 @@ class ComponentRegistry:
tools.append(comp)
return tools
def get_tools_for_llm(self, *, enabled_only: bool = True, session_id: Optional[str] = None) -> List[Dict[str, Any]]:
"""兼容旧接口,返回可供 LLM 使用的工具条目列表。
Args:
enabled_only: 是否仅返回启用的组件。
session_id: 可选的会话 ID若提供则考虑会话禁用状态。
Returns:
List[Dict[str, Any]]: 兼容旧结构的工具组件字典列表。
"""
return [
{
"name": tool.full_name,
"description": tool.description,
"parameters": (
dict(tool.parameters_raw)
if isinstance(tool.parameters_raw, dict) and tool.parameters_raw
else tool._get_parameters_schema() or {}
),
"parameters_raw": tool.parameters_raw,
"enabled": tool.enabled,
"plugin_id": tool.plugin_id,
}
for tool in self.get_tools(enabled_only=enabled_only, session_id=session_id)
if not self._is_legacy_action_component(tool)
]
# ====== 统计信息 ======
def get_stats(self) -> StatusDict:
"""获取注册统计。
@@ -863,9 +1072,21 @@ class ComponentRegistry:
"""
return StatusDict(
total=len(self._components),
action=len(self._by_type[ComponentTypes.ACTION]),
action=len(
[
component
for component in self._by_type.get(ComponentTypes.TOOL, {}).values()
if self._is_legacy_action_component(component)
]
),
command=len(self._by_type[ComponentTypes.COMMAND]),
tool=len(self._by_type[ComponentTypes.TOOL]),
tool=len(
[
component
for component in self._by_type.get(ComponentTypes.TOOL, {}).values()
if not self._is_legacy_action_component(component)
]
),
event_handler=len(self._by_type[ComponentTypes.EVENT_HANDLER]),
hook_handler=len(self._by_type[ComponentTypes.HOOK_HANDLER]),
message_gateway=len(self._by_type[ComponentTypes.MESSAGE_GATEWAY]),

View File

@@ -26,6 +26,8 @@ import contextlib
from src.common.logger import get_logger
from src.config.config import global_config
from .hook_spec_registry import HookSpec, HookSpecRegistry
if TYPE_CHECKING:
from .component_registry import HookHandlerEntry
from .supervisor import PluginRunnerSupervisor
@@ -33,29 +35,6 @@ if TYPE_CHECKING:
logger = get_logger("plugin_runtime.host.hook_dispatcher")
@dataclass(slots=True)
class HookSpec:
"""命名 Hook 的静态规格定义。
Attributes:
name: Hook 的唯一名称。
description: Hook 描述。
default_timeout_ms: 默认超时毫秒数;为 `0` 时退回系统默认值。
allow_blocking: 是否允许注册阻塞处理器。
allow_observe: 是否允许注册观察处理器。
allow_abort: 是否允许处理器中止当前 Hook 调用。
allow_kwargs_mutation: 是否允许阻塞处理器修改 `kwargs`。
"""
name: str
description: str = ""
default_timeout_ms: int = 0
allow_blocking: bool = True
allow_observe: bool = True
allow_abort: bool = True
allow_kwargs_mutation: bool = True
@dataclass(slots=True)
class HookHandlerExecutionResult:
"""单个 HookHandler 的执行结果。
@@ -121,17 +100,19 @@ class HookDispatcher:
def __init__(
self,
supervisors_provider: Optional[Callable[[], Sequence["PluginRunnerSupervisor"]]] = None,
hook_spec_registry: Optional[HookSpecRegistry] = None,
) -> None:
"""初始化 Hook 分发器。
Args:
supervisors_provider: 可选的 Supervisor 提供器。若调用 `invoke_hook()`
时未显式传入 `supervisors`,则使用该回调获取目标 Supervisor 列表。
hook_spec_registry: 可选的 Hook 规格注册中心;留空时使用独立注册中心。
"""
self._background_tasks: Set[asyncio.Task[Any]] = set()
self._hook_specs: Dict[str, HookSpec] = {}
self._supervisors_provider = supervisors_provider
self._hook_spec_registry = hook_spec_registry or HookSpecRegistry()
async def stop(self) -> None:
"""停止分发器并取消所有未完成的观察任务。"""
@@ -148,16 +129,7 @@ class HookDispatcher:
spec: 需要注册的 Hook 规格。
"""
normalized_name = self._normalize_hook_name(spec.name)
self._hook_specs[normalized_name] = HookSpec(
name=normalized_name,
description=spec.description,
default_timeout_ms=max(int(spec.default_timeout_ms), 0),
allow_blocking=bool(spec.allow_blocking),
allow_observe=bool(spec.allow_observe),
allow_abort=bool(spec.allow_abort),
allow_kwargs_mutation=bool(spec.allow_kwargs_mutation),
)
self._hook_spec_registry.register_hook_spec(spec)
def register_hook_specs(self, specs: Sequence[HookSpec]) -> None:
"""批量注册命名 Hook 规格。
@@ -180,14 +152,37 @@ class HookDispatcher:
"""
normalized_name = self._normalize_hook_name(hook_name)
if normalized_name in self._hook_specs:
return self._hook_specs[normalized_name]
registered_spec = self._hook_spec_registry.get_hook_spec(normalized_name)
if registered_spec is not None:
return registered_spec
return HookSpec(
name=normalized_name,
parameters_schema={},
default_timeout_ms=self._get_default_timeout_ms(),
)
def unregister_hook_spec(self, hook_name: str) -> bool:
"""注销指定命名 Hook 规格。
Args:
hook_name: 目标 Hook 名称。
Returns:
bool: 是否成功注销。
"""
return self._hook_spec_registry.unregister_hook_spec(hook_name)
def list_hook_specs(self) -> List[HookSpec]:
"""返回当前全部显式注册的 Hook 规格。
Returns:
List[HookSpec]: 已注册 Hook 规格列表。
"""
return self._hook_spec_registry.list_hook_specs()
async def invoke_hook(
self,
hook_name: str,

View File

@@ -0,0 +1,190 @@
"""命名 Hook 规格注册中心。"""
from __future__ import annotations
from copy import deepcopy
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Sequence
@dataclass(slots=True)
class HookSpec:
"""命名 Hook 的静态规格定义。
Attributes:
name: Hook 的唯一名称。
description: Hook 描述。
parameters_schema: Hook 参数模型,使用对象级 JSON Schema 表示。
default_timeout_ms: 默认超时毫秒数;为 ``0`` 时退回系统默认值。
allow_blocking: 是否允许注册阻塞处理器。
allow_observe: 是否允许注册观察处理器。
allow_abort: 是否允许处理器中止当前 Hook 调用。
allow_kwargs_mutation: 是否允许阻塞处理器修改 ``kwargs``。
"""
name: str
description: str = ""
parameters_schema: Dict[str, Any] = field(default_factory=dict)
default_timeout_ms: int = 0
allow_blocking: bool = True
allow_observe: bool = True
allow_abort: bool = True
allow_kwargs_mutation: bool = True
class HookSpecRegistry:
"""命名 Hook 规格注册中心。"""
def __init__(self) -> None:
"""初始化 Hook 规格注册中心。"""
self._hook_specs: Dict[str, HookSpec] = {}
@staticmethod
def _normalize_hook_name(hook_name: str) -> str:
"""规范化 Hook 名称。
Args:
hook_name: 原始 Hook 名称。
Returns:
str: 规范化后的 Hook 名称。
Raises:
ValueError: Hook 名称为空时抛出。
"""
normalized_name = str(hook_name or "").strip()
if not normalized_name:
raise ValueError("Hook 名称不能为空")
return normalized_name
@staticmethod
def _normalize_parameters_schema(raw_schema: Any) -> Dict[str, Any]:
"""规范化 Hook 参数模型。
Args:
raw_schema: 原始参数模型。
Returns:
Dict[str, Any]: 规范化后的对象级 JSON Schema。
Raises:
ValueError: 参数模型不是合法对象级 Schema 时抛出。
"""
if raw_schema is None:
return {}
if not isinstance(raw_schema, dict):
raise ValueError("Hook 参数模型必须是字典")
if not raw_schema:
return {}
normalized_schema = deepcopy(raw_schema)
schema_type = normalized_schema.get("type")
properties = normalized_schema.get("properties")
if schema_type not in {"", None, "object"} and properties is None:
raise ValueError("Hook 参数模型必须是 object 类型或属性映射")
if schema_type in {"", None} and properties is None:
normalized_schema = {
"type": "object",
"properties": normalized_schema,
}
elif schema_type in {"", None}:
normalized_schema["type"] = "object"
if normalized_schema.get("type") != "object":
raise ValueError("Hook 参数模型必须是 object 类型")
return normalized_schema
@classmethod
def _normalize_spec(cls, spec: HookSpec) -> HookSpec:
"""规范化 Hook 规格对象。
Args:
spec: 原始 Hook 规格。
Returns:
HookSpec: 规范化后的 Hook 规格副本。
"""
return HookSpec(
name=cls._normalize_hook_name(spec.name),
description=str(spec.description or "").strip(),
parameters_schema=cls._normalize_parameters_schema(spec.parameters_schema),
default_timeout_ms=max(int(spec.default_timeout_ms), 0),
allow_blocking=bool(spec.allow_blocking),
allow_observe=bool(spec.allow_observe),
allow_abort=bool(spec.allow_abort),
allow_kwargs_mutation=bool(spec.allow_kwargs_mutation),
)
def clear(self) -> None:
"""清空全部 Hook 规格。"""
self._hook_specs.clear()
def register_hook_spec(self, spec: HookSpec) -> HookSpec:
"""注册单个 Hook 规格。
Args:
spec: 需要注册的 Hook 规格。
Returns:
HookSpec: 规范化后实际注册的 Hook 规格。
"""
normalized_spec = self._normalize_spec(spec)
self._hook_specs[normalized_spec.name] = normalized_spec
return normalized_spec
def register_hook_specs(self, specs: Sequence[HookSpec]) -> List[HookSpec]:
"""批量注册 Hook 规格。
Args:
specs: 需要注册的 Hook 规格列表。
Returns:
List[HookSpec]: 规范化后实际注册的 Hook 规格列表。
"""
return [self.register_hook_spec(spec) for spec in specs]
def unregister_hook_spec(self, hook_name: str) -> bool:
"""注销指定 Hook 规格。
Args:
hook_name: 目标 Hook 名称。
Returns:
bool: 是否成功删除。
"""
normalized_name = self._normalize_hook_name(hook_name)
return self._hook_specs.pop(normalized_name, None) is not None
def get_hook_spec(self, hook_name: str) -> Optional[HookSpec]:
"""获取指定 Hook 的显式规格。
Args:
hook_name: 目标 Hook 名称。
Returns:
Optional[HookSpec]: 已注册时返回规格副本,否则返回 ``None``。
"""
normalized_name = self._normalize_hook_name(hook_name)
spec = self._hook_specs.get(normalized_name)
return None if spec is None else self._normalize_spec(spec)
def list_hook_specs(self) -> List[HookSpec]:
"""返回当前全部 Hook 规格。
Returns:
List[HookSpec]: 按 Hook 名称升序排列的规格副本列表。
"""
return [
self._normalize_spec(spec)
for _, spec in sorted(self._hook_specs.items(), key=lambda item: item[0])
]

View File

@@ -27,6 +27,8 @@ from src.plugin_runtime.protocol.envelope import (
ConfigUpdatedPayload,
Envelope,
HealthPayload,
InspectPluginConfigPayload,
InspectPluginConfigResultPayload,
MessageGatewayStateUpdatePayload,
MessageGatewayStateUpdateResultPayload,
PROTOCOL_VERSION,
@@ -52,6 +54,7 @@ from .capability_service import CapabilityService
from .component_registry import ComponentRegistry
from .event_dispatcher import EventDispatcher
from .hook_dispatcher import HookDispatchResult, HookDispatcher
from .hook_spec_registry import HookSpecRegistry
from .logger_bridge import RunnerLogBridge
from .message_gateway import MessageGateway
from .rpc_server import RPCServer
@@ -84,6 +87,7 @@ class PluginRunnerSupervisor:
self,
plugin_dirs: Optional[List[Path]] = None,
group_name: str = "third_party",
hook_spec_registry: Optional[HookSpecRegistry] = None,
socket_path: Optional[str] = None,
health_check_interval_sec: Optional[float] = None,
max_restart_attempts: Optional[int] = None,
@@ -94,6 +98,7 @@ class PluginRunnerSupervisor:
Args:
plugin_dirs: 由当前 Runner 负责加载的插件目录列表。
group_name: 当前 Supervisor 所属运行时分组名称。
hook_spec_registry: 可选的共享 Hook 规格注册中心。
socket_path: 自定义 IPC 地址;留空时由传输层自动生成。
health_check_interval_sec: 健康检查间隔,单位秒。
max_restart_attempts: 自动重启 Runner 的最大次数。
@@ -110,9 +115,12 @@ class PluginRunnerSupervisor:
self._authorization = AuthorizationManager()
self._capability_service = CapabilityService(self._authorization)
self._api_registry = APIRegistry()
self._component_registry = ComponentRegistry()
self._component_registry = ComponentRegistry(hook_spec_registry=hook_spec_registry)
self._event_dispatcher = EventDispatcher(self._component_registry)
self._hook_dispatcher = HookDispatcher(lambda: [self])
self._hook_dispatcher = HookDispatcher(
lambda: [self],
hook_spec_registry=hook_spec_registry,
)
self._message_gateway = MessageGateway(self._component_registry)
self._log_bridge = RunnerLogBridge()
@@ -581,6 +589,49 @@ class PluginRunnerSupervisor:
raise ValueError("插件配置校验失败")
return dict(result.normalized_config)
async def inspect_plugin_config(
self,
plugin_id: str,
config_data: Optional[Dict[str, Any]] = None,
*,
use_provided_config: bool = False,
) -> InspectPluginConfigResultPayload:
"""请求 Runner 解析插件配置元数据。
Args:
plugin_id: 目标插件 ID。
config_data: 可选的配置内容。
use_provided_config: 是否优先使用传入配置而不是磁盘配置。
Returns:
InspectPluginConfigResultPayload: 插件配置解析结果。
Raises:
ValueError: Runner 无法解析插件或返回了错误响应时抛出。
"""
payload = InspectPluginConfigPayload(
config_data=config_data or {},
use_provided_config=use_provided_config,
)
try:
response = await self._rpc_server.send_request(
"plugin.inspect_config",
plugin_id=plugin_id,
payload=payload.model_dump(),
timeout_ms=10000,
)
except Exception as exc:
raise ValueError(f"插件配置解析请求失败: {exc}") from exc
if response.error:
raise ValueError(str(response.error.get("message", "插件配置解析失败")))
result = InspectPluginConfigResultPayload.model_validate(response.payload)
if not result.success:
raise ValueError("插件配置解析失败")
return result
def get_config_reload_subscribers(self, scope: str) -> List[str]:
"""返回订阅指定全局配置广播的插件列表。
@@ -713,15 +764,25 @@ class PluginRunnerSupervisor:
component_declarations = [component.model_dump() for component in payload.components]
runtime_components, api_components = self._split_component_declarations(component_declarations)
self._component_registry.remove_components_by_plugin(payload.plugin_id)
self._api_registry.remove_apis_by_plugin(payload.plugin_id)
await self._unregister_all_message_gateway_drivers_for_plugin(payload.plugin_id)
try:
registered_count = self._component_registry.register_plugin_components(
payload.plugin_id,
runtime_components,
)
except Exception as exc:
logger.error(f"插件 {payload.plugin_id} 组件注册失败: {exc}")
return envelope.make_error_response(
ErrorCode.E_BAD_PAYLOAD.value,
str(exc),
details={
"plugin_id": payload.plugin_id,
"component_count": len(runtime_components),
},
)
registered_count = self._component_registry.register_plugin_components(
payload.plugin_id,
runtime_components,
)
self._api_registry.remove_apis_by_plugin(payload.plugin_id)
registered_api_count = self._api_registry.register_plugin_apis(payload.plugin_id, api_components)
await self._unregister_all_message_gateway_drivers_for_plugin(payload.plugin_id)
self._registered_plugins[payload.plugin_id] = payload
self._message_gateway_states[payload.plugin_id] = {}

View File

@@ -25,6 +25,7 @@ from typing import (
)
import asyncio
import inspect
import tomlkit
@@ -32,14 +33,17 @@ from src.common.logger import get_logger
from src.config.config import config_manager
from src.config.file_watcher import FileChange, FileWatcher
from src.platform_io import DeliveryBatch, InboundMessageEnvelope, get_platform_io_manager
from src.plugin_runtime.hook_catalog import register_builtin_hook_specs
from src.plugin_runtime.capabilities import (
RuntimeComponentCapabilityMixin,
RuntimeCoreCapabilityMixin,
RuntimeDataCapabilityMixin,
)
from src.plugin_runtime.capabilities.registry import register_capability_impls
from src.plugin_runtime.host.hook_dispatcher import HookDispatchResult, HookDispatcher, HookSpec
from src.plugin_runtime.host.hook_dispatcher import HookDispatchResult, HookDispatcher
from src.plugin_runtime.host.hook_spec_registry import HookSpec, HookSpecRegistry
from src.plugin_runtime.host.message_utils import MessageDict, PluginMessageUtils
from src.plugin_runtime.protocol.envelope import InspectPluginConfigResultPayload
from src.plugin_runtime.runner.manifest_validator import ManifestValidator
if TYPE_CHECKING:
@@ -87,7 +91,12 @@ class PluginRuntimeManager(
self._manifest_validator: ManifestValidator = ManifestValidator()
self._config_reload_callback: Callable[[Sequence[str]], Awaitable[None]] = self._handle_main_config_reload
self._config_reload_callback_registered: bool = False
self._hook_dispatcher: HookDispatcher = HookDispatcher(lambda: self.supervisors)
self._hook_spec_registry: HookSpecRegistry = HookSpecRegistry()
self._builtin_hook_specs_registered: bool = False
self._hook_dispatcher: HookDispatcher = HookDispatcher(
lambda: self.supervisors,
hook_spec_registry=self._hook_spec_registry,
)
async def _dispatch_platform_inbound(self, envelope: InboundMessageEnvelope) -> None:
"""接收 Platform IO 审核后的入站消息并送入主消息链。
@@ -155,6 +164,33 @@ class PluginRuntimeManager(
return ["third_party", "builtin"]
return ["builtin", "third_party"]
@staticmethod
def _instantiate_supervisor(supervisor_cls: Any, **kwargs: Any) -> Any:
"""兼容不同构造签名地实例化 Supervisor。
Args:
supervisor_cls: 目标 Supervisor 类。
**kwargs: 期望传入的构造参数。
Returns:
Any: 实例化后的 Supervisor。
"""
signature = inspect.signature(supervisor_cls)
accepts_var_keyword = any(
parameter.kind == inspect.Parameter.VAR_KEYWORD
for parameter in signature.parameters.values()
)
if accepts_var_keyword:
return supervisor_cls(**kwargs)
supported_kwargs = {
key: value
for key, value in kwargs.items()
if key in signature.parameters
}
return supervisor_cls(**supported_kwargs)
# ─── 生命周期 ─────────────────────────────────────────────
async def start(self) -> None:
@@ -185,6 +221,7 @@ class PluginRuntimeManager(
logger.info("未找到任何插件目录,跳过插件运行时启动")
return
self.ensure_builtin_hook_specs_registered()
platform_io_manager = get_platform_io_manager()
# 从配置读取自定义 IPC socket 路径(留空则自动生成)
@@ -196,17 +233,21 @@ class PluginRuntimeManager(
# 创建两个 Supervisor各自拥有独立的 socket / Runner 子进程
if builtin_dirs:
self._builtin_supervisor = PluginSupervisor(
self._builtin_supervisor = self._instantiate_supervisor(
PluginSupervisor,
plugin_dirs=builtin_dirs,
group_name="builtin",
hook_spec_registry=self._hook_spec_registry,
socket_path=builtin_socket,
)
self._register_capability_impls(self._builtin_supervisor)
if third_party_dirs:
self._third_party_supervisor = PluginSupervisor(
self._third_party_supervisor = self._instantiate_supervisor(
PluginSupervisor,
plugin_dirs=third_party_dirs,
group_name="third_party",
hook_spec_registry=self._hook_spec_registry,
socket_path=third_party_socket,
)
self._register_capability_impls(self._third_party_supervisor)
@@ -328,6 +369,7 @@ class PluginRuntimeManager(
spec: 需要注册的 Hook 规格。
"""
self.ensure_builtin_hook_specs_registered()
self._hook_dispatcher.register_hook_spec(spec)
def register_hook_specs(self, specs: Sequence[HookSpec]) -> None:
@@ -337,8 +379,41 @@ class PluginRuntimeManager(
specs: 需要注册的 Hook 规格序列。
"""
self.ensure_builtin_hook_specs_registered()
self._hook_dispatcher.register_hook_specs(specs)
def unregister_hook_spec(self, hook_name: str) -> bool:
"""注销指定命名 Hook 规格。
Args:
hook_name: 目标 Hook 名称。
Returns:
bool: 是否成功注销。
"""
self.ensure_builtin_hook_specs_registered()
return self._hook_dispatcher.unregister_hook_spec(hook_name)
def list_hook_specs(self) -> List[HookSpec]:
"""返回当前全部命名 Hook 规格。
Returns:
List[HookSpec]: 当前已注册的 Hook 规格列表。
"""
self.ensure_builtin_hook_specs_registered()
return self._hook_dispatcher.list_hook_specs()
def ensure_builtin_hook_specs_registered(self) -> None:
"""确保内置 Hook 规格已经注册到共享中心表。"""
if self._builtin_hook_specs_registered:
return
register_builtin_hook_specs(self._hook_spec_registry)
self._builtin_hook_specs_registered = True
def _build_registered_dependency_map(self) -> Dict[str, Set[str]]:
"""根据当前已注册插件构建全局依赖图。"""
@@ -542,8 +617,8 @@ class PluginRuntimeManager(
config_data: 待校验的配置内容。
Returns:
Dict[str, Any] | None: 校验成功时返回规范化后的配置;若插件当前未加载
或运行时不可用,则返回 ``None`` 以便调用方回退到静态 Schema 方案。
Dict[str, Any] | None: 校验成功时返回规范化后的配置;若插件不存在、
当前不可路由或运行时不可用,则返回 ``None`` 以便调用方回退到弱推断方案。
Raises:
ValueError: 插件已加载,但配置校验失败时抛出。
@@ -558,6 +633,8 @@ class PluginRuntimeManager(
logger.warning(f"插件 {plugin_id} 配置校验路由失败,将回退到静态 Schema: {exc}")
return None
if supervisor is None:
supervisor = self._find_supervisor_by_plugin_directory(plugin_id)
if supervisor is None:
return None
@@ -569,6 +646,54 @@ class PluginRuntimeManager(
logger.warning(f"插件 {plugin_id} 运行时配置校验不可用,将回退到静态 Schema: {exc}")
return None
async def inspect_plugin_config(
self,
plugin_id: str,
config_data: Optional[Dict[str, Any]] = None,
*,
use_provided_config: bool = False,
) -> InspectPluginConfigResultPayload | None:
"""请求运行时解析插件配置元数据。
Args:
plugin_id: 目标插件 ID。
config_data: 可选的配置内容。
use_provided_config: 是否优先使用传入的配置内容而不是磁盘配置。
Returns:
InspectPluginConfigResultPayload | None: 解析成功时返回结构化结果;若插件
当前不可路由或运行时不可用,则返回 ``None``。
Raises:
ValueError: 插件存在,但运行时明确拒绝解析请求时抛出。
"""
if not self._started:
return None
try:
supervisor = self._get_supervisor_for_plugin(plugin_id)
except RuntimeError as exc:
logger.warning(f"插件 {plugin_id} 配置解析路由失败: {exc}")
return None
if supervisor is None:
supervisor = self._find_supervisor_by_plugin_directory(plugin_id)
if supervisor is None:
return None
try:
return await supervisor.inspect_plugin_config(
plugin_id=plugin_id,
config_data=config_data,
use_provided_config=use_provided_config,
)
except ValueError:
raise
except Exception as exc:
logger.warning(f"插件 {plugin_id} 配置解析不可用: {exc}")
return None
@staticmethod
def _normalize_config_reload_scopes(changed_scopes: Sequence[str]) -> tuple[str, ...]:
"""规范化配置热重载范围列表。
@@ -771,7 +896,15 @@ class PluginRuntimeManager(
return matches[0] if matches else None
async def load_plugin_globally(self, plugin_id: str, reason: str = "manual") -> bool:
"""加载或重载单个插件,并为其补齐跨 Supervisor 外部依赖。"""
"""加载或重载单个插件,并为其补齐跨 Supervisor 外部依赖。
Args:
plugin_id: 目标插件 ID。
reason: 加载或重载原因。
Returns:
bool: 插件最终是否处于已加载状态。
"""
normalized_plugin_id = str(plugin_id or "").strip()
if not normalized_plugin_id:
@@ -789,11 +922,12 @@ class PluginRuntimeManager(
if supervisor is None:
return False
return await supervisor.reload_plugins(
reloaded = await supervisor.reload_plugins(
plugin_ids=[normalized_plugin_id],
reason=reason,
external_available_plugins=self._build_external_available_plugins_for_supervisor(supervisor),
)
return reloaded and normalized_plugin_id in supervisor.get_loaded_plugin_ids()
@classmethod
def _find_duplicate_plugin_ids(cls, plugin_dirs: List[Path]) -> Dict[str, List[Path]]:
@@ -920,15 +1054,16 @@ class PluginRuntimeManager(
return None
def _refresh_plugin_config_watch_subscriptions(self) -> None:
"""按当前已注册插件集合刷新 config.toml 的单插件订阅。
"""按当前可识别插件集合刷新 config.toml 的单插件订阅。
当插件热重载后,插件集合或目录位置可能发生变化,因此需要重新对齐
watcher 的订阅,确保每个插件配置变更只触发对应 plugin_id。
这里不仅覆盖当前已注册插件,也覆盖已存在但暂未激活的合法插件。
"""
if self._plugin_file_watcher is None:
return
desired_plugin_paths = dict(self._iter_registered_plugin_paths())
desired_plugin_paths = dict(self._iter_watchable_plugin_paths())
self._plugin_path_cache = desired_plugin_paths.copy()
desired_config_paths = {
plugin_id: plugin_path / "config.toml" for plugin_id, plugin_path in desired_plugin_paths.items()
@@ -970,6 +1105,18 @@ class PluginRuntimeManager(
if plugin_path := self._get_plugin_path_for_supervisor(supervisor, plugin_id):
yield plugin_id, plugin_path
def _iter_watchable_plugin_paths(self) -> Iterable[Tuple[str, Path]]:
"""迭代应被配置监听器追踪的插件目录。
Returns:
Iterable[Tuple[str, Path]]: ``(plugin_id, plugin_path)`` 迭代器。
"""
watchable_plugin_paths = dict(self._iter_discovered_plugin_paths(self._iter_plugin_dirs()))
for plugin_id, plugin_path in self._iter_registered_plugin_paths():
watchable_plugin_paths.setdefault(plugin_id, plugin_path)
yield from watchable_plugin_paths.items()
def _get_plugin_config_path_for_supervisor(self, supervisor: Any, plugin_id: str) -> Optional[Path]:
"""从指定 Supervisor 的插件目录中定位某个插件的 config.toml。"""
plugin_path = self._get_plugin_path_for_supervisor(supervisor, plugin_id)
@@ -993,18 +1140,43 @@ class PluginRuntimeManager(
return
if supervisor is None:
supervisor = self._find_supervisor_by_plugin_directory(plugin_id)
if supervisor is None:
return
plugin_is_loaded = plugin_id in getattr(supervisor, "_registered_plugins", {})
try:
snapshot = await supervisor.inspect_plugin_config(plugin_id)
except Exception as exc:
logger.warning(f"插件 {plugin_id} 配置文件变更解析失败: {exc}")
return
try:
config_payload = self._load_plugin_config_for_supervisor(supervisor, plugin_id)
delivered = await supervisor.notify_plugin_config_updated(
plugin_id=plugin_id,
config_data=config_payload,
config_version="",
config_scope="self",
)
if not delivered:
logger.warning(f"插件 {plugin_id} 配置文件变更后通知失败")
if plugin_is_loaded and snapshot.enabled:
delivered = await supervisor.notify_plugin_config_updated(
plugin_id=plugin_id,
config_data=dict(snapshot.normalized_config),
config_version="",
config_scope="self",
)
if not delivered:
logger.warning(f"插件 {plugin_id} 配置文件变更后通知失败")
return
if plugin_is_loaded and not snapshot.enabled:
reloaded = await self.reload_plugins_globally([plugin_id], reason="config_disabled")
if not reloaded:
logger.warning(f"插件 {plugin_id} 禁用配置已写入,但运行时卸载失败")
return
if not snapshot.enabled:
logger.info(f"插件 {plugin_id} 当前处于禁用状态,跳过自动加载")
return
loaded = await self.load_plugin_globally(plugin_id, reason="config_enabled")
if not loaded:
logger.warning(f"插件 {plugin_id} 配置文件变更后自动加载失败")
except Exception as exc:
logger.warning(f"插件 {plugin_id} 配置文件变更处理失败: {exc}")

View File

@@ -288,6 +288,8 @@ class RunnerReadyPayload(BaseModel):
"""已完成初始化的插件列表"""
failed_plugins: List[str] = Field(default_factory=list, description="初始化失败的插件列表")
"""初始化失败的插件列表"""
inactive_plugins: List[str] = Field(default_factory=list, description="当前因禁用或依赖不可用而未激活的插件列表")
"""当前因禁用或依赖不可用而未激活的插件列表"""
# ====== 配置更新 ======
@@ -311,6 +313,32 @@ class ValidatePluginConfigPayload(BaseModel):
"""待校验的配置内容"""
class InspectPluginConfigPayload(BaseModel):
"""plugin.inspect_config 请求 payload。"""
config_data: Dict[str, Any] = Field(default_factory=dict, description="可选的配置内容")
"""可选的配置内容"""
use_provided_config: bool = Field(default=False, description="是否优先使用请求中携带的配置内容")
"""是否优先使用请求中携带的配置内容"""
class InspectPluginConfigResultPayload(BaseModel):
"""plugin.inspect_config 响应 payload。"""
success: bool = Field(description="是否解析成功")
"""是否解析成功"""
default_config: Dict[str, Any] = Field(default_factory=dict, description="插件默认配置")
"""插件默认配置"""
config_schema: Dict[str, Any] = Field(default_factory=dict, description="插件配置 Schema")
"""插件配置 Schema"""
normalized_config: Dict[str, Any] = Field(default_factory=dict, description="归一化后的配置内容")
"""归一化后的配置内容"""
changed: bool = Field(default=False, description="是否在归一化过程中自动补齐或修正了配置")
"""是否在归一化过程中自动补齐或修正了配置"""
enabled: bool = Field(default=True, description="插件在当前配置下是否应被视为启用")
"""插件在当前配置下是否应被视为启用"""
class ValidatePluginConfigResultPayload(BaseModel):
"""plugin.validate_config 响应 payload。"""
@@ -380,6 +408,8 @@ class ReloadPluginResultPayload(BaseModel):
"""成功完成重载的插件列表"""
unloaded_plugins: List[str] = Field(default_factory=list, description="本次已卸载的插件列表")
"""本次已卸载的插件列表"""
inactive_plugins: List[str] = Field(default_factory=list, description="本次处于未激活状态的插件列表")
"""本次处于未激活状态的插件列表"""
failed_plugins: Dict[str, str] = Field(default_factory=dict, description="重载失败的插件及原因")
"""重载失败的插件及原因"""
@@ -395,6 +425,8 @@ class ReloadPluginsResultPayload(BaseModel):
"""成功完成重载的插件列表"""
unloaded_plugins: List[str] = Field(default_factory=list, description="本次已卸载的插件列表")
"""本次已卸载的插件列表"""
inactive_plugins: List[str] = Field(default_factory=list, description="本次处于未激活状态的插件列表")
"""本次处于未激活状态的插件列表"""
failed_plugins: Dict[str, str] = Field(default_factory=dict, description="重载失败的插件及原因")
"""重载失败的插件及原因"""

View File

@@ -9,8 +9,10 @@
6. 转发插件的能力调用到 Host
"""
from collections.abc import Mapping
from enum import Enum
from pathlib import Path
from typing import Any, Callable, Dict, List, Mapping, Optional, Protocol, Set, Tuple, cast
from typing import Any, Callable, Dict, List, Optional, Protocol, Set, Tuple, cast
import asyncio
import contextlib
@@ -39,6 +41,8 @@ from src.plugin_runtime.protocol.envelope import (
ConfigUpdatedPayload,
Envelope,
HealthPayload,
InspectPluginConfigPayload,
InspectPluginConfigResultPayload,
InvokePayload,
InvokeResultPayload,
RegisterPluginPayload,
@@ -141,6 +145,14 @@ class _ConfigAwarePlugin(Protocol):
...
class PluginActivationStatus(str, Enum):
"""描述插件激活结果。"""
LOADED = "loaded"
INACTIVE = "inactive"
FAILED = "failed"
def _install_shutdown_signal_handlers(
mark_runner_shutting_down: Callable[[], None],
loop: Optional[asyncio.AbstractEventLoop] = None,
@@ -236,13 +248,43 @@ class PluginRunner:
# 4. 注入 PluginContext + 调用 on_load 生命周期钩子
failed_plugins: Set[str] = set(self._loader.failed_plugins.keys())
inactive_plugins: Set[str] = set()
available_plugin_versions: Dict[str, str] = dict(self._external_available_plugins)
for meta in plugins:
ok = await self._activate_plugin(meta)
if not ok:
unsatisfied_dependencies = [
dependency.id
for dependency in meta.manifest.plugin_dependencies
if dependency.id not in available_plugin_versions
or not self._loader.manifest_validator.is_plugin_dependency_satisfied(
dependency,
available_plugin_versions[dependency.id],
)
]
if unsatisfied_dependencies:
if any(dependency_id in inactive_plugins for dependency_id in unsatisfied_dependencies):
logger.info(
f"插件 {meta.plugin_id} 依赖的插件当前未激活,跳过本次启动: {', '.join(unsatisfied_dependencies)}"
)
inactive_plugins.add(meta.plugin_id)
continue
failed_plugins.add(meta.plugin_id)
continue
successful_plugins = [meta.plugin_id for meta in plugins if meta.plugin_id not in failed_plugins]
await self._notify_ready(successful_plugins, sorted(failed_plugins))
activation_status = await self._activate_plugin(meta)
if activation_status == PluginActivationStatus.LOADED:
available_plugin_versions[meta.plugin_id] = meta.version
continue
if activation_status == PluginActivationStatus.INACTIVE:
inactive_plugins.add(meta.plugin_id)
continue
failed_plugins.add(meta.plugin_id)
successful_plugins = [
meta.plugin_id
for meta in plugins
if meta.plugin_id not in failed_plugins and meta.plugin_id not in inactive_plugins
]
await self._notify_ready(successful_plugins, sorted(failed_plugins), sorted(inactive_plugins))
# 5. 等待直到收到关停信号
with contextlib.suppress(asyncio.CancelledError):
@@ -352,17 +394,17 @@ class PluginRunner:
cast(_ContextAwarePlugin, instance)._set_context(ctx)
logger.debug(f"已为插件 {plugin_id} 注入 PluginContext")
def _apply_plugin_config(self, meta: PluginMeta, config_data: Optional[Dict[str, Any]] = None) -> None:
def _apply_plugin_config(self, meta: PluginMeta, config_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""在 Runner 侧为插件实例注入当前插件配置。
Args:
meta: 插件元数据。
config_data: 可选的配置数据;留空时自动从插件目录读取。
Returns:
Dict[str, Any]: 归一化后的当前插件配置。
"""
instance = meta.instance
if not hasattr(instance, "set_plugin_config"):
return
raw_config = config_data if config_data is not None else self._load_plugin_config(meta.plugin_dir)
plugin_config, should_persist = self._normalize_plugin_config(instance, raw_config)
config_path = Path(meta.plugin_dir) / "config.toml"
@@ -370,10 +412,12 @@ class PluginRunner:
should_initialize_file = not config_path.exists() and bool(default_config)
if should_persist or should_initialize_file:
self._save_plugin_config(meta.plugin_dir, plugin_config)
try:
cast(_ConfigAwarePlugin, instance).set_plugin_config(plugin_config)
except Exception as exc:
logger.warning(f"插件 {meta.plugin_id} 配置注入失败: {exc}")
if hasattr(instance, "set_plugin_config"):
try:
cast(_ConfigAwarePlugin, instance).set_plugin_config(plugin_config)
except Exception as exc:
logger.warning(f"插件 {meta.plugin_id} 配置注入失败: {exc}")
return plugin_config
def _normalize_plugin_config(
self,
@@ -405,6 +449,33 @@ class PluginRunner:
logger.warning(f"插件配置归一化失败,将回退为原始配置: {exc}")
return normalized_config, False
@staticmethod
def _is_plugin_enabled(config_data: Optional[Mapping[str, Any]]) -> bool:
"""根据配置内容判断插件是否应被视为启用。
Args:
config_data: 当前插件配置。
Returns:
bool: 插件是否启用。
"""
if not isinstance(config_data, Mapping):
return True
plugin_section = config_data.get("plugin")
if not isinstance(plugin_section, Mapping):
return True
enabled_value = plugin_section.get("enabled", True)
if isinstance(enabled_value, str):
normalized_value = enabled_value.strip().lower()
if normalized_value in {"0", "false", "no", "off"}:
return False
if normalized_value in {"1", "true", "yes", "on"}:
return True
return bool(enabled_value)
@staticmethod
def _save_plugin_config(plugin_dir: str, config_data: Dict[str, Any]) -> None:
"""将插件配置写回到 ``config.toml``。
@@ -435,6 +506,99 @@ class PluginRunner:
return loaded if isinstance(loaded, dict) else {}
def _resolve_plugin_candidate(self, plugin_id: str) -> Tuple[Optional[PluginCandidate], Optional[str]]:
"""解析指定插件的候选目录。
Args:
plugin_id: 目标插件 ID。
Returns:
Tuple[Optional[PluginCandidate], Optional[str]]: 候选插件与错误信息。
"""
candidates, duplicate_candidates = self._loader.discover_candidates(self._plugin_dirs)
if plugin_id in duplicate_candidates:
conflict_paths = ", ".join(str(path) for path in duplicate_candidates[plugin_id])
return None, f"检测到重复插件 ID: {conflict_paths}"
candidate = candidates.get(plugin_id)
if candidate is None:
return None, f"未找到插件: {plugin_id}"
return candidate, None
def _resolve_plugin_meta_for_config_request(
self,
plugin_id: str,
) -> Tuple[Optional[PluginMeta], bool, Optional[str]]:
"""为配置相关请求解析插件元数据。
Args:
plugin_id: 目标插件 ID。
Returns:
Tuple[Optional[PluginMeta], bool, Optional[str]]: 依次为插件元数据、
是否为临时冷加载实例、以及错误信息。
"""
loaded_meta = self._loader.get_plugin(plugin_id)
if loaded_meta is not None:
return loaded_meta, False, None
candidate, error_message = self._resolve_plugin_candidate(plugin_id)
if candidate is None:
return None, False, error_message
try:
meta = self._loader.load_candidate(plugin_id, candidate)
except Exception as exc:
return None, False, str(exc)
if meta is None:
return None, False, "插件模块加载失败"
return meta, True, None
def _inspect_plugin_config(
self,
meta: PluginMeta,
*,
config_data: Optional[Dict[str, Any]] = None,
use_provided_config: bool = False,
suppress_errors: bool = True,
) -> InspectPluginConfigResultPayload:
"""解析插件代码定义的配置元数据。
Args:
meta: 插件元数据。
config_data: 可选的配置内容。
use_provided_config: 是否优先使用传入的配置内容。
suppress_errors: 是否在归一化失败时回退原始配置。
Returns:
InspectPluginConfigResultPayload: 结构化解析结果。
"""
raw_config = config_data if use_provided_config else self._load_plugin_config(meta.plugin_dir)
if use_provided_config and config_data is None:
raw_config = {}
normalized_config, changed = self._normalize_plugin_config(
meta.instance,
raw_config,
suppress_errors=suppress_errors,
)
default_config = self._get_plugin_default_config(meta.instance)
if not normalized_config and not raw_config and default_config:
normalized_config = dict(default_config)
changed = True
return InspectPluginConfigResultPayload(
success=True,
default_config=default_config,
config_schema=self._get_plugin_config_schema(meta),
normalized_config=normalized_config,
changed=changed,
enabled=self._is_plugin_enabled(normalized_config),
)
def _register_handlers(self) -> None:
"""注册 Host -> Runner 的方法处理器。"""
self._rpc_client.register_method("plugin.invoke_command", self._handle_invoke)
@@ -448,6 +612,7 @@ class PluginRunner:
self._rpc_client.register_method("plugin.prepare_shutdown", self._handle_prepare_shutdown)
self._rpc_client.register_method("plugin.shutdown", self._handle_shutdown)
self._rpc_client.register_method("plugin.config_updated", self._handle_config_updated)
self._rpc_client.register_method("plugin.inspect_config", self._handle_inspect_plugin_config)
self._rpc_client.register_method("plugin.validate_config", self._handle_validate_plugin_config)
self._rpc_client.register_method("plugin.reload", self._handle_reload_plugin)
self._rpc_client.register_method("plugin.reload_batch", self._handle_reload_plugins)
@@ -579,6 +744,9 @@ class PluginRunner:
)
if response.error:
raise RuntimeError(response.error.get("message", "插件注册失败"))
response_payload = response.payload if isinstance(response.payload, dict) else {}
if not bool(response_payload.get("accepted", True)):
raise RuntimeError(str(response_payload.get("reason", "插件注册失败")))
logger.info(f"插件 {meta.plugin_id} 注册完成")
return True
except Exception as e:
@@ -689,36 +857,40 @@ class PluginRunner:
except Exception as exc:
logger.error(f"插件 {meta.plugin_id} on_unload 失败: {exc}", exc_info=True)
async def _activate_plugin(self, meta: PluginMeta) -> bool:
async def _activate_plugin(self, meta: PluginMeta) -> PluginActivationStatus:
"""完成插件注入、授权、生命周期和组件注册。
Args:
meta: 待激活的插件元数据。
Returns:
bool: 是否激活成功
PluginActivationStatus: 插件激活结果
"""
self._inject_context(meta.plugin_id, meta.instance)
self._apply_plugin_config(meta)
plugin_config = self._apply_plugin_config(meta)
if not self._is_plugin_enabled(plugin_config):
logger.info(f"插件 {meta.plugin_id} 已在配置中禁用,跳过激活")
self._loader.purge_plugin_modules(meta.plugin_id, meta.plugin_dir)
return PluginActivationStatus.INACTIVE
if not await self._bootstrap_plugin(meta):
self._loader.purge_plugin_modules(meta.plugin_id, meta.plugin_dir)
return False
return PluginActivationStatus.FAILED
if not await self._register_plugin(meta):
await self._invoke_plugin_on_unload(meta)
await self._deactivate_plugin(meta)
self._loader.purge_plugin_modules(meta.plugin_id, meta.plugin_dir)
return False
return PluginActivationStatus.FAILED
if not await self._invoke_plugin_on_load(meta):
await self._unregister_plugin(meta.plugin_id, reason="on_load_failed")
await self._deactivate_plugin(meta)
self._loader.purge_plugin_modules(meta.plugin_id, meta.plugin_dir)
return False
return PluginActivationStatus.FAILED
self._loader.set_loaded_plugin(meta)
return True
return PluginActivationStatus.LOADED
async def _unload_plugin(self, meta: PluginMeta, reason: str, *, purge_modules: bool = True) -> None:
"""卸载单个插件并清理 Host/Runner 两侧状态。
@@ -879,6 +1051,7 @@ class PluginRunner:
requested_plugin_id=plugin_id,
reloaded_plugins=batch_result.reloaded_plugins,
unloaded_plugins=batch_result.unloaded_plugins,
inactive_plugins=batch_result.inactive_plugins,
failed_plugins=batch_result.failed_plugins,
)
@@ -973,6 +1146,8 @@ class PluginRunner:
},
}
reloaded_plugins: List[str] = []
inactive_plugins: List[str] = []
inactive_plugin_ids: Set[str] = set()
for load_plugin_id in load_order:
if load_plugin_id in failed_plugins:
@@ -983,10 +1158,28 @@ class PluginRunner:
continue
_, manifest, _ = candidate
unsatisfied_dependency_ids = [
dependency.id
for dependency in manifest.plugin_dependencies
if dependency.id not in available_plugins
or not self._loader.manifest_validator.is_plugin_dependency_satisfied(
dependency,
available_plugins[dependency.id],
)
]
if unsatisfied_dependencies := self._loader.manifest_validator.get_unsatisfied_plugin_dependencies(
manifest,
available_plugin_versions=available_plugins,
):
if load_plugin_id not in reload_root_ids and any(
dependency_id in inactive_plugin_ids for dependency_id in unsatisfied_dependency_ids
):
logger.info(
f"插件 {load_plugin_id} 的依赖当前未激活,保留为未激活状态: {', '.join(unsatisfied_dependencies)}"
)
inactive_plugin_ids.add(load_plugin_id)
inactive_plugins.append(load_plugin_id)
continue
failed_plugins[load_plugin_id] = f"依赖未满足: {', '.join(unsatisfied_dependencies)}"
continue
@@ -996,9 +1189,13 @@ class PluginRunner:
continue
activated = await self._activate_plugin(meta)
if not activated:
if activated == PluginActivationStatus.FAILED:
failed_plugins[load_plugin_id] = "插件初始化失败"
continue
if activated == PluginActivationStatus.INACTIVE:
inactive_plugin_ids.add(load_plugin_id)
inactive_plugins.append(load_plugin_id)
continue
available_plugins[load_plugin_id] = meta.version
reloaded_plugins.append(load_plugin_id)
@@ -1033,7 +1230,7 @@ class PluginRunner:
rollback_failures[rollback_plugin_id] = str(exc)
continue
if not restored:
if restored != PluginActivationStatus.LOADED:
rollback_failures[rollback_plugin_id] = "无法重新激活旧版本"
return ReloadPluginsResultPayload(
@@ -1041,29 +1238,40 @@ class PluginRunner:
requested_plugin_ids=normalized_plugin_ids,
reloaded_plugins=[],
unloaded_plugins=unloaded_plugins,
inactive_plugins=[],
failed_plugins=self._finalize_failed_reload_messages(failed_plugins, rollback_failures),
)
requested_plugin_success = all(plugin_id in reloaded_plugins for plugin_id in reload_root_ids)
requested_plugin_success = all(
plugin_id in reloaded_plugins or plugin_id in inactive_plugins for plugin_id in reload_root_ids
)
return ReloadPluginsResultPayload(
success=requested_plugin_success and not failed_plugins,
requested_plugin_ids=normalized_plugin_ids,
reloaded_plugins=reloaded_plugins,
unloaded_plugins=unloaded_plugins,
inactive_plugins=inactive_plugins,
failed_plugins=failed_plugins,
)
async def _notify_ready(self, loaded_plugins: List[str], failed_plugins: List[str]) -> None:
async def _notify_ready(
self,
loaded_plugins: List[str],
failed_plugins: List[str],
inactive_plugins: List[str],
) -> None:
"""通知 Host 当前 Runner 已完成插件初始化。
Args:
loaded_plugins: 成功初始化的插件列表。
failed_plugins: 初始化失败的插件列表。
inactive_plugins: 因禁用或依赖不可用而未激活的插件列表。
"""
payload = RunnerReadyPayload(
loaded_plugins=loaded_plugins,
failed_plugins=failed_plugins,
inactive_plugins=inactive_plugins,
)
await self._rpc_client.send_request(
"runner.ready",
@@ -1289,6 +1497,44 @@ class PluginRunner:
return envelope.make_error_response(ErrorCode.E_UNKNOWN.value, str(e))
return envelope.make_response(payload={"acknowledged": True})
async def _handle_inspect_plugin_config(self, envelope: Envelope) -> Envelope:
"""处理插件配置元数据解析请求。
Args:
envelope: RPC 请求信封。
Returns:
Envelope: RPC 响应信封。
"""
try:
payload = InspectPluginConfigPayload.model_validate(envelope.payload)
except Exception as exc:
return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc))
plugin_id = envelope.plugin_id
meta, is_temporary_meta, error_message = self._resolve_plugin_meta_for_config_request(plugin_id)
if meta is None:
return envelope.make_error_response(
ErrorCode.E_PLUGIN_NOT_FOUND.value,
error_message or f"未找到插件: {plugin_id}",
)
try:
result = self._inspect_plugin_config(
meta,
config_data=payload.config_data,
use_provided_config=payload.use_provided_config,
suppress_errors=True,
)
except Exception as exc:
return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc))
finally:
if is_temporary_meta:
self._loader.purge_plugin_modules(plugin_id, meta.plugin_dir)
return envelope.make_response(payload=result.model_dump())
async def _handle_validate_plugin_config(self, envelope: Envelope) -> Envelope:
"""处理插件配置校验请求。
@@ -1305,23 +1551,30 @@ class PluginRunner:
return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc))
plugin_id = envelope.plugin_id
meta = self._loader.get_plugin(plugin_id)
meta, is_temporary_meta, error_message = self._resolve_plugin_meta_for_config_request(plugin_id)
if meta is None:
return envelope.make_error_response(ErrorCode.E_PLUGIN_NOT_FOUND.value, f"未找到插件: {plugin_id}")
return envelope.make_error_response(
ErrorCode.E_PLUGIN_NOT_FOUND.value,
error_message or f"未找到插件: {plugin_id}",
)
try:
normalized_config, changed = self._normalize_plugin_config(
meta.instance,
payload.config_data,
inspection_result = self._inspect_plugin_config(
meta,
config_data=payload.config_data,
use_provided_config=True,
suppress_errors=False,
)
except Exception as exc:
return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc))
finally:
if is_temporary_meta:
self._loader.purge_plugin_modules(plugin_id, meta.plugin_dir)
result = ValidatePluginConfigResultPayload(
success=True,
normalized_config=normalized_config,
changed=changed,
normalized_config=inspection_result.normalized_config,
changed=inspection_result.changed,
)
return envelope.make_response(payload=result.model_dump())

View File

@@ -40,10 +40,213 @@ from src.common.utils.utils_message import MessageUtils
from src.config.config import global_config
from src.platform_io import DeliveryBatch, get_platform_io_manager
from src.platform_io.route_key_factory import RouteKeyFactory
from src.plugin_runtime.hook_payloads import deserialize_session_message, serialize_session_message
from src.plugin_runtime.hook_schema_utils import build_object_schema
from src.plugin_runtime.host.hook_dispatcher import HookDispatchResult
from src.plugin_runtime.host.hook_spec_registry import HookSpec, HookSpecRegistry
logger = get_logger("send_service")
def register_send_service_hook_specs(registry: HookSpecRegistry) -> List[HookSpec]:
"""注册发送服务内置 Hook 规格。
Args:
registry: 目标 Hook 规格注册中心。
Returns:
List[HookSpec]: 实际注册的 Hook 规格列表。
"""
return registry.register_hook_specs(
[
HookSpec(
name="send_service.after_build_message",
description="在出站 SessionMessage 构建完成后触发,可改写消息体或取消发送。",
parameters_schema=build_object_schema(
{
"message": {
"type": "object",
"description": "待发送消息的序列化 SessionMessage。",
},
"stream_id": {
"type": "string",
"description": "目标会话 ID。",
},
"display_message": {
"type": "string",
"description": "展示层文本。",
},
"typing": {
"type": "boolean",
"description": "是否模拟打字。",
},
"set_reply": {
"type": "boolean",
"description": "是否附带引用回复。",
},
"storage_message": {
"type": "boolean",
"description": "发送成功后是否写库。",
},
"show_log": {
"type": "boolean",
"description": "是否输出发送日志。",
},
},
required=[
"message",
"stream_id",
"display_message",
"typing",
"set_reply",
"storage_message",
"show_log",
],
),
default_timeout_ms=5000,
allow_abort=True,
allow_kwargs_mutation=True,
),
HookSpec(
name="send_service.before_send",
description="在真正调用 Platform IO 发送前触发,可改写消息或取消本次发送。",
parameters_schema=build_object_schema(
{
"message": {
"type": "object",
"description": "待发送消息的序列化 SessionMessage。",
},
"typing": {
"type": "boolean",
"description": "是否模拟打字。",
},
"set_reply": {
"type": "boolean",
"description": "是否附带引用回复。",
},
"reply_message_id": {
"type": "string",
"description": "被引用消息 ID。",
},
"storage_message": {
"type": "boolean",
"description": "发送成功后是否写库。",
},
"show_log": {
"type": "boolean",
"description": "是否输出发送日志。",
},
},
required=["message", "typing", "set_reply", "storage_message", "show_log"],
),
default_timeout_ms=5000,
allow_abort=True,
allow_kwargs_mutation=True,
),
HookSpec(
name="send_service.after_send",
description="在发送流程结束后触发,用于观察最终发送结果。",
parameters_schema=build_object_schema(
{
"message": {
"type": "object",
"description": "本次发送消息的序列化 SessionMessage。",
},
"sent": {
"type": "boolean",
"description": "本次发送是否成功。",
},
"typing": {
"type": "boolean",
"description": "是否模拟打字。",
},
"set_reply": {
"type": "boolean",
"description": "是否附带引用回复。",
},
"reply_message_id": {
"type": "string",
"description": "被引用消息 ID。",
},
"storage_message": {
"type": "boolean",
"description": "发送成功后是否写库。",
},
"show_log": {
"type": "boolean",
"description": "是否输出发送日志。",
},
},
required=["message", "sent", "typing", "set_reply", "storage_message", "show_log"],
),
default_timeout_ms=5000,
allow_abort=False,
allow_kwargs_mutation=False,
),
]
)
def _get_runtime_manager() -> Any:
"""获取插件运行时管理器。
Returns:
Any: 插件运行时管理器单例。
"""
from src.plugin_runtime.integration import get_plugin_runtime_manager
return get_plugin_runtime_manager()
def _coerce_bool(value: Any, default: bool) -> bool:
"""将任意值安全转换为布尔值。
Args:
value: 待转换的值。
default: 当值为空时使用的默认值。
Returns:
bool: 转换后的布尔值。
"""
if value is None:
return default
return bool(value)
async def _invoke_send_hook(
hook_name: str,
message: SessionMessage,
**kwargs: Any,
) -> tuple[HookDispatchResult, SessionMessage]:
"""触发携带出站消息的命名 Hook。
Args:
hook_name: 目标 Hook 名称。
message: 当前待发送消息。
**kwargs: 需要附带的额外参数。
Returns:
tuple[HookDispatchResult, SessionMessage]: Hook 聚合结果以及可能被改写后的消息对象。
"""
hook_result = await _get_runtime_manager().invoke_hook(
hook_name,
message=serialize_session_message(message),
**kwargs,
)
mutated_message = message
raw_message = hook_result.kwargs.get("message")
if raw_message is not None:
try:
mutated_message = deserialize_session_message(raw_message)
except Exception as exc:
logger.warning(f"Hook {hook_name} 返回的 message 无法反序列化,已忽略: {exc}")
return hook_result, mutated_message
def _inherit_platform_io_route_metadata(target_stream: BotChatSession) -> Dict[str, object]:
"""从目标会话继承 Platform IO 路由元数据。
@@ -469,6 +672,27 @@ async def _send_via_platform_io(
Returns:
bool: 发送成功时返回 ``True``。
"""
before_send_result, message = await _invoke_send_hook(
"send_service.before_send",
message,
typing=typing,
set_reply=set_reply,
reply_message_id=reply_message_id,
storage_message=storage_message,
show_log=show_log,
)
if before_send_result.aborted:
logger.info(f"[SendService] 消息 {message.message_id} 在发送前被 Hook 中止")
return False
before_kwargs = before_send_result.kwargs
typing = _coerce_bool(before_kwargs.get("typing"), typing)
set_reply = _coerce_bool(before_kwargs.get("set_reply"), set_reply)
storage_message = _coerce_bool(before_kwargs.get("storage_message"), storage_message)
show_log = _coerce_bool(before_kwargs.get("show_log"), show_log)
raw_reply_message_id = before_kwargs.get("reply_message_id", reply_message_id)
reply_message_id = None if raw_reply_message_id in {None, ""} else str(raw_reply_message_id)
platform_io_manager = get_platform_io_manager()
try:
await platform_io_manager.ensure_send_pipeline_ready()
@@ -500,6 +724,18 @@ async def _send_via_platform_io(
logger.debug(traceback.format_exc())
return False
sent = bool(delivery_batch.has_success)
await _invoke_send_hook(
"send_service.after_send",
message,
sent=sent,
typing=typing,
set_reply=set_reply,
reply_message_id=reply_message_id,
storage_message=storage_message,
show_log=show_log,
)
if delivery_batch.has_success:
if storage_message:
_store_sent_message(message)
@@ -606,6 +842,26 @@ async def _send_to_target(
if outbound_message is None:
return False
after_build_result, outbound_message = await _invoke_send_hook(
"send_service.after_build_message",
outbound_message,
stream_id=stream_id,
display_message=display_message,
typing=typing,
set_reply=set_reply,
storage_message=storage_message,
show_log=show_log,
)
if after_build_result.aborted:
logger.info(f"[SendService] 消息 {outbound_message.message_id} 在构建后被 Hook 中止")
return False
after_build_kwargs = after_build_result.kwargs
typing = _coerce_bool(after_build_kwargs.get("typing"), typing)
set_reply = _coerce_bool(after_build_kwargs.get("set_reply"), set_reply)
storage_message = _coerce_bool(after_build_kwargs.get("storage_message"), storage_message)
show_log = _coerce_bool(after_build_kwargs.get("show_log"), show_log)
sent = await send_session_message(
outbound_message,
typing=typing,

View File

@@ -6,11 +6,13 @@ from .catalog import router as catalog_router
from .config_routes import router as config_router
from .management import router as management_router
from .progress import get_progress_router, update_progress
from .runtime_routes import router as runtime_router
router = APIRouter(prefix="/plugins", tags=["插件管理"])
router.include_router(catalog_router)
router.include_router(management_router)
router.include_router(config_router)
router.include_router(runtime_router)
set_update_progress_callback(update_progress)

View File

@@ -1,13 +1,13 @@
"""插件配置相关 WebUI 路由。"""
import json
from pathlib import Path
from typing import Any, Dict, Optional, cast
import tomlkit
from fastapi import APIRouter, Cookie, HTTPException
from src.common.logger import get_logger
from src.plugin_runtime.component_query import component_query_service
from src.plugin_runtime.protocol.envelope import InspectPluginConfigResultPayload
from src.webui.utils.toml_utils import save_toml_with_format
from .schemas import UpdatePluginConfigRequest, UpdatePluginRawConfigRequest
@@ -207,6 +207,55 @@ def _build_toml_document(config_data: Dict[str, Any]) -> tomlkit.TOMLDocument:
return tomlkit.parse(tomlkit.dumps(config_data))
def _load_plugin_config_from_disk(plugin_path: Path) -> Dict[str, Any]:
"""从磁盘读取插件配置。
Args:
plugin_path: 插件目录。
Returns:
Dict[str, Any]: 当前配置字典;文件不存在时返回空字典。
"""
config_path = resolve_plugin_file_path(plugin_path, "config.toml")
if not config_path.exists():
return {}
with open(config_path, "r", encoding="utf-8") as file_obj:
loaded_config = tomlkit.load(file_obj).unwrap()
return loaded_config if isinstance(loaded_config, dict) else {}
async def _inspect_plugin_config_via_runtime(
plugin_id: str,
config_data: Optional[Dict[str, Any]] = None,
*,
use_provided_config: bool = False,
) -> InspectPluginConfigResultPayload | None:
"""通过插件运行时解析配置元数据。
Args:
plugin_id: 插件 ID。
config_data: 可选的配置内容。
use_provided_config: 是否优先使用传入配置而不是磁盘配置。
Returns:
InspectPluginConfigResultPayload | None: 运行时可用时返回解析结果,否则返回 ``None``。
Raises:
ValueError: 插件运行时明确拒绝解析请求时抛出。
"""
from src.plugin_runtime.integration import get_plugin_runtime_manager
runtime_manager = get_plugin_runtime_manager()
return await runtime_manager.inspect_plugin_config(
plugin_id,
config_data,
use_provided_config=use_provided_config,
)
async def _validate_plugin_config_via_runtime(plugin_id: str, config_data: Dict[str, Any]) -> Dict[str, Any] | None:
"""通过插件运行时对配置进行校验。
@@ -244,27 +293,24 @@ async def get_plugin_config_schema(plugin_id: str, maibot_session: Optional[str]
logger.info(f"获取插件配置 Schema: {plugin_id}")
try:
registration_schema = component_query_service.get_plugin_config_schema(plugin_id)
if isinstance(registration_schema, dict) and registration_schema:
return {"success": True, "schema": registration_schema}
plugin_path = find_plugin_path_by_id(plugin_id)
if plugin_path is None:
raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}")
schema_json_path = resolve_plugin_file_path(plugin_path, "config_schema.json")
if schema_json_path.exists():
try:
with open(schema_json_path, "r", encoding="utf-8") as file_obj:
return {"success": True, "schema": json.load(file_obj)}
except Exception as e:
logger.warning(f"读取 config_schema.json 失败,回退到自动推断: {e}")
try:
runtime_snapshot = await _inspect_plugin_config_via_runtime(plugin_id)
except ValueError as exc:
logger.warning(f"插件 {plugin_id} 配置 Schema 解析失败,将回退到弱推断: {exc}")
runtime_snapshot = None
current_config: Any = component_query_service.get_plugin_default_config(plugin_id) or {}
config_path = resolve_plugin_file_path(plugin_path, "config.toml")
if config_path.exists():
with open(config_path, "r", encoding="utf-8") as file_obj:
current_config = tomlkit.load(file_obj)
if runtime_snapshot is not None and runtime_snapshot.config_schema:
return {"success": True, "schema": dict(runtime_snapshot.config_schema)}
current_config: Any = (
dict(runtime_snapshot.normalized_config)
if runtime_snapshot is not None
else _load_plugin_config_from_disk(plugin_path)
)
return {"success": True, "schema": _build_schema_from_current_config(plugin_id, current_config)}
except HTTPException:
@@ -375,15 +421,24 @@ async def get_plugin_config(plugin_id: str, maibot_session: Optional[str] = Cook
raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}")
config_path = resolve_plugin_file_path(plugin_path, "config.toml")
try:
runtime_snapshot = await _inspect_plugin_config_via_runtime(plugin_id)
except ValueError as exc:
logger.warning(f"插件 {plugin_id} 配置读取失败,将回退到磁盘内容: {exc}")
runtime_snapshot = None
if runtime_snapshot is not None:
message = "配置文件不存在,已返回默认配置" if not config_path.exists() else ""
return {
"success": True,
"config": dict(runtime_snapshot.normalized_config),
"message": message,
}
if not config_path.exists():
default_config = component_query_service.get_plugin_default_config(plugin_id)
if isinstance(default_config, dict):
return {"success": True, "config": default_config, "message": "配置文件不存在,已返回默认配置"}
return {"success": True, "config": {}, "message": "配置文件不存在"}
with open(config_path, "r", encoding="utf-8") as file_obj:
config = tomlkit.load(file_obj)
return {"success": True, "config": dict(config)}
return {"success": True, "config": _load_plugin_config_from_disk(plugin_path)}
except HTTPException:
raise
except Exception as e:
@@ -412,6 +467,10 @@ async def update_plugin_config(
logger.info(f"更新插件配置: {plugin_id}")
try:
plugin_path = find_plugin_path_by_id(plugin_id)
if plugin_path is None:
raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}")
config_data = request.config or {}
if isinstance(config_data, dict):
config_data = normalize_dotted_keys(config_data)
@@ -419,12 +478,13 @@ async def update_plugin_config(
if isinstance(runtime_validated_config, dict):
config_data = runtime_validated_config
else:
plugin_schema = component_query_service.get_plugin_config_schema(plugin_id)
if isinstance(plugin_schema, dict) and plugin_schema:
_coerce_config_by_plugin_schema(plugin_schema, config_data)
plugin_path = find_plugin_path_by_id(plugin_id)
if plugin_path is None:
raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}")
runtime_snapshot = await _inspect_plugin_config_via_runtime(
plugin_id,
config_data,
use_provided_config=True,
)
if runtime_snapshot is not None and runtime_snapshot.config_schema:
_coerce_config_by_plugin_schema(dict(runtime_snapshot.config_schema), config_data)
config_path = resolve_plugin_file_path(plugin_path, "config.toml")
backup_path = backup_file(config_path, "backup")
@@ -498,17 +558,29 @@ async def toggle_plugin(plugin_id: str, maibot_session: Optional[str] = Cookie(N
raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}")
config_path = resolve_plugin_file_path(plugin_path, "config.toml")
default_config = component_query_service.get_plugin_default_config(plugin_id)
config = _build_toml_document(default_config if isinstance(default_config, dict) else {})
if config_path.exists():
with open(config_path, "r", encoding="utf-8") as file_obj:
config = tomlkit.load(file_obj)
try:
runtime_snapshot = await _inspect_plugin_config_via_runtime(plugin_id)
except ValueError as exc:
logger.warning(f"插件 {plugin_id} 状态切换前配置解析失败,将回退到磁盘内容: {exc}")
runtime_snapshot = None
if "plugin" not in config:
current_config = (
dict(runtime_snapshot.normalized_config)
if runtime_snapshot is not None
else _load_plugin_config_from_disk(plugin_path)
)
config = _build_toml_document(current_config)
plugin_section = config.get("plugin")
if plugin_section is None or not hasattr(plugin_section, "get"):
config["plugin"] = tomlkit.table()
plugin_config = cast(Any, config["plugin"])
current_enabled = bool(plugin_config.get("enabled", True))
current_enabled = (
bool(runtime_snapshot.enabled)
if runtime_snapshot is not None
else bool(plugin_config.get("enabled", True))
)
new_enabled = not current_enabled
plugin_config["enabled"] = new_enabled
save_toml_with_format(config, str(config_path))
@@ -519,7 +591,7 @@ async def toggle_plugin(plugin_id: str, maibot_session: Optional[str] = Cookie(N
"success": True,
"enabled": new_enabled,
"message": f"插件已{status}",
"note": "状态更改将在下次加载插件时生效",
"note": "状态更改将自动热更新到对应插件",
}
except HTTPException:
raise

View File

@@ -0,0 +1,28 @@
"""插件运行时相关 WebUI 路由。"""
from typing import Optional
from fastapi import APIRouter, Cookie
from src.plugin_runtime.component_query import component_query_service
from .schemas import HookSpecListResponse, HookSpecResponse
from .support import require_plugin_token
router = APIRouter()
@router.get("/runtime/hooks", response_model=HookSpecListResponse)
async def list_runtime_hook_specs(maibot_session: Optional[str] = Cookie(None)) -> HookSpecListResponse:
"""返回当前插件运行时公开的 Hook 规格清单。
Args:
maibot_session: 当前 WebUI 会话令牌。
Returns:
HookSpecListResponse: Hook 规格列表响应。
"""
require_plugin_token(maibot_session)
hooks = [HookSpecResponse(**hook_data) for hook_data in component_query_service.list_hook_specs()]
return HookSpecListResponse(success=True, hooks=hooks)

View File

@@ -111,3 +111,19 @@ class UpdatePluginConfigRequest(BaseModel):
class UpdatePluginRawConfigRequest(BaseModel):
config: str = Field(..., description="原始 TOML 配置内容")
class HookSpecResponse(BaseModel):
name: str = Field(..., description="Hook 名称")
description: str = Field("", description="Hook 描述")
parameters_schema: Dict[str, Any] = Field(default_factory=dict, description="Hook 参数模型")
default_timeout_ms: int = Field(..., description="默认超时毫秒数")
allow_blocking: bool = Field(..., description="是否允许 blocking 处理器")
allow_observe: bool = Field(..., description="是否允许 observe 处理器")
allow_abort: bool = Field(..., description="是否允许 abort")
allow_kwargs_mutation: bool = Field(..., description="是否允许修改 kwargs")
class HookSpecListResponse(BaseModel):
success: bool = Field(..., description="是否成功")
hooks: List[HookSpecResponse] = Field(default_factory=list, description="Hook 规格列表")