diff --git a/.gitignore b/.gitignore index 156a41dc..093ba248 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ data/ data1/ +mai_knowledge/knowledge.json mongodb/ NapCat.Framework.Windows.Once/ NapCat.Framework.Windows.OneKey/ diff --git a/mai_knowledge/knowledge.json b/mai_knowledge/knowledge.json deleted file mode 100644 index feae33c6..00000000 --- a/mai_knowledge/knowledge.json +++ /dev/null @@ -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": "使用VRChat(VRC)", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:00:33.980219" - }, - { - "id": "know_6_1774771397.766996", - "content": "对VRChat(VRC)及虚拟形象社交感兴趣", - "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" - } - ] -} \ No newline at end of file diff --git a/pytests/test_plugin_config_runtime.py b/pytests/test_plugin_config_runtime.py index df03d343..51eb4a80 100644 --- a/pytests/test_plugin_config_runtime.py +++ b/pytests/test_plugin_config_runtime.py @@ -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": "配置文件不存在,已返回默认配置", + } diff --git a/pytests/test_plugin_runtime.py b/pytests/test_plugin_runtime.py index b866d3ff..aef14b6b 100644 --- a/pytests/test_plugin_runtime.py +++ b/pytests/test_plugin_runtime.py @@ -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 diff --git a/src/chat/message_receive/bot.py b/src/chat/message_receive/bot.py index 33a66ffc..27a13821 100644 --- a/src/chat/message_receive/bot.py +++ b/src/chat/message_receive/bot.py @@ -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 统一处理;此处不做用户名硬编码匹配 diff --git a/src/maisaka/chat_loop_service.py b/src/maisaka/chat_loop_service.py index 93b48753..63ab2fba 100644 --- a/src/maisaka/chat_loop_service.py +++ b/src/maisaka/chat_loop_service.py @@ -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 diff --git a/src/plugin_runtime/component_query.py b/src/plugin_runtime/component_query.py index 366c3c0f..c4ded56e 100644 --- a/src/plugin_runtime/component_query.py +++ b/src/plugin_runtime/component_query.py @@ -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() diff --git a/src/plugin_runtime/hook_catalog.py b/src/plugin_runtime/hook_catalog.py new file mode 100644 index 00000000..714773eb --- /dev/null +++ b/src/plugin_runtime/hook_catalog.py @@ -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 diff --git a/src/plugin_runtime/hook_payloads.py b/src/plugin_runtime/hook_payloads.py new file mode 100644 index 00000000..9d3fbc69 --- /dev/null +++ b/src/plugin_runtime/hook_payloads.py @@ -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] diff --git a/src/plugin_runtime/hook_schema_utils.py b/src/plugin_runtime/hook_schema_utils.py new file mode 100644 index 00000000..c92d15ea --- /dev/null +++ b/src/plugin_runtime/hook_schema_utils.py @@ -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 diff --git a/src/plugin_runtime/host/component_registry.py b/src/plugin_runtime/host/component_registry.py index c91574e5..07e4d8ea 100644 --- a/src/plugin_runtime/host/component_registry.py +++ b/src/plugin_runtime/host/component_registry.py @@ -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} 不能注册为 blocking:Hook {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} 不能注册为 observe:Hook {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=abort:Hook {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]), diff --git a/src/plugin_runtime/host/hook_dispatcher.py b/src/plugin_runtime/host/hook_dispatcher.py index f2979f29..1891b1ed 100644 --- a/src/plugin_runtime/host/hook_dispatcher.py +++ b/src/plugin_runtime/host/hook_dispatcher.py @@ -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, diff --git a/src/plugin_runtime/host/hook_spec_registry.py b/src/plugin_runtime/host/hook_spec_registry.py new file mode 100644 index 00000000..29ade396 --- /dev/null +++ b/src/plugin_runtime/host/hook_spec_registry.py @@ -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]) + ] diff --git a/src/plugin_runtime/host/supervisor.py b/src/plugin_runtime/host/supervisor.py index 7a023167..bc0fca85 100644 --- a/src/plugin_runtime/host/supervisor.py +++ b/src/plugin_runtime/host/supervisor.py @@ -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] = {} diff --git a/src/plugin_runtime/integration.py b/src/plugin_runtime/integration.py index deecaba8..39d899c3 100644 --- a/src/plugin_runtime/integration.py +++ b/src/plugin_runtime/integration.py @@ -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}") diff --git a/src/plugin_runtime/protocol/envelope.py b/src/plugin_runtime/protocol/envelope.py index 88c5c7df..e4bebba9 100644 --- a/src/plugin_runtime/protocol/envelope.py +++ b/src/plugin_runtime/protocol/envelope.py @@ -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="重载失败的插件及原因") """重载失败的插件及原因""" diff --git a/src/plugin_runtime/runner/runner_main.py b/src/plugin_runtime/runner/runner_main.py index 55c53d8d..cc910bd8 100644 --- a/src/plugin_runtime/runner/runner_main.py +++ b/src/plugin_runtime/runner/runner_main.py @@ -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()) diff --git a/src/services/send_service.py b/src/services/send_service.py index d7f17563..967d3723 100644 --- a/src/services/send_service.py +++ b/src/services/send_service.py @@ -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, diff --git a/src/webui/routers/plugin/__init__.py b/src/webui/routers/plugin/__init__.py index 1be61841..deaa2eac 100644 --- a/src/webui/routers/plugin/__init__.py +++ b/src/webui/routers/plugin/__init__.py @@ -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) diff --git a/src/webui/routers/plugin/config_routes.py b/src/webui/routers/plugin/config_routes.py index 3a24503e..de51a2cf 100644 --- a/src/webui/routers/plugin/config_routes.py +++ b/src/webui/routers/plugin/config_routes.py @@ -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 diff --git a/src/webui/routers/plugin/runtime_routes.py b/src/webui/routers/plugin/runtime_routes.py new file mode 100644 index 00000000..fe1773a4 --- /dev/null +++ b/src/webui/routers/plugin/runtime_routes.py @@ -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) diff --git a/src/webui/routers/plugin/schemas.py b/src/webui/routers/plugin/schemas.py index 0d431dc0..eda21038 100644 --- a/src/webui/routers/plugin/schemas.py +++ b/src/webui/routers/plugin/schemas.py @@ -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 规格列表")