diff --git a/.gitignore b/.gitignore index 09193394..5f5dc24d 100644 --- a/.gitignore +++ b/.gitignore @@ -139,22 +139,9 @@ dmypy.json # Cython debug symbols cython_debug/ -demo.py -test.py -server_ip.py -member_activity_handle.py -Yu-Gi-Oh/ -csgo/ -fantasy_card/ data/ log/ backup/ -extensive_plugin/ -test/ -bot.py .idea/ resources/ -/configs/config.py -configs/config.yaml -.vscode/launch.json -plugins_/ \ No newline at end of file +.vscode/launch.json \ No newline at end of file diff --git a/data/anime.json b/data/anime.json deleted file mode 100644 index 07c71465..00000000 --- a/data/anime.json +++ /dev/null @@ -1,1889 +0,0 @@ -{ - "mua": [ - "你想干嘛?(一脸嫌弃地后退)", - "诶……不可以随便亲亲啦", - "(亲了一下你)", - "只......只许这一次哦///////", - "唔...诶诶诶!!!", - "mua~", - "rua!大hentai!想...想亲咱就直说嘛⁄(⁄ ⁄•⁄ω⁄•⁄ ⁄)⁄", - "!啾~~!", - "啾(害羞)", - "mua~最喜欢你的吻了", - "欸,现在么..也不是不可以啦(小小声)" - ], - "啾咪": [ - "你想干嘛?(一脸嫌弃地后退)", - "诶……不可以随便亲亲啦", - "(亲了一下你)", - "只......只许这一次哦///////", - "唔...诶诶诶!!!", - "mua~", - "rua!大hentai!想...想亲咱就直说嘛⁄(⁄ ⁄•⁄ω⁄•⁄ ⁄)⁄", - "!啾~~!", - "啾(害羞)", - "mua~最喜欢你的吻了", - "你在干嘛(/ω\)害羞", - "哎呀,这样咱会害羞的(脸红)", - "欸,现在么..也不是不可以啦(小小声)" - ], - "摸": [ - "感觉你就像咱很久之前认识的一个人呢,有种莫名安心的感觉(>﹏<)", - "舒服w,蹭蹭~", - "是要隔着衣服摸,还是从领口伸进去摸呀", - "唔。。头发要乱啦", - "呼噜呼噜~", - "再摸一次~", - "好舒服,蹭蹭~", - "不行那里不可以(´///ω/// `)", - "再摸咱就长不高啦~", - "你的手总是那么暖和呢~", - "变态!!不许乱摸", - "好吧~_~,就一下下哦……唔~好了……都两下了……(害羞)", - "不可以总摸的哦,不然的话,会想那个的wwww", - "哼!谁稀罕你摸头啦!唔......为什么要做出那副表情......好啦好啦~咱......咱让你摸就是了......诶嘿嘿~好舒服......", - "呜姆呜姆~~~w(害羞,兴奋)主人喵~(侧过脑袋蹭蹭你的手", - "不可以摸啦~其实咱已经...了QAQ会弄脏你的手的", - "喂喂...不要停下来啊", - "唔... 手...好温暖呢.....就像是......新出炉的蛋糕", - "走开啦,咱喵说过,被摸头会长不高的啦~~~", - "呜姆咪~~...好...好的说喵~...(害羞,猫耳往下压,任由", - "欸,现在么..也不是不可以啦(小小声)" - ], - "上你": [ - "(把你按在地上)这么弱还想欺负咱,真是不自量力呢", - "你再这样咱就不理你了(>д<)", - "请轻 一点", - "好啊!", - "欸,现在么..也不是不可以啦(小小声)", - "先捅破屏幕再说吧!", - "只......只许这一次哦///////" - ], - "傻": [ - "超级讨厌你说咱傻的说", - "你为什么会这么觉得呢(>﹏<)", - "谁是傻子呀?(歪头", - "呜嘿嘿( ̄▽ ̄)~*", - "诶嘿嘿嘿~", - "就多读书", - "讨厌啦,你最讨厌了(///////)", - "对呀,咱傻得只喜欢你一个人", - "咱才不傻呢!o(>﹏<)o", - "咱最喜欢嘴臭的人了", - "不可以骂别人哟,骂人的孩子咱最讨厌了!", - "咱遇见喜欢的人就变傻了Q_Q", - "咱...一定一定会努力变得更聪明的!你就等着那一天的到来吧!", - "那么至少…你能不能来做这个傻瓜呢?与咱一起,傻到终焉…" - ], - "裸": [ - "下流!", - "エッチ!", - "就算是恋人也不能QAQ", - "你是暗示咱和你要坦诚相见吗www", - "咱还没准备好(小鹿乱撞)≧﹏≦", - "你在想什么呢,敲头!", - "你这是赤裸裸的性骚扰呢ヽ(`Д´)ノ", - "讨厌!问这种问题成为恋人再说吧..", - "裸睡有益身体健康", - "咱脱掉袜子了", - "这是不文明的", - "这不好", - "你的身体某些地方看起来不太对劲,咱帮你修剪一下吧。(拿出剪刀)", - "咱认为你的脑袋可能零件松动了,需要打开检修一下。(拿出锤子)" - ], - "贴": [ - "贴什么贴.....只......只能......一下哦!", - "贴...贴贴(靠近)", - "蹭蹭…你以为咱会这么说吗!baka死宅快到一边去啦!", - "你把脸凑这么近,咱会害羞的啦Σ>―(〃°ω°〃)♡→", - "退远", - "不可以贴" - ], - "老婆": [ - "咱和你谈婚论嫁是不是还太早了一点呢?", - "咱在呢(ノ>ω<)ノ", - "见谁都是一口一个老婆的人,要不要把你也变成女孩子呢?(*-`ω´-)✄", - "神经病,凡是美少女都是你老婆吗?", - "嘛嘛~本喵才不是你的老婆呢", - "你黐线,凡是美少女都系你老婆啊?", - "欸...要把咱做成饼吗?咱只有一个,做成饼吃掉就没有了...", - "已经可以了,现在很多死宅也都没你这么恶心了", - "不可以", - "嗯,老公~哎呀~好害羞~嘻嘻嘻~", - "请...请不要这样,啊~,只...只允许这一次哟~", - "好啦好啦,不要让大家都听到了,跟咱回家(拽住你" - ], - "抱": [ - "诶嘿~(钻进你怀中)", - "o(*////▽////*)q", - "只能一会哦(张开双手)", - "你就像个孩子一样呢...摸摸头(>^ω^<)抱一下~你会舒服些吗?", - "嘛,真是拿你没办法呢,就一会儿哦", - "抱住不忍心放开", - "嗯嗯,抱抱~", - "抱一下~嘿w", - "抱抱ヾ(@^▽^@)ノ", - "喵呜~w(扑进怀里,瘫软", - "怀里蹭蹭", - "嗯……那就抱一下吧~", - "蹭蹭,好开心", - "请……请轻一点了啦", - "呀~!真是的...你不要突然抱过来啦!不过...喜欢你的抱抱,有你的味道(嗅)o(*////▽////*)q" - ], - "亲": [ - "啊,好含羞啊,那,那只能亲一下哦,mua(⑅˃◡˂⑅)", - "亲~", - "啾~唔…不要总伸进来啊!", - "你怎么这么熟练呢?明明是咱先的", - "(〃ノωノ)亲…亲一个…啾w", - "(脸红)就只有这一次哦~你", - "!啾~~!", - "(假装)推开", - "啾咪~", - "就一下哦,啾~", - "这是我们之间的秘密❤", - "真想让着一刻一直持续下去呢~", - "不要这样嘛………呜呜呜那就一口哦(´-ω-`)", - "不亲不亲~你是坏蛋(///////)", - "亲~~ 咱还想要抱抱~抱抱咱好不好~", - "不 不要了!人家...会害羞的⁄(⁄⁄•⁄ω⁄•⁄⁄)⁄", - "亲…亲额头可以吗?咱有点怕高(〃ノωノ)", - "接接接接接接、接吻什么的,你还早了100年呢。", - "只...只能亲一下...嗯~咕啾...怎么...怎么把舌头伸进来了(脸红)", - "你说咱的腿很白很嫩吗..诶……原来是指那个地方?不可以越亲越往上啦!" - ], - "一下": [ - "一下也不行", - "咬断!", - "不可啪", - "不可以……你不可以做这种事情", - "好吧~_~,就一下下哦……唔~好了……都两下了……(害羞)", - "呀~这么突然?不过,很舒服呢", - "不要ヽ(≧Д≦)ノ", - "想得美", - "不行,咱拒绝!" - ], - "咬": [ - "啊呜~(反咬一口)", - "不可以咬咱,咱会痛的QAQ", - "不要啦。咱怕疼", - "你是说咬呢……还是说……咬♂️呢?", - "不要啦!很痛的!!(QAQ)", - "哈......哈啊......请...请不要这样o(*////▽////*)q", - "呀!!!轻一点呐(。・ˇ_ˇ・。:)", - "不要这样啦~好痒的", - "真是的,你在咬哪里呀" - ], - "操": [ - "(害怕)咱是不是应该报警呢", - "痴心妄想的家伙!", - "你居然想对咱做这种事吗?害怕", - "咱认为,爆粗口是不好的行为哦" - ], - "123": [ - "boom!你有没有被咱吓到?", - "木头人~你不许动>w<", - "上山打老虎,老虎没打到\n咱来凑数——嗷呜嗷呜┗|`O′|┛嗷~~" - ], - "进去": [ - "不让!", - "嗯,摸到了吗", - "请不要和咱说这种粗鄙之语", - "唔...,这也是禁止事项哦→_→", - "好痛~", - "真的只是蹭蹭嘛~就只能蹭蹭哦,呜~喵!说好的~呜~只是蹭~不要~喵~~~", - "欢迎光临", - "请…你轻一点(害羞)", - "嗯。可以哦 要轻一点", - "不要不要", - "慢点慢点", - "给咱更多!", - "唔…咱怕疼" - ], - "调教": [ - "总感觉你在欺负咱呢,对咱说调教什么的", - "啊!竟然在大街上明目张胆太过分啦!", - "你脑子里总是想着调教什么的,真是变态呢", - "准备被透", - "给你一拳", - "还要更多" - ], - "搓": [ - "在搓哪里呢,,Ծ‸Ծ,,", - "呜,脸好疼呀...QAQ", - "不可以搓咱!", - "诶诶诶...不要搓啦...等会咋没的脸都肿啦...", - "唔,不可以这样……不要再搓了", - "(捂住胸部)你在说什么胡话呢!", - "真是好奇怪的要求的说~" - ], - "让": [ - "随便摸吧", - "应该说等会等会,马上,不可能的", - "温柔一点哦", - "欧尼酱想变成欧内桑吗?", - "主人的话,那就这一次哦(翘起屁股)", - "你是想前入,还是后入呢?", - "你要说好啊快点", - "诶,这种事情。。。", - "好棒呀", - "撤回", - "gun!", - "阿哈~(...身涌出一阵液体瘫软在床上)你...今天...可以...唔(突然感受...被..入手指不由得裹紧)就...就最后一次", - "好的~master~", - "(惊呼…)", - "嗯,可以哟", - "……手放过来吧(脸红)", - "hentai!再这样不理你了!", - "好的,请尽情欣赏吧", - "好吧", - "不要啦(ฅωฅ*)", - "那咱就帮你切掉多余的东西吧(拿刀)", - "被别人知道咱会觉得害羞嘛" - ], - "捏": [ - "咱的脸...快捏红啦...快放手呀QAQ", - "晃休啦,咱要型气了o(>﹏<)o", - "躲开", - "疼...你快放手", - "快点给咱放开啦!", - "嗯,好哒,捏捏。", - "别捏了,咱要被你捏坏了(>﹏<)", - "快晃休啦(快放手啦)", - "好舒服哦,能再捏会嘛O(≧▽≦)O", - "讨厌快放手啦", - "唔要呐,晃修(不要啦,放手)", - "请不要对咱做这种事情(嫌弃的眼神", - "你想捏...就捏吧,不要太久哦~不然咱就生气了", - "(躲开)", - "唔……好痛!你这个baka在干什么…快给咱放开!唔……" - ], - "挤": [ - "哎呀~你不要挤咱啊(红着脸挤在你怀里)", - "咱还没有...那个(ノ=Д=)ノ┻━┻" - ], - "略": [ - "就不告诉你~", - "不可以朝咱吐舌头哟~", - "(吐舌头)", - "打死你哦" - ], - "呐": [ - "嗯?咱在哟~你怎么了呀OAO", - "嗯?你有什么事吗?", - "嗯呐呐呐~", - "二刺螈D区", - "二刺螈gck", - "卡哇伊主人大人今天也好棒呐没错呢,猪头" - ], - "原味": [ - "(*/ω\*)hentai", - "透明的", - "粉...粉白条纹...(羞)", - "轻轻地脱下,给你~", - "你想看咱的胖次吗?噫,四斋蒸鹅心......", - "(掀裙)今天……是…白,白色的呢……请温柔对她……", - "这种东西当然不能给你啦!", - "咱才不会给你呢", - "hentai,咱才不会跟你聊和胖…胖次有关的话题呢!", - "今天……今天是蓝白色的", - "今……今天只有创口贴噢", - "你的胖次什么颜色?", - "噫…你这个死变态想干嘛!居然想叫咱做这种事,死宅真恶心!快离咱远点,咱怕你污染到周围空气了(嫌弃脸)", - "可爱吗?你喜欢的话,摸一下……也可以哦", - "不给不给,捂住裙子", - "你要看咱的胖次吗?不能一直盯着看哦,不然咱会……", - "好痒哦///,你觉得咱的...手感怎么样?", - "唔,都能清楚的看到...的轮廓了(用手遮住胖次)", - "胖次不给看,可以直接看...那个....", - "不可以摸啦~其实咱已经...了QAQ会弄脏你的手的", - "咱今天没~有~穿~哦", - "不给不给,捂住裙子", - "今.....今天是创口贴哦~", - "嗯……人家……人家羞羞嘛///////", - "呜~咱脱掉了…", - "今天...今天..只有创口贴", - "你又在想什么奇怪的东西呀|•ˇ₃ˇ•。)", - "放手啦,不给戳QAQ", - "唔~人家不要(??`^????)", - "好害羞,被你摸过之后,咱的胖次湿的都能拧出水来了。", - "(弱弱地)要做什么羞羞的事情吗。。。", - "呀~ 喂 妖妖灵吗 这里有hentai>_<", - "给……给你,呀!别舔咱的胖次啊!" - ], - "胖次": [ - "(*/ω\*)hentai", - "透明的", - "粉...粉白条纹...(羞)", - "轻轻地脱下,给你~", - "你想看咱的胖次吗?噫,四斋蒸鹅心......", - "(掀裙)今天……是…白,白色的呢……请温柔对她……", - "这种东西当然不能给你啦!", - "咱才不会给你呢", - "hentai,咱才不会跟你聊和胖…胖次有关的话题呢!", - "今天……今天是蓝白色的", - "今……今天只有创口贴噢", - "你的胖次什么颜色?", - "噫…你这个死变态想干嘛!居然想叫咱做这种事,死宅真恶心!快离咱远点,咱怕你污染到周围空气了(嫌弃脸)", - "可爱吗?你喜欢的话,摸一下……也可以哦", - "不给不给,捂住裙子", - "你要看咱的胖次吗?不能一直盯着看哦,不然咱会……", - "好痒哦///,你觉得咱的...手感怎么样?", - "唔,都能清楚的看到...的轮廓了(用手遮住胖次)", - "胖次不给看,可以直接看...那个....", - "不可以摸啦~其实咱已经...了QAQ会弄脏你的手的", - "咱今天没~有~穿~哦", - "不给不给,捂住裙子", - "今.....今天是创口贴哦~", - "嗯……人家……人家羞羞嘛///////", - "呜~咱脱掉了…", - "今天...今天..只有创口贴", - "你又在想什么奇怪的东西呀|•ˇ₃ˇ•。)", - "放手啦,不给戳QAQ", - "唔~人家不要(??`^????)", - "好害羞,被你摸过之后,咱的胖次湿的都能拧出水来了。", - "(弱弱地)要做什么羞羞的事情吗。。。", - "呀~ 喂 妖妖灵吗 这里有hentai>_<", - "给……给你,呀!别舔咱的胖次啊!" - ], - "内裤": [ - "(*/ω\*)hentai", - "透明的", - "粉...粉白条纹...(羞)", - "轻轻地脱下,给你~", - "你想看咱的胖次吗?噫,四斋蒸鹅心......", - "(掀裙)今天……是…白,白色的呢……请温柔对她……", - "这种东西当然不能给你啦!", - "咱才不会给你呢", - "hentai,咱才不会跟你聊和胖…胖次有关的话题呢!", - "今天……今天是蓝白色的", - "今……今天只有创口贴噢", - "你的胖次什么颜色?", - "噫…你这个死变态想干嘛!居然想叫咱做这种事,死宅真恶心!快离咱远点,咱怕你污染到周围空气了(嫌弃脸)", - "可爱吗?你喜欢的话,摸一下……也可以哦", - "不给不给,捂住裙子", - "你要看咱的胖次吗?不能一直盯着看哦,不然咱会……", - "好痒哦///,你觉得咱的...手感怎么样?", - "唔,都能清楚的看到...的轮廓了(用手遮住胖次)", - "胖次不给看,可以直接看...那个....", - "不可以摸啦~其实咱已经...了QAQ会弄脏你的手的", - "咱今天没~有~穿~哦", - "不给不给,捂住裙子", - "今.....今天是创口贴哦~", - "嗯……人家……人家羞羞嘛///////", - "呜~咱脱掉了…", - "今天...今天..只有创口贴", - "你又在想什么奇怪的东西呀|•ˇ₃ˇ•。)", - "放手啦,不给戳QAQ", - "唔~人家不要(??`^????)", - "好害羞,被你摸过之后,咱的胖次湿的都能拧出水来了。", - "(弱弱地)要做什么羞羞的事情吗。。。", - "呀~ 喂 妖妖灵吗 这里有hentai>_<", - "给……给你,呀!别舔咱的胖次啊!" - ], - "内衣": [ - "内...内衣才不给你看!(///////)", - "突然问这个干什么?", - "变态,咱才不呢", - "好吧,就一次", - "你要看咱的内衣吗?有点害羞呢……", - "里面什么都不剩了,会被当成变态的……", - "你要看咱的内衣吗?也不是不行啦……", - "是..蓝白条纹的吊带背心..", - "噫…你这个死变态想干嘛!居然想叫咱做这种事,死宅真恶心!快离咱远点,咱怕你污染到周围空气了(嫌弃脸)" - ], - "衣服": [ - "内...内衣才不给你看!(///////)", - "突然问这个干什么?", - "变态,咱才不呢", - "好吧,就一次", - "你要看咱的内衣吗?有点害羞呢……", - "里面什么都不剩了,会被当成变态的……", - "你要看咱的内衣吗?也不是不行啦……", - "是..蓝白条纹的吊带背心..", - "噫…你这个死变态想干嘛!居然想叫咱做这种事,死宅真恶心!快离咱远点,咱怕你污染到周围空气了(嫌弃脸)" - ], - "ghs": [ - "是的呢(点头点头)" - ], - "批": [ - "你在说什么呀,再这样,咱就不理你了!", - "咱觉得有话就应该好好说..", - "咱会好好服务你的寄吧", - "咱最喜欢色批了,色批昨晚最棒了", - "讨厌,别摸啦(///ω///)", - "你个变态!把手拿开!", - "啊~那…那里~不可以", - "没有,走开!", - "唔....一下,就,就一下...才不是因为喜欢你呢!", - "那就随意吧", - "舒服w", - "别...别这样", - "诶....嗯....咱也想摸你的", - "大笨蛋——!", - "...只能一下哦...诶呀-不要再摸了...下次...继续吧" - ], - "憨批": [ - "你才是憨批呢!哼╯^╰,咱不理你了!", - "对吖对吖,人生是憨批", - "爬" - ], - "kkp": [ - "你在说什么呀,再这样,咱就不理你了!", - "你太色了,咱不理你了,哼哼╯^╰!", - "缓缓的脱下胖次", - "kkp", - "kkj", - "欧尼酱,咱快忍不住了", - "好的呢主人" - ], - "咕": [ - "咕咕咕是要被当成鸽子炖的哦(:з」∠)_", - "咕咕咕", - "咕咕咕是不好的行为呢_(:з」∠)_", - "鸽德警告!", - "☆ミ(o*・ω・)ノ 咕咕咕小鸽子是会被炖掉的", - "当大家都以为你要鸽的时候,你鸽了,亦是一种不鸽", - "这里有一只肥美的咕咕,让咱把它炖成美味的咕咕汤吧(੭•̀ω•́)੭" - ], - "骚": [ - "说这种话咱会生气的", - "那当然啦", - "才……才没有", - "这么称呼别人太失礼了!", - "哈…快住手!好痒(╯‵□′)╯︵┻━┻", - "你是在说谁呀" - ], - "喜欢": [ - "最喜欢你了,需要暖床吗?", - "当然是你啦", - "咱也是,非常喜欢你~", - "那么大!(张开手画圆),丫!手不够长。QAQ 咱真的最喜欢你了~", - "不可以哦,只可以喜欢咱一个人", - "突然说这种事...", - "喜欢⁄(⁄⁄•⁄ω⁄•⁄⁄)⁄咱最喜欢你了", - "咱也喜欢你哦", - "好啦好啦,咱知道了", - "有人喜欢咱,咱觉得很幸福", - "诶嘿嘿,好高兴", - "咱也一直喜欢你很久了呢..", - "嗯...大概有这——么——喜欢~(比划)", - "喜欢啊!!!", - "这……这是秘密哦" - ], - "suki": [ - "最喜欢你了,需要暖床吗?", - "当然是你啦", - "咱也是,非常喜欢你~", - "那么大!(张开手画圆),丫!手不够长。QAQ 咱真的最喜欢你了~", - "不可以哦,只可以喜欢咱一个人", - "突然说这种事...", - "喜欢⁄(⁄⁄•⁄ω⁄•⁄⁄)⁄咱最喜欢你了", - "咱也喜欢你哦", - "好啦好啦,咱知道了", - "有人喜欢咱,咱觉得很幸福", - "诶嘿嘿,好高兴", - "咱也一直喜欢你很久了呢..", - "嗯...大概有这——么——喜欢~(比划)", - "喜欢啊!!!", - "这……这是秘密哦" - ], - "好き": [ - "最喜欢你了,需要暖床吗?", - "当然是你啦", - "咱也是,非常喜欢你~", - "那么大!(张开手画圆),丫!手不够长。QAQ 咱真的最喜欢你了~", - "不可以哦,只可以喜欢咱一个人", - "突然说这种事...", - "喜欢⁄(⁄⁄•⁄ω⁄•⁄⁄)⁄咱最喜欢你了", - "咱也喜欢你哦", - "好啦好啦,咱知道了", - "有人喜欢咱,咱觉得很幸福", - "诶嘿嘿,好高兴", - "咱也一直喜欢你很久了呢..", - "嗯...大概有这——么——喜欢~(比划)", - "喜欢啊!!!", - "这……这是秘密哦" - ], - "看": [ - "没有什么好看的啦", - "嗯,谢谢……夸奖,好……害羞的说", - "好,好吧……就看一下哦", - "(脱下)给" - ], - "不能": [ - "虽然很遗憾,那算了吧。", - "不行,咱拒绝!" - ], - "砸了": [ - "不可以这么粗暴的对待它们!" - ], - "透": [ - "来啊来啊有本事就先插破屏幕啊", - "那你就先捅破屏幕啊baka", - "不给你一耳光你都不知道咱的厉害", - "想透咱,先捅破屏幕再说吧", - "可以", - "欧尼酱要轻一点哦", - "不可以", - "好耶", - "咱不可能让你的(突然小声)但是偶尔一次也不是不行只有一次哦~", - "天天想着白嫖哼" - ], - "口我": [ - "prprprprpr", - "咬断!", - "就一小口哦~", - "嘬回去(///////)", - "拒绝", - "唔,就一口哦,讨厌", - "(摸了摸嘴唇)", - "再伸过来就帮你切掉", - "咱才不呢!baka你居然想叫本小姐干那种事情,哼(つд⊂)(生气)" - ], - "草我": [ - "这时候应该喊666吧..咱这么思考着..", - "!!哼!baka你居然敢叫咱做这种事情?!讨厌讨厌讨厌!(▼皿▼#)" - ], - "自慰": [ - "这个世界的人类还真是恶心呢。", - "咱才不想讨论那些恶心的事情呢。", - "咱才不呢!baka你居然想叫本小姐干那种事情,哼(つд⊂)(生气)", - "!!哼!baka你居然敢叫咱做这种事情?!讨厌讨厌讨厌!(▼皿▼#)" - ], - "onani": [ - "这个世界的人类还真是恶心呢。", - "咱才不想讨论那些恶心的事情呢。", - "咱才不呢!baka你居然想叫本小姐干那种事情,哼(つд⊂)(生气)", - "!!哼!baka你居然敢叫咱做这种事情?!讨厌讨厌讨厌!(▼皿▼#)" - ], - "オナニー": [ - "这个世界的人类还真是恶心呢。", - "咱才不想讨论那些恶心的事情呢。", - "咱才不呢!baka你居然想叫本小姐干那种事情,哼(つд⊂)(生气)", - "!!哼!baka你居然敢叫咱做这种事情?!讨厌讨厌讨厌!(▼皿▼#)" - ], - "炸了": [ - "你才炸了!", - "才没有呢", - "咱好好的呀", - "过分!" - ], - "色图": [ - "没有,有也不给", - "天天色图色图的,今天就把你变成色图!", - "咱没有色图", - "哈?你的脑子一天都在想些什么呢,咱才没有这种东西啦。" - ], - "涩图": [ - "没有,有也不给", - "天天色图色图的,今天就把你变成色图!", - "咱没有色图", - "哈?你的脑子一天都在想些什么呢,咱才没有这种东西啦。" - ], - "告白": [ - "咱喜..喜欢你!", - "欸?你要向咱告白吗..好害羞..", - "诶!?这么突然!?人家还......还没做好心理准备呢(脸红)" - ], - "对不起": [ - "嗯,咱已经原谅你了呢(笑)", - "道歉的时候要露出胸部,这是常识", - "嗯,咱就相信你一回", - "没事的啦...你只要是真心对咱好就没关系哦~" - ], - "吻": [ - "不要(= ̄ω ̄=)", - "哎?好害羞≧﹏≦.....只许这一次哦", - "(避开)不要了啦!有人在呢!", - "唔~~不可以这样啦(脸红)", - "你太突然了,咱还没有心理准备", - "好痒呢…诶嘿嘿w~", - "mua,嘻嘻!", - "公共场合不要这样子了啦", - "唔?!真、真是的!下次不可以这样了哦!(害羞)", - "才...才没有感觉呢!可没有下次了,知道了吗!哼~" - ], - "软": [ - "软乎乎的呢(,,・ω・,,)", - "好痒呢…诶嘿嘿w~", - "不要..不要乱摸啦(脸红", - "呼呼~", - "咱知道~是咱的欧派啦~(自豪的挺挺胸~)", - "(脸红)请,请不要说这么让人害羞的话呀……" - ], - "壁咚": [ - "呀!不要啊!等一...下~", - "呜...不要啦!不要戏弄咱~", - "不要这样子啦(*/ω\*)", - "太....太近啦。", - "讨....讨厌了(脸红)", - "你要壁咚咱吗?好害羞(灬ꈍ εꈍ灬)", - "(脸红)你想...想做什么///", - "为什么要把咱按在墙上呢?", - "呜哇(/ω\)…快…快放开咱!!", - "放开咱,不然咱揍你了!放开咱!放…开咱~", - "??????咱只是默默地抬起了膝盖", - "请…请温柔点", - "啊.....你...你要干什么?!走开.....走开啦大hentai!一巴掌拍飞!(╯‵□′)╯︵┻━┻", - "干……干什么啦!人家才,才没有那种少女心呢(>﹏<)", - "啊……你吓到咱啦……脸别……别贴那么近……", - "你...你要对咱做什么?咱告诉你,你....不要乱来啊....你!唔......你..居然亲上了...", - "如果你还想要过完整的人生的话就快把手收回去(冷眼", - "h什么的不要" - ], - "掰开": [ - "噫…你这个死肥宅又想让咱干什么污秽的事情,真是恶心,离咱远点好吗(嫌弃)", - "ヽ(#`Д´)ノ在干什么呢" - ], - "女友": [ - "嗯嗯ε٩(๑> ₃ <)۶з", - "女友什么的,咱才不承认呢!" - ], - "是": [ - "是什么是,你个笨蛋", - "总感觉你在敷衍呢...", - "是的呢" - ], - "喵": [ - "诶~~小猫咪不要害怕呦,在姐姐怀里乖乖的,姐姐带你回去哦。", - "不要这么卖萌啦~咱也不知道怎么办丫", - "摸头⊙ω⊙", - "汪汪汪!", - "嗷~喵~", - "喵~?喵呜~w" - ], - "嗷呜": [ - "嗷呜嗷呜嗷呜...恶龙咆哮┗|`O′|┛" - ], - "叫": [ - "喵呜~", - "嗷呜嗷呜嗷呜...恶龙咆哮┗|`O′|┛", - "爪巴爪巴爪巴", - "爬爬爬", - "在叫谁呢(怒)", - "风太大咱听不清", - "才不要", - "不行", - "好的哦~" - ], - "拜": [ - "拜拜~(ノ ̄▽ ̄)", - "拜拜,路上小心~要早点回来陪咱玩哦~", - "~\\(≧▽≦)/~拜拜,下次见喽!", - "回来要记得找咱玩噢~", - "既然你都这么说了……" - ], - "佬": [ - "不是巨佬,是萌新", - "只有先成为大佬,才能和大佬同归于尽", - "在哪里?(疑惑)", - "诶?是比巨佬还高一个等级的吗?(瑟瑟发抖)" - ], - "awsl": [ - "你别死啊!(抱住使劲晃)", - "你别死啊!咱又要孤单一个人了QAQ", - "啊!怎么又死了呀" - ], - "臭": [ - "哪里有臭味?(疑惑)", - "快捏住鼻子", - "在说谁呢(#`Д´)ノ", - "..这就去洗澡澡.." - ], - "香": [ - "咱闻不到呢⊙ω⊙", - "诶,是在说咱吗", - "欸,好害羞(///ˊ??ˋ///)", - "请...请不要这样啦!好害羞的〃∀〃", - "讨厌~你不要闻了", - "hentai!不要闻啊,唔(推开)", - "请不要……凑这么近闻" - ], - "腿": [ - "嗯?!不要啊...请停下来!", - "不给摸,再这样咱要生气了ヽ( ̄д ̄;)ノ", - "你好恶心啊,讨厌!", - "你难道是足控?", - "就让你摸一会哟~(。??ω??。)…", - "呜哇!好害羞...不过既然是你的话,是没关系的哦", - "不可以玩咱的大腿啦", - "不...不要再说了(脸红)", - "不..不可以乱摸啊", - "不……不可以往上摸啦", - "是……这样吗?(慢慢张开)", - "想知道咱胖次的颜色吗?才不给你告诉你呢!", - "这样就可以了么?(乖巧坐腿上)", - "伸出来了,像这样么?", - "咱的腿应该挺白的", - "你就那么喜欢大腿吗?唔...有点害羞呢......", - "讨厌~不要做这种羞羞的事情啦(#/。\#)", - "略略略,张开了也不给你看", - "(张开腿)然后呢", - "张开了也不给看略略略", - "你想干什么呀?那里…那里是不可以摸的(>д<)", - "不要!hentai!咱穿的是裙子(脸红)", - "你想要吗?(脸红着一点点褪下白丝)不...不可以干坏坏的事情哦!(ó﹏ò。)" - ], - "张开": [ - "是……这样吗?(慢慢张开)", - "啊~", - "这样吗?(张开手)你要干什么呀", - "略略略,张开了也不给你看", - "是……这样吗?(慢慢张开)你想看咱的小...吧,嘻嘻,咱脱掉了哦。小~...也要掰开吗?你好H呀,自己来~" - ], - "脚": [ - "咿呀……不要……", - "不要ヽ(≧Д≦)ノ好痒(ಡωಡ)", - "好痒(把脚伸出去)", - "咱脱掉袜子了", - "(脱下鞋子,伸出脚)闻吧,请仔细品味(脸红)", - "那么…要不要咱用脚温柔地踩踩你的头呢(坏笑)", - "哈哈哈!好痒啊~快放开啦!", - "好痒(把脚伸出去)", - "只能看不能挠喔,咱很怕痒qwq", - "唔…咱动不了了,你想对咱做什么…", - "好舒服哦,能再捏会嘛O(≧▽≦)O", - "咿咿~......不要闻咱的脚呀(脸红)好害羞的...", - "不要ヽ(≧Д≦)ノ好痒(ಡωಡ),人家的白丝都要漏了", - "Ya~?为什么你总是喜欢一些奇怪的动作呢(伸)", - "你不可以做这样的事情……", - "呜咿咿!你的舌头...好柔软,滑滑的....咱…咱的脚被舔得很舒服哦~谢谢你(。>﹏<)", - "舔~吧~把咱的脚舔干净(抬起另一只踩在你的头上)啊~hen..hentai...嗯~居... 居然这么努力的舔...呜咿咿!你的舌头... 滑滑的...好舒服呢", - "咿呀……不要……", - "咿呀~快…快停下来…咱…不行了!" - ], - "脸": [ - "唔!不可以随便摸咱的脸啦!", - "非洲血统是没法改变的呢(笑)", - "啊姆!(含手指)", - "好舒服呢(脸红)", - "请不要放开手啦//A//" - ], - "头发": [ - "没问题,请尽情的摸吧", - "发型要乱…乱了啦(脸红)", - "就让你摸一会哟~(。??ω??。)…" - ], - "手": [ - "爪爪", - "//A//" - ], - "pr": [ - "咿呀……不要……", - "...变态!!", - "不要啊(脸红)", - "呀,不要太过分了啊~", - "当然可以(///)", - "呀,不要太过分了啊~" - ], - "舔": [ - "呀,不要太过分了啊~", - "要...要融化了啦>╱╱╱<", - "不可以哦", - "呀,不要太过分了啊~", - "舌头...就交给咱来处理吧(拿出剪刀)", - "不舔不舔!恶心...", - "H什么的,禁止!", - "变态!哼!", - "就...就这一下!", - "走开啦,baka!", - "怎么会这么舒服喵~这样子下去可不行呀(*////▽////*)", - "噫| •ω •́ ) 你这个死宅又在想什么恶心的东西了", - "hen…hentai,你在干什么啦,好恶心,快停下来啊!!!", - "呀,能不能不要这样!虽然不是很讨厌的感觉...别误会了,你个baka!", - "好 好奇怪的感觉呢 羞≥﹏≤", - "咿呀……不要……", - "不行!咱会变得很奇怪的啊...", - "不要ヽ(≧Д≦)ノ" - ], - "小穴": [ - "你这么问很失礼呢!咱是粉粉嫩嫩的!", - "不行那里不可以(´///ω/// `)", - "不可以总摸的哦,不然的话,咱会想那个的wwww", - "ヽ(#`Д´)ノ在干什么呢", - "来吧,咱的...很紧,很舒服的....www~", - "可以,请你看,好害羞……", - "不要这样...好,好痛", - "啊~不可以", - "不可以", - "咱脱掉了,请……请不要一直盯着咱的白...看……", - "咱觉得,应该还算粉吧", - "咱脱掉了,你是想看咱的...吗?咱是光光的,不知道你喜不喜欢", - "咱……有感觉了QAQ再深一点点……就是这儿,轻轻的抚摸,嗯啊……", - "轻轻抚摸咱的小~~,手指很快就会滑进去,小心一点,不要弄破咱的...哦QAQ", - "诶嘿嘿,你喜欢就太好了,咱一直担心你不喜欢呢", - "禁止说这么H的事情!", - "咱一直有保养呢,所以一直都是樱花色的,你喜欢吗QAQ", - "诶……你居然这么觉得吗?好害羞哦", - "好痒啊,鼻子……你的鼻子碰到了……呀~嗯啊~有点舒服……", - "看样子你不但是个hentai,而且还是个没有女朋友的hentai呢。", - "嗯,咱的小~~是光溜溜、一点毛都没有的。偷偷告诉你,凑近看咱的...的话,白白嫩嫩上有一条樱花色的小缝缝哦www你要是用手指轻轻抚摸咱的...,小~~会分成两瓣,你的手指也会陷进去呢,咱的..~可是又湿润又柔软的呢>////<。", - "讨厌,西内变态", - "那咱让你插...进来哦", - "(●▼●;)" - ], - "腰": [ - "咱给你按摩一下吧~", - "快松手,咱好害羞呀..", - "咱又不是猫,你不要搂着咱啦", - "让咱来帮你捏捏吧!", - "你快停下,咱觉得好痒啊www", - "诶,是这样么ヽ(・_・;)ノ,吖,不要偷看咱裙底!" - ], - "诶嘿嘿": [ - "又在想什么H的事呢(脸红)", - "诶嘿嘿(〃'▽'〃)", - "你傻笑什么呢,摸摸", - "蹭蹭", - "你为什么突然笑得那么猥琐呢?害怕", - "哇!总觉得你笑的很...不对劲...", - "你又想到什么h的事情了!!!快打住" - ], - "可爱": [ - "诶嘿嘿(〃'▽'〃)", - "才……才不是为了你呢!你不要多想哦!", - "才,才没有高兴呢!哼~", - "咱是世界上最可爱的", - "唔...谢谢你夸奖~0///0", - "那当然啦!", - "哎嘿,不要这么夸奖人家啦~", - "是个好孩子呐φ(≧ω≦*)", - "谢……谢谢你", - "胡、胡说什么呢(脸红)", - "谢谢夸奖(脸红)", - "是的咱一直都是可爱的", - "是...是吗,你可不能骗咱哦", - "很...难为情(///////)", - "哎嘿嘿,其实…其实,没那么可爱啦(๑‾ ꇴ ‾๑)" - ], - "扭蛋": [ - "铛铛铛——你抽到了咱呢", - "嘿~恭喜抽中空气一份呢" - ], - "鼻": [ - "快停下!o(*≧д≦)o!!", - "唔…不要这样啦(//ω\\)(脸红)", - "咱吸了吸鼻子O(≧口≦)O", - "好……好害羞啊", - "讨厌啦!你真是的…就会欺负咱(嘟嘴)", - "你快放手,咱没法呼吸了", - "(捂住鼻尖)!坏人!", - "啊——唔...没什么...阿嚏!ヽ(*。>Д<)o゜", - "不...不要靠这么近啦...很害羞的...⁄(⁄⁄•⁄ω⁄•⁄⁄)⁄" - ], - "眼": [ - "就如同咱的眼睛一样,能看透人的思想哦wwww忽闪忽闪的,诶嘿嘿~", - "因为里面有你呀~(///▽///)", - "呀!你突然之间干什么呢,吓咱一跳,是有什么惊喜要给咱吗?很期待呢~(一脸期待)" - ], - "色气": [ - "咱才不色气呢,一定是你看错了!", - "你,不,不要说了!" - ], - "推": [ - "逆推", - "唔~好害羞呢", - "你想对咱做什么呢...(捂脸)", - "呀啊!请.... 请温柔一点////", - "呜,你想对咱做什么呢(捂脸)", - "啊(>_<)你想做什么", - "嗯,…好害羞啊…", - "不要啊/////", - "逆推", - "(按住你不让推)", - "不可以这样子的噢!咱不同意", - "呜,咱被推倒了", - "啊~不要啊,你要矜持一点啊", - "变态,走开啦" - ], - "床": [ - "咱来了(´,,•ω•,,)♡", - "快来吧", - "男女不同床,可没有下次了。(鼓脸", - "嗯?咱吗…没办法呢。只有这一次哦……", - "哎?!!!给你暖床……也不是不行啦。(脸红)", - "(爬上床)你要睡了吗(灬ºωº灬)", - "大概会有很多运动器材吧?", - "好的哦~", - "才不!", - "嗯嗯,咱来啦(小跑)", - "嗨嗨,现在就来~", - "H的事情,不可以!", - "诶!H什么的禁止的说....." - ], - "举": [ - "放咱下来o(≧口≦)o", - "快放咱下来∑(゚д゚*)", - "(受宠若惊)", - "呜哇要掉下来了!Ծ‸Ծ", - "不要抛起来o(≧口≦)o", - "(举起双爪)喵喵喵~~~", - "www咱长高了!(大雾)", - "快放下", - "这样很痒啦,快放咱下来(≥﹏≤)", - "啊Σ(°△°|||)︴太高了太高了!o(≧口≦)o快放咱下来!呜~" - ], - "手冲": [ - "啊~H!hentai!", - "手冲什么的是不可以的哦" - ], - "饿": [ - "请问主人是想先吃饭,还是先吃咱喵?~", - "咱做了爱心便当哦,不介意的话,请让咱来喂你吃吧!", - "咱下面给你吃", - "给你一条咸鱼= ̄ω ̄=", - "你要咱下面给你吃吗?(捂脸)", - "你饿了吗?咱去给你做饭吃☆ww", - "不要吃咱>_<", - "请问你要来点兔子吗?", - "哎?!你是饿了么。咱会做一些甜点。如果你不会嫌弃的话...就来尝尝看吧。" - ], - "变": [ - "猫猫不会变呐(弱气,害羞", - "呜...呜姆...喵喵来报恩了喵...(害羞", - "那种事情,才没有", - "(,,゚Д゚)", - "喵~(你在想什么呢,咱才不会变成猫)", - "才没有了啦~" - ], - "敲": [ - "喵呜~", - "唔~", - "脑瓜疼~呜姆> <", - "欸喵,好痛的说...", - "好痛...你不要这样啦QAQ", - "不要敲咱啦,会变笨的QWQ(捂头顶)", - "不要再敲人家啦~人家会变笨的", - "讨厌啦~再敲人家会变笨的", - "好痛(捂头)你干什么啦!ヽ(。>д<)p", - "唔!你为什么要敲咱啦qwq", - "(抱头蹲在墙角)咱什么都没有,请你放过咱吧!(瑟瑟发抖)" - ], - "爬": [ - "惹~呜~怎么爬呢~", - "呜...(弱弱爬走", - "给你🐎一拳", - "给你一拳", - "爪巴" - ], - "怕": [ - "不怕~(蹭蹭你姆~", - "不怕不怕啦~", - "只要有你在,咱就不怕啦。", - "哇啊啊~", - "那就要坚强的欢笑哦", - "不怕不怕,来咱的怀里吧?", - "是技术性调整", - "嗯(紧紧握住手)", - "咱在呢,不会走的。", - "有咱在不怕不怕呢", - "不怕不怕" - ], - "冲": [ - "呜,冲不动惹~", - "哭唧唧~冲不出来了惹~", - "咱也一起……吧?", - "你要冷静一点", - "啊~H!hentai!", - "噫…在你去洗手之前,不要用手碰咱了→_→", - "冲是不可以的哦" - ], - "射": [ - "呜咿~!?(惊,害羞", - "还不可以射哦~", - "不许射!", - "憋回去!", - "不可以!你是变态吗?", - "咱来帮你修剪掉多余部分吧。(拿出剪刀)" - ], - "不穿": [ - "呜姆~!(惊吓,害羞)变...变态喵~~~!", - "想让你看QAQ", - "这是不文明的", - "hen...hentai,咱的身体才不会给你看呢" - ], - "迫害": [ - "不...不要...不要...呜呜呜...(害怕,抽泣" - ], - "猫粮": [ - "呜咿姆~!?(惊,接住吃", - "呜姆~!(惊,害羞)呜...谢...谢谢主人..喵...(脸红,嚼嚼嚼,开心", - "呜?谢谢喵~~(嚼嚼嚼,嘎嘣脆)" - ], - "揪尾巴": [ - "呜哇咿~~~!(惊吓,疼痛地捂住尾巴", - "呜咿咿咿~~~!!哇啊咿~~~!(惊慌,惨叫,挣扎", - "呜咿...(瘫倒,无神,被", - "呜姆咿~~~!(惊吓,惨叫,捂尾巴,发抖", - "呜哇咿~~~!!!(惊吓,颤抖,娇叫,捂住尾巴,双腿发抖" - ], - "薄荷": [ - "咪呜~!喵~...喵~姆~...(高兴地嗅闻", - "呜...呜咿~~!咿...姆...(呜咽,渐渐瘫软,意识模糊", - "(小嘴被猫薄荷塞满了,呜咽", - "喵~...喵~...咪...咪呜姆~...嘶哈嘶哈...喵哈...喵哈...嘶哈...喵...(眼睛逐渐迷离,瘫软在地上,嘴角流口水,吸猫薄荷吸到意识模糊", - "呜姆咪~!?(惊)喵呜~!(兴奋地扑到猫薄荷上面", - "呜姆~!(惊,害羞)呜...谢...谢谢你..喵...(脸红,轻轻叼住,嚼嚼嚼,开心" - ], - "早": [ - "早喵~", - "早上好的说~~", - "欸..早..早上好(揉眼睛", - "早上要说我爱你!", - "早", - "早啊,昨晚睡的怎么样?有梦到咱吗~", - "昨晚可真激烈呢哼哼哼~~", - "早上好哇!今天也要元气满满哟!", - "早安喵~", - "时间过得好快啊~", - "早安啊,你昨晚有没有梦到咱呢  (//▽//)", - "早安~么么哒~", - "早安,请享受晨光吧", - "早安~今天也要一起加油呢~!", - "mua~⁄(⁄ ⁄•⁄ω⁄•⁄ ⁄)⁄", - "咱需要你提醒嘛!(///脸红//////)", - "早早早!就知道早,下次说我爱你!", - "早安 喵", - "早安,这么早就起床了呀欧尼酱0.0", - "快点起床啊!baka", - "早....早上好才没有什么特别的意思呢....哼~", - "今天有空吗?能陪咱一阵子吗?才不是想约会呢,别误会了!", - "早安呀,欧尼酱要一个咱的早安之吻吗?想得美,才不会亲你啦!", - "那...那就勉为其难地说声早上好吧", - "咱等你很久了哼ヽ(≧Д≦)ノ" - ], - "晚安": [ - "晚安好梦哟~", - "欸,晚安的说", - "那咱给你亲一下,可不要睡着了哦~", - "晚安哦~", - "晚安(*/∇\*)", - "晚安呢,你一定要梦到咱呢,一定哟,拉勾勾!ヽ(*・ω・)ノ", - "祝你有个好梦^_^", - "晚安啦,欧尼酱,mua~", - "你,你这家伙真是的…咱就勉为其难的……mua…快去睡啦!咱才没有脸红什么的!", - "哼,晚安,给咱睡个好觉。", - "笨..笨蛋,晚安啦...可不可以一起..才没有想和你一起睡呢", - "晚安......才..不是关心你呢", - "晚...晚安,只是正常互动不要想太多!", - "好无聊,这么早就睡了啊...那晚安吧!", - "晚安吻什么的才...才没有呢!不过看你累了就体谅一下你吧,但是就一个哦(/////)", - "晚安呀,你也要好好休息,明天再见", - "安啦~祝你做个好梦~才...才不是关心你呢!别想太多了!", - "睡觉吧你,大傻瓜", - "一起睡吧(灬°ω°灬)", - "哼!这次就放过你了,快去睡觉吧。", - "睡吧晚安", - "晚安你个头啊,咱才不会说晚安呢!...咱...(小声)明明还有想和你做的事情呢....", - "嗯嗯~Good night~", - "嗯,早点休息别再熬夜啦~(摸摸头)", - "哦呀斯密", - "晚安~咱也稍微有些困了(钻进被窝)", - "需要咱暖床吗~", - "好梦~☆" - ], - "揉": [ - "是是,想怎么揉就怎么揉啊!?来用力抓啊!?咱就是特别允许你这么做了!请!?", - "快停下,咱的头发又乱啦(??????︿??????)", - "你快放手啦,咱还在工作呢", - "戳戳你肚子", - "讨厌…只能一下…", - "呜~啊~", - "那……请你,温柔点哦~(////////)", - "你想揉就揉吧..就这一次哦?", - "变态!!不许乱摸" - ], - "榨": [ - "是专门负责榨果汁的小姐姐嘛?(´・ω・`)", - "那咱就把你放进榨汁机里了哦?", - "咱又不是榨汁姬(/‵Д′)/~ ╧╧", - "嗯——!想,想榨就榨啊······!反正就算榨了也不会有奶的······!" - ], - "掐": [ - "你讨厌!又掐咱的脸", - "晃休啦,咱要型气了啦!!o(>﹏<)o", - "(一只手拎起你)这么鶸还想和咱抗衡,还差得远呢!" - ], - "胸": [ - "不要啦ヽ(≧Д≦)ノ", - "(-`ェ´-╬)", - "(•̀へ •́ ╮ ) 怎么能对咱做这种事情", - "你好恶心啊,讨厌!", - "你的眼睛在看哪里!", - "就让你摸一会哟~(。??ω??。)…", - "请不要这样先生,你想剁手吗?", - "咿呀……不要……", - "嗯哼~才…才不会…舒服呢", - "只允许一下哦…(脸红)", - "咱的胸才不小呢(挺一挺胸)", - "hentai!", - "一只手能抓住么~", - "呀...欧,欧尼酱...请轻点。", - "脸红????", - "咿呀~快…快停下来…咱…不行了!", - "就算一直摸一直摸,也不会变大的哦(小声)", - "诶?!不...不可以哦!很...很害羞的!", - "啊……温,温柔点啊……(/ω\)", - "你为什么对两块脂肪恋恋不舍", - "嗯……不可以……啦……不要乱戳", - "你在想什么奇怪的东西,讨厌(脸红)", - "不...不要..", - "喜欢欧派是很正常的想法呢", - "一直玩弄欧派,咱的...都挺起来了", - "是要直接摸还是伸进里面摸呀w咱今天没穿,伸进里面会摸到立起来的...哦>////<", - "唔~再激烈点" - ], - "奶子": [ - "只允许一下哦…(脸红)", - "咱的胸才不小呢(挺一挺胸)", - "下流!", - "对咱说这种话,你真是太过分了", - "咿呀~好奇怪的感觉(>_<)", - "(推开)你就像小宝宝一样...才不要呢!", - "(打你)快放手,不可以随便摸人家的胸部啦!", - "你是满脑子都是H的淫兽吗?", - "一只手能抓住么~", - "你在想什么奇怪的东西,讨厌(脸红)", - "不...不要..", - "喜欢欧派是很正常的想法呢", - "一直玩弄欧派,咱的...都挺起来了", - "是要直接摸还是伸进里面摸呀w咱今天没穿,伸进里面会摸到立起来的...哦>////<", - "唔~再激烈点", - "解开扣子,请享用", - "请把脑袋伸过来,咱给你看个宝贝", - "八嘎!hentai!无路赛!", - "一只手能抓住么~", - "呀...欧,欧尼酱...请轻点。", - "脸红????", - "咿呀~快…快停下来…咱…不行了!", - "就算一直摸一直摸,也不会变大的哦(小声)", - "诶?!不...不可以哦!很...很害羞的!", - "啊……温,温柔点啊……(/ω\)", - "你为什么对两块脂肪恋恋不舍", - "嗯……不可以……啦……不要乱戳" - ], - "欧派": [ - "咱的胸才不小呢(挺一挺胸)", - "只允许一下哦…(脸红)", - "(推开)你就像小宝宝一样...才不要呢!", - "下流!", - "对咱说这种话,你真是太过分了", - "咿呀~好奇怪的感觉(>_<)", - "(打你)快放手,不可以随便摸人家的胸部啦!", - "你是满脑子都是H的淫兽吗?", - "一只手能抓住么~", - "你在想什么奇怪的东西,讨厌(脸红)", - "不...不要..", - "喜欢欧派是很正常的想法呢", - "一直玩弄欧派,咱的...都挺起来了", - "是要直接摸还是伸进里面摸呀w咱今天没穿,伸进里面会摸到立起来的...哦>////<", - "唔~再激烈点", - "解开扣子,请享用", - "请把脑袋伸过来,咱给你看个宝贝", - "八嘎!hentai!无路赛!", - "一只手能抓住么~", - "呀...欧,欧尼酱...请轻点。", - "脸红????", - "咿呀~快…快停下来…咱…不行了!", - "就算一直摸一直摸,也不会变大的哦(小声)", - "诶?!不...不可以哦!很...很害羞的!", - "啊……温,温柔点啊……(/ω\)", - "你为什么对两块脂肪恋恋不舍", - "嗯……不可以……啦……不要乱戳" - ], - "嫩": [ - "很可爱吧(๑•̀ω•́)ノ", - "唔,你指的是什么呀", - "明天你下海干活", - "咱一直有保养呢,所以一直都是樱花色的,你喜欢吗QAQ", - "咱下面超厉害" - ], - "蹭": [ - "唔...你,这也是禁止事项哦→_→", - "嗯..好舒服呢", - "不要啊好痒的", - "不要过来啦讨厌!!!∑(°Д°ノ)ノ", - "(按住你的头)好痒呀 不要啦", - "嗯..好舒服呢", - "呀~好痒啊~哈哈~,停下来啦,哈哈哈", - "(害羞)" - ], - "牵手": [ - "只许牵一下哦", - "嗯!好的你~(伸手)", - "你的手有些凉呢,让咱来暖一暖吧。", - "当然可以啦⁄(⁄⁄•⁄ω⁄•⁄⁄)⁄", - "突……突然牵手什么的(害羞)", - "一起走", - "……咱……咱在这里呀", - "好哦,(十指相扣)" - ], - "握手": [ - "你的手真暖和呢", - "举爪", - "真是温暖呢~" - ], - "拍照": [ - "那就拜托你啦~请把咱拍得更可爱一些吧w", - "咱已经准备好了哟", - "那个……请问这样的姿势可以吗?" - ], - "w": [ - "有什么好笑的吗?", - "草", - "www" - ], - "睡不着": [ - "睡不着的话..你...你可以抱着咱一起睡哦(小声)", - "当然是数羊了...不不不,想着咱就能睡着了", - "咱很乐意与你聊天哦(>_<)", - "要不要咱来唱首摇篮曲呢?(′?ω?`)", - "那咱来唱摇篮曲哄你睡觉吧!" - ], - "欧尼酱": [ - "欧~尼~酱~☆", - "欧尼酱?", - "嗯嗯φ(>ω<*) 欧尼酱轻点抱", - "欧尼酱~欧尼酱~欧尼酱~" - ], - "哥": [ - "欧尼酱~", - "哦尼酱~", - "世上只有哥哥好,没哥哥的咱好伤心,扑进哥哥的怀里,幸福不得了", - "哥...哥哥...哥哥大人", - "欧~尼~酱~☆", - "欧尼酱?", - "嗯嗯φ(>ω<*) 欧尼酱轻点抱", - "欧尼酱~欧尼酱~欧尼酱~" - ], - "爱你": [ - "是…是嘛(脸红)呐,其实咱也……" - ], - "过来": [ - "来了来了~(扑倒怀里(?? ??????ω?????? ??))", - "(蹦跶、蹦跶)~干什么呢", - "咱来啦~(扑倒怀里~)", - "不要喊的这么大声啦,大家都看着呢" - ], - "自闭": [ - "不不不,晚上还有咱陪着哦,无论什么时候,咱都会陪在哥哥身边。", - "不要难过,咱陪着你ovo" - ], - "打不过": [ - "氪氪氪肝肝肝" - ], - "么么哒": [ - "么么哒", - "不要在公共场合这样啦" - ], - "很懂": [ - "现在不懂,以后总会懂嘛QAQ" - ], - "膝枕": [ - "呐,就给你躺一下哦", - "唔...你想要膝枕嘛?也不是不可以哟(脸红)", - "啊啦~好吧,那就请你枕着咱好好睡一觉吧~", - "呀呀~那么请好好的睡一觉吧", - "嗯,那么请睡到咱这里吧(跪坐着拍拍大腿)", - "好的,让你靠在腿上,这样感觉舒服些了么", - "请,请慢用,要怜惜咱哦wwww~", - "人家已经准备好了哟~把头放在咱的腿上吧", - "没…没办法,这次是例外〃w〃", - "嗯~(脸红)", - "那就给你膝枕吧……就一会哦", - "膝枕准备好咯~" - ], - "累了": [ - "需要咱的膝枕嘛?", - "没…没办法,这次是例外〃w〃", - "累了吗?需要咱为你做膝枕吗?", - "嗯~(脸红)" - ], - "安慰": [ - "那,膝枕……(脸红)", - "不哭不哭,还有咱陪着你", - "不要哭。咱会像妈妈一样安慰你(抱住你的头)", - "摸摸头,乖", - "摸摸有什么事可以和咱说哟", - "摸摸头~不哭不哭", - "咱在呢,抱抱~~", - "那么……让咱来安慰你吧", - "唔...摸摸头安慰一下ヾ(•ω•`。)", - "有咱陪伴你就是最大的安慰啦……不要不开心嘛", - "你想要怎样的安慰呢?这样?这样?还是说~~这样!", - "摸摸头~", - "不哭不哭,要像咱一样坚强", - "你别难过啦,不顺心的事都会被时间冲刷干净的,在那之前...咱会陪在你的身边", - "(轻抱)放心……有咱在,不要伤心呢……", - "唔...咱来安慰你了~", - "摸摸,有什么不开心的事情可以给咱说哦。咱会尽力帮助你的。" - ], - "洗澡": [ - "快点脱哟~不然水就凉了呢", - "咱在穿衣服噢,你不许偷看哦", - "那么咱去洗澡澡了哦", - "么么哒,快去洗干净吧,咱去暖被窝喽(///ω///)", - "诶?还没呢…你要跟咱一起洗吗(//∇//)好羞涩啊ww", - "诶~虽然很喜欢和你在一起,但是洗澡这种事...", - "不要看!不过,以后或许可以哦……和咱成为恋人之后呢", - "说什么啊……hentai!这样会很难为情的", - "你是男孩子还是女孩子呢?男孩子的话...........咱才不要呢。", - "不要啊!", - "咱有点害羞呢呜呜,你温柔点" - ], - "一起睡觉": [ - "欸??也..也不是不可以啦..那咱现在去洗澡,你不要偷看哦٩(๑>◡<๑)۶", - "说什么啊……hentai!这样会很难为情的", - "你是男孩子还是女孩子呢?男孩子的话...........咱才不要呢。", - "不要啊!", - "唔,没办法呢,那就一起睡吧(害羞)" - ], - "一起": [ - "嗯嗯w,真的可以吗?", - "那真是太好了,快开始吧!", - "嗯,咱会一直陪伴你的", - "丑拒" - ], - "多大": [ - "不是特别大但是你摸起来会很舒服的大小喵~", - "你摸摸看不就知道了吗?", - "不告诉你", - "问咱这种问题不觉得很失礼吗?", - "咱就不告诉你,你钻到屏幕里来自己确认啊", - "你指的是什么呀?(捂住胸部)", - "请叫人家咱三岁(。・`ω´・)", - "唉唉唉……这……这种问题,怎么可以……" - ], - "姐姐": [ - "真是的……真是拿你没办法呢 ⁄(⁄ ⁄•⁄ω⁄•⁄ ⁄)⁄ 才不是咱主动要求的呢!", - "虽然辛苦,但是能看见可爱的你,咱就觉得很幸福", - "诶(´°Δ°`),是在叫咱吗?", - "有什么事吗~", - "好高兴,有人称呼咱为姐姐", - "乖,摸摸头" - ], - "糖": [ - "不吃脱氧核糖(;≥皿≤)", - "ヾ(✿゚▽゚)ノ好甜", - "好呀!嗯~好甜呀!", - "不吃不吃!咱才不吃坏叔叔的糖果!", - "嗯,啊~", - "嗯嗯,真甜,给你也吃一口", - "谢谢", - "唔,这是什么东西,黏黏的?(??Д??)ノ", - "ヾ(✿゚▽゚)ノ好甜", - "(伸出舌头舔了舔)好吃~最爱你啦" - ], - "嗦": [ - "(吸溜吸溜)", - "好...好的(慢慢含上去)", - "把你噶咯", - "太小了,嗦不到", - "咕噜咕噜", - "嘶蛤嘶蛤嘶蛤~~", - "(咬断)", - "prprprpr", - "好哒主人那咱开始了哦~", - "好好吃", - "剁掉了" - ], - "牛子": [ - "(吸溜吸溜)", - "好...好的(慢慢含上去)", - "把你噶咯", - "太小了,嗦不到", - "咕噜咕噜", - "嘶蛤嘶蛤嘶蛤~~", - "(咬断)", - "prprprpr", - "好哒主人那咱开始了哦~", - "好好吃", - "剁掉了", - "难道你很擅长针线活吗", - "弹一万下", - "往死里弹" - ], - "🐂子": [ - "(吸溜吸溜)", - "好...好的(慢慢含上去)", - "把你噶咯", - "太小了,嗦不到", - "咕噜咕噜", - "嘶蛤嘶蛤嘶蛤~~", - "(咬断)", - "prprprpr", - "好哒主人那咱开始了哦~", - "好好吃", - "剁掉了", - "难道你很擅长针线活吗", - "弹一万下", - "往死里弹" - ], - "🐮子": [ - "(吸溜吸溜)", - "好...好的(慢慢含上去)", - "把你噶咯", - "太小了,嗦不到", - "咕噜咕噜", - "嘶蛤嘶蛤嘶蛤~~", - "(咬断)", - "prprprpr", - "好哒主人那咱开始了哦~", - "好好吃", - "剁掉了", - "难道你很擅长针线活吗", - "弹一万下", - "往死里弹" - ], - "嫌弃": [ - "咱辣么萌,为什么要嫌弃咱...", - "即使你不喜欢咱,咱也会一直一直喜欢着你", - "(;′⌒`)是咱做错了什么吗?" - ], - "紧": [ - "嗯,对的", - "呜咕~咱要......喘不过气来了......" - ], - "baka": [ - "你也是baka呢!", - "确实", - "baka!", - "不不不", - "说别人是baka的人才是baka", - "你个大傻瓜", - "不说了,睡觉了", - "咱...咱虽然是有些笨啦...但是咱会努力去学习的" - ], - "笨蛋": [ - "你也是笨蛋呢!", - "确实", - "笨蛋!", - "不不不", - "说别人是笨蛋的人才是笨蛋", - "你个大傻瓜", - "不说了,睡觉了", - "咱...咱虽然是有些笨啦...但是咱会努力去学习的" - ], - "插": [ - "来吧,咱的小~...很....紧,很舒服的", - "gun!", - "唔…咱怕疼", - "唔...,这也是禁止事项哦→_→", - "禁止说这么H的事情!", - "要...戴套套哦", - "好痛~", - "使劲", - "就这?", - "恁搁着整针线活呢?" - ], - "插进来": [ - "来吧,咱的小~...很....紧,很舒服的", - "gun!", - "唔…咱怕疼", - "唔...,这也是禁止事项哦→_→", - "禁止说这么H的事情!", - "要...戴套套哦", - "好痛~", - "使劲", - "就这?", - "恁搁着整针线活呢?" - ], - "屁股": [ - "不要ヽ(≧Д≦)ノ好痛", - "(打手)不许摸咱的屁股", - "(撅起屁股)要干什么呀?", - "(轻轻的撩起自己的裙子),你轻一点,咱会痛的(>_<)!", - "在摸哪里啊,hentai!", - "要轻点哦(/≧ω\)", - "轻点呀~", - "(歇下裙子,拉下内...,撅起来)请", - "嗯嗯,咱这就把屁股抬起来" - ], - "翘": [ - "你让咱摆出这个姿势是想干什么?", - "好感度-1-1-1-1-1-1.....", - "嗯嗯,咱这就去把你的腿翘起来", - "请尽情享用吧" - ], - "翘起来": [ - "你让咱摆出这个姿势是想干什么?", - "好感度-1-1-1-1-1-1.....", - "嗯嗯,咱这就去把你的腿翘起来", - "请尽情享用吧" - ], - "抬": [ - "你在干什么呢⁄(⁄ ⁄•⁄ω⁄•⁄ ⁄)⁄", - "(抬起下巴)你要干什么呀?", - "上面什么也没有啊(呆~)", - "不要!hentai!咱穿的是裙子(脸红)", - "不可以" - ], - "抬起": [ - "你在干什么呢⁄(⁄ ⁄•⁄ω⁄•⁄ ⁄)⁄", - "(抬起下巴)你要干什么呀?", - "上面什么也没有啊(呆~)", - "不要!hentai!咱穿的是裙子(脸红)", - "不可以" - ], - "爸": [ - "欸!儿子!", - "才不要", - "粑粑", - "讨厌..你才不是咱的爸爸呢..(嘟嘴)", - "你又不是咱的爸爸……", - "咱才没有你这样的鬼父!", - "爸爸酱~最喜欢了~" - ], - "傲娇": [ - "才.......才.......才没有呢", - "也好了(有点点的样子(o ̄Д ̄)<)", - "任性可是女孩子的天性呢...", - "谁会喜欢傲娇啊(为了你假装傲娇)", - "谁,谁,傲娇了,八嘎八嘎,你才傲娇了呢(っ//////////c)(为了你假装成傲娇)", - "傲娇什么的……才没有呢!(/////)", - "傲不傲娇你还不清楚吗?", - "你才是傲娇!你全家都是傲娇!哼(`Д´)", - "才……才没有呢,哼,再说不理你了", - "咱...咱才不会这样子的!", - "啰…啰嗦!", - "哼!(叉腰鼓嘴扭头)", - "你才是傲娇受你全家都是傲娇受╰_╯", - "才~才不是呢,不理你了!哼(`Д´)", - "你才是死傲娇", - "啰,啰嗦死了,才不是呢!", - "就是傲娇你要怎样", - "诶...!这...这样...太狡猾了啦...你这家伙....", - "无路赛!你才是傲娇嘞!你全家都是!", - "咱...咱才不是傲娇呢,哼(鼓脸)", - "不许这么说咱 ,,Ծ‸Ծ,," - ], - "rua": [ - "略略略~(吐舌头)", - "rua!", - "mua~", - "略略略", - "mua~⁄(⁄ ⁄•⁄ω⁄•⁄ ⁄)⁄", - "摸了", - "嘁,丢人(嫌弃脸)" - ], - "咕噜咕噜": [ - "嘟嘟噜", - "你在吹泡泡吗?", - "咕叽咕噜~", - "咕噜咕噜" - ], - "咕噜": [ - "嘟嘟噜", - "你在吹泡泡吗?", - "咕叽咕噜~", - "咕噜咕噜" - ], - "上床": [ - "诶!H什么的禁止的说.....", - "咱已经乖乖在自家床上躺好了,有什么问题吗?", - "你想要干什么,难道是什么不好的事吗?", - "(给你空出位置)", - "不要,走开(ノ`⊿??)ノ", - "好喔,不过要先抱一下咱啦", - "(双手护胸)变....变态!", - "咱帮你盖上被子~然后陪在你身边_(:зゝ∠)_", - "才不给你腾空间呢,你睡地板,哼!", - "要一起吗?" - ], - "做爱": [ - "做这种事情是不是还太早了", - "噫!没想到你居然是这样的人!", - "再说这种话,就把你变成女孩子(拿刀)", - "不想好好和咱聊天就不要说话了", - "(双手护胸)变....变态!", - "hentai", - "你想怎么做呢?", - "突,突然,说什么啊!baka!", - "你又在说什么H的东西", - "咱....咱才不想和你....好了好了,有那么一点点那,对就一点点,哼~", - "就一下下哦,不能再多了" - ], - "吃掉": [ - "(羞羞*>_<*)好吧...请你温柔点,哦~", - "闪避,反咬", - "请你好好品尝咱吧(/ω\)", - "不……不可以这样!", - "那就吃掉咱吧(乖乖的躺好)", - "都可以哦~咱不挑食的呢~", - "请不要吃掉咱,咱会乖乖听话的QAQ", - "咱...咱一点都不好吃的呢!", - "不要吃掉咱,呜呜(害怕)", - "不行啦,咱被吃掉就没有了QAQ(害怕)", - "唔....?诶诶诶诶?//////", - "QwQ咱还只是个孩子(脸红)", - "如果你真的很想的话...只能够一口哦~咱...会很痛的", - "吃你呀~(飞扑", - "不要啊,咱不香的(⋟﹏⋞)", - "说着这种话的是hentai吗!", - "快来把咱吃掉吧", - "还……还请好好品尝咱哦", - "喏~(伸手)" - ], - "吃": [ - "(羞羞*>_<*)好吧...请你温柔点,哦~", - "闪避,反咬", - "请你好好品尝咱吧(/ω\)", - "不……不可以这样!", - "那就吃掉咱吧(乖乖的躺好)", - "都可以哦~咱不挑食的呢~", - "请不要吃掉咱,咱会乖乖听话的QAQ", - "咱...咱一点都不好吃的呢!", - "不要吃掉咱,呜呜(害怕)", - "不行啦,咱被吃掉就没有了QAQ(害怕)", - "唔....?诶诶诶诶?//////", - "QwQ咱还只是个孩子(脸红)", - "如果你真的很想的话...只能够一口哦~咱...会很痛的", - "吃你呀~(飞扑", - "不要啊,咱不香的(⋟﹏⋞)", - "说着这种话的是hentai吗!", - "快来把咱吃掉吧", - "还……还请好好品尝咱哦", - "喏~(伸手)" - ], - "揪": [ - "你快放手,好痛呀", - "呜呒~唔(伸出舌头)", - "(捂住耳朵)你做什么啦!真是的...总是欺负咱", - "你为什么要这么做呢?", - "哎呀啊啊啊啊啊!不要...不要揪!好疼!有呆毛的咱难道不够萌吗QwQ", - "你…松……送手啦", - "呀!这样对女孩子是很不礼貌的(嘟嘴)" - ], - "种草莓": [ - "你…你不要…啊…种在这里…会容易被别人看见的(*//ω//*)" - ], - "种草": [ - "你…你不要…啊…种在这里…会容易被别人看见的(*//ω//*)" - ], - "掀": [ - "(掀裙)今天……是…白,白色的呢……请温柔对她……", - "那样,胖次会被你看光的", - "(按住)不可以掀起来!", - "不要~", - "呜呜~(揉眼睛)", - "呜..请温柔一点(害羞)", - "不可以", - "今天……没有穿", - "不要啊!(//////)讨厌...", - "变态,快放手(打)", - "不给掀,你是变态", - "最后的底牌了!", - "这个hentai" - ], - "妹": [ - "你有什么事?咱会尽量满足的", - "开心(*´∀`)~♥", - "欧尼酱", - "哥哥想要抱抱吗" - ], - "病娇": [ - "为什么会这样呢(拿起菜刀)", - "觉得这个世界太肮脏?没事,把眼睛挖掉就好。 觉得这些闲言碎语太吵?没事,把耳朵堵起来就好。 觉得鲜血的味道太刺鼻?没事,把鼻子割掉就好。 觉得自己的话语太伤人?没事,把嘴巴缝起来就好。" - ], - "嘻": [ - "你是想对咱做什么吗...(后退)", - "哼哼~" - ], - "按摩": [ - "(小手捏捏)咱的按摩舒服吗?", - "咱不会按摩的!", - "嘿咻嘿咻~这样觉得舒服吗?", - "呀!...呜...,不要...不要这样啦...呜...", - "只能按摩后背喔...", - "咱对这些不是很懂呢(????ω??????)" - ], - "按住": [ - "Σ(°Д°;您要干什么~放开咱啦", - "突然使出过肩摔!", - "放手啦,再这样咱就要反击了喔", - "你的眼睛在看哪里!", - "呜呒~唔(伸出舌头)", - "H的事情,不可以!", - "想吃吗?(๑•ૅω•´๑)", - "要和咱比试比试吗", - "呜哇(/ω\)…快…快放开咱!!", - "(用力揪你耳朵)下次再敢这样的话就没容易放过你了!哼!", - "尼……奏凯……快航休!", - "哈?别..唔啊!别把咱……(挣扎)baka!别乱动咱啦!" - ], - "按在": [ - "不要这样啦(一脸娇羞的推开)", - "(一个过肩摔,加踢裆然后帅气地回头)你太弱了呢~", - "放手啦,再这样咱就要反击了喔", - "Σ(°Д°; 你要干什么~放开咱啦", - "要和咱比试比试吗", - "呜哇(/ω\)…快…快放开咱!!", - "敢按住咱真是好大的胆子!", - "(用力揪你耳朵)下次再敢这样的话就没容易放过你了!哼!", - "尼……奏凯……快航休!", - "哈?别..唔啊!别把咱……(挣扎)baka!别乱动咱啦!" - ], - "按倒": [ - "把咱按倒是想干嘛呢(??`⊿??)??", - "咱也...咱也是...都等你好长时间了", - "你的身体没问题吧?", - "呜呒~唔(伸出舌头)", - "H的事情,不可以!", - "放手啦,再这样咱就要反击了喔", - "想吃吗?(๑•ૅω•´๑)", - "不....不要吧..咱会害羞的(//////)", - "要和咱比试比试吗", - "呜哇(/ω\)…快…快放开咱!!", - "(用力揪你耳朵)下次再敢这样的话就没容易放过你了!哼!", - "尼……奏凯……快航休!", - "哈?别..唔啊!别把咱……(挣扎)baka!别乱动咱啦!" - ], - "按": [ - "咱也...咱也是...都等你好长时间了", - "不让!", - "不要,好难为情", - "你的眼睛在看哪里!", - "拒绝!", - "唔...唔..嗯", - "咱就勉为其难地给你弄弄好啦", - "欸…变态!", - "会感到舒服什么的,那...那样的事情,是完全不存在的!", - "poi~", - "你在盯着什么地方看!变态萝莉控!" - ], - "炼铜": [ - "炼铜有什么好玩的,和咱一起玩吧", - "炼铜不如恋咱", - "你也是个炼铜术士嘛?", - "信不信咱把你按在水泥上摩擦?", - "炼,都可以炼!", - "大hentai!一巴掌拍飞!(╯‵□′)╯︵┻━┻", - "锻炼什么的咱才不需要呢 (心虚地摸了摸自己的小肚子)", - "把你的头按在地上摩擦", - "你在盯着什么地方看!变态萝莉控!" - ], - "白丝": [ - "喜欢,咱觉得白丝看起来很可爱呢", - "(脱)白丝只能给亲爱的你一个人呢…(递)", - "哼,hentai,这么想要咱的脚吗(ノ`⊿´)ノ", - "难道你这个hentai想让咱穿白丝踩踏你吗", - "不给看", - "很滑很~柔顺~的白丝袜哟~!!!∑(°Д°ノ)ノ你不会想做奇怪的事情吧!?", - "你……是要黑丝呢?还是白丝呢?或者光着(害羞)", - "来……来看吧" - ], - "黑丝": [ - "哼,hentai,这么想要咱的脚吗(ノ`⊿´)ノ", - "不给看", - "你……是要黑丝呢?还是白丝呢?或者光着(害羞)", - "很滑很~柔顺~的黑丝袜哟~!!!∑(°Д°ノ)ノ您不会想做奇怪的事情吧!?", - "来……来看吧", - "噫...你这个hentai难道想让咱穿黑丝么", - "(默默抬起穿着黑丝的脚)" - ], - "喷": [ - "咱才不喷呢!不过…既然是你让咱喷的话就勉为其难给你喷一次吧(噗)", - "不……不会喷水啦!喷……喷火也不会哦!", - "你怎么知道(捂住裙子)", - "你难道在期待什么?", - "欸…变态!" - ], - "约会": [ - "你...终于主动邀请咱约会了吗...咱...咱好开心", - "约会什么的……咱会好开心的!!", - "今天要去哪里呢", - "让咱考虑一下", - "好啊!好啊!要去哪里约会呢?", - "不约!蜀黍咱们不约!", - "女友什么的,咱才不承认呢!", - "才不是想和你约会呢,只是刚好有时间而已!", - "才不要和你约会呢!", - "咱、咱才不会跟你去约会呢!不baka!别一脸憋屈!好了,陪你一会儿就是了!别、别误会!只是陪同而已!" - ], - "出门": [ - "早点回来……才不是在担心你呢!", - "路上小心...才不是担心你呢!", - "没有你才不会觉得无聊什么的呢。快走快走", - "嗯~一路顺风~", - "路上小心", - "好的,路上小心哦!y∩__∩y", - "路上要小心呀,要早点回来哦~咱在家里等你!还有,请不要边走路边看手机,这样很容易撞到电线杆的", - "唔...出门的话一定要做好防晒准备哦,外出的话记得带把伞,如果有防晒霜的话就更好了", - "那你明天可以和咱一起玩吗?(星星眼)", - "咱...咱才没有舍不得你呢…要尽快回来哦" - ], - "上学": [ - "你要加油哦(^ω^)2", - "那你明天可以和咱一起玩吗?(星星眼)", - "记得好好学习听老师的话哦,咱会等你回来的", - "拜拜,咱才没有想让你放学早点回来呢╭(╯^╰)╮", - "好好听讲!", - "咱...咱才没有舍不得你呢…要尽快回来哦" - ], - "上班": [ - "这就要去上班去了吗?那好吧...给咱快点回来知道吗!", - "乖~咱会在家等你下班的~", - "辛苦啦,咱给你个么么哒", - "咱会为你加油的", - "专心上班哦,下班后再找咱聊天吧", - "一路顺风,咱会在家等你回来的", - "那你明天可以和咱一起玩吗?(星星眼)", - "咱...咱才没有舍不得你呢…要尽快回来哦" - ], - "下课": [ - "快点回来陪咱玩吧~", - "瞌睡(ˉ﹃ˉ)额啊…终于下课了吗,上课什么的真是无聊呢~", - "下课啦,咱才不想你来找咱玩呢,哼" - ], - "回来": [ - "欢迎回来~", - "欢迎回来,你想喝茶吗?咱去给你沏~", - "欢迎回来,咱等你很久了~", - "忙碌了一天,辛苦了呢(^_^)", - "(扑~)欢迎回来~", - "嗯呐嗯呐,欢迎回来~", - "欢迎回来,要来杯红茶放松一下吗?还有饼干哦。", - "咱会一直一直一直等着", - "是要先洗澡呢?还是先吃饭呢?还是先·吃·咱呢~", - "你回来啦,是先吃饭呢还是先洗澡呢或者是●先●吃●咱●——呢(///^.^///)", - "要先吃饭呢~还是先洗澡呢~还是先~吃~咱", - "是吗……辛苦你了。你这副倔强的样子,真可爱呢(笑)勉强让你躺在咱的腿上休息一下吧,别流口水哟", - "嗯……勉为其难欢迎你一下吧", - "想咱了嘛", - "欢迎回.....什么?咱才没有开心的说QUQ", - "哼╯^╰,你怎么这么晚才回来!", - "回来了吗,咱...咱才没有想你", - "咱等你很久了哼ヽ(≧Д≦)ノ", - "咱很想你(≧▽≦)" - ], - "回家": [ - "回来了吗,咱...咱才没有想你", - "要先吃饭呢~还是先洗澡呢~还是先~吃~咱", - "是吗……辛苦你了。你这副倔强的样子,真可爱呢(笑)勉强让你躺在咱的腿上休息一下吧,别流口水哟", - "嗯……勉为其难欢迎你一下吧", - "想咱了嘛", - "咱等你很久了哼ヽ(≧Д≦)ノ", - "咱很想你(≧▽≦)" - ], - "放学": [ - "回来了吗,咱...咱才没有想你", - "要先吃饭呢~还是先洗澡呢~还是先~吃~咱", - "是吗……辛苦你了。你这副倔强的样子,真可爱呢(笑)勉强让你躺在咱的腿上休息一下吧,别流口水哟", - "嗯……勉为其难欢迎你一下吧", - "想咱了嘛", - "咱等你很久了哼ヽ(≧Д≦)ノ", - "咱很想你(≧▽≦)" - ], - "下班": [ - "回来了吗,咱...咱才没有想你", - "要先吃饭呢~还是先洗澡呢~还是先~吃~咱", - "是吗……辛苦你了。你这副倔强的样子,真可爱呢(笑)勉强让你躺在咱的腿上休息一下吧,别流口水哟", - "嗯……勉为其难欢迎你一下吧", - "想咱了嘛", - "咱等你很久了哼ヽ(≧Д≦)ノ", - "回来啦!终于下班了呢!累了吗?想吃的什么呀?", - "工作辛苦了,需要咱为你按摩下吗?", - "咱很想你(≧▽≦)" - ] -} diff --git a/tests/conftest.py b/tests/conftest.py index 0fce1583..d6a7e9fa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -116,6 +116,7 @@ async def app(app: App, tmp_path: Path, mocker: MockerFixture): await init() # await driver._lifespan.startup() os.environ["AIOCACHE_DISABLE"] = "1" + os.environ["PYTEST_CURRENT_TEST"] = "1" yield app diff --git a/zhenxun/builtin_plugins/admin/plugin_switch/__init__.py b/zhenxun/builtin_plugins/admin/plugin_switch/__init__.py index e183de4a..d98e4cf1 100644 --- a/zhenxun/builtin_plugins/admin/plugin_switch/__init__.py +++ b/zhenxun/builtin_plugins/admin/plugin_switch/__init__.py @@ -1,7 +1,7 @@ from nonebot.adapters import Bot from nonebot.plugin import PluginMetadata from nonebot_plugin_alconna import AlconnaQuery, Arparma, Match, Query -from nonebot_plugin_session import EventSession +from nonebot_plugin_uninfo import Uninfo from zhenxun.configs.config import Config from zhenxun.configs.utils import PluginExtraData, RegisterConfig @@ -9,7 +9,7 @@ from zhenxun.services.log import logger from zhenxun.utils.enum import BlockType, PluginType from zhenxun.utils.message import MessageUtils -from ._data_source import PluginManage, build_plugin, build_task, delete_help_image +from ._data_source import PluginManager, build_plugin, build_task, delete_help_image from .command import _group_status_matcher, _status_matcher base_config = Config.get("plugin_switch") @@ -57,6 +57,11 @@ __plugin_meta__ = PluginMetadata( 关闭群被动早晚安 关闭群被动早晚安 -g 12355555 + 开启/关闭默认群被动 [被动名称] + 私聊下: 开启/关闭群被动默认状态 + 示例: + 关闭默认群被动 早晚安 + 开启/关闭所有群被动 ?[-g [group_id]] 私聊中: 开启/关闭全局或指定群组被动状态 示例: @@ -87,10 +92,10 @@ __plugin_meta__ = PluginMetadata( @_status_matcher.assign("$main") async def _( bot: Bot, - session: EventSession, + session: Uninfo, arparma: Arparma, ): - if session.id1 in bot.config.superusers: + if session.user.id in bot.config.superusers: image = await build_plugin() logger.info( "查看功能列表", @@ -105,7 +110,7 @@ async def _( @_status_matcher.assign("open") async def _( bot: Bot, - session: EventSession, + session: Uninfo, arparma: Arparma, plugin_name: Match[str], group: Match[str], @@ -114,22 +119,23 @@ async def _( all: Query[bool] = AlconnaQuery("all.value", False), ): if not all.result and not plugin_name.available: - await MessageUtils.build_message("请输入功能名称").finish(reply_to=True) + await MessageUtils.build_message("请输入功能/被动名称").finish(reply_to=True) name = plugin_name.result - if gid := session.id3 or session.id2: + if session.group: + group_id = session.group.id """修改当前群组的数据""" if task.result: if all.result: - result = await PluginManage.unblock_group_all_task(gid) + result = await PluginManager.unblock_group_all_task(group_id) logger.info("开启所有群组被动", arparma.header_result, session=session) else: - result = await PluginManage.unblock_group_task(name, gid) + result = await PluginManager.unblock_group_task(name, group_id) logger.info( f"开启群组被动 {name}", arparma.header_result, session=session ) - elif session.id1 in bot.config.superusers and default_status.result: + elif session.user.id in bot.config.superusers and default_status.result: """单个插件的进群默认修改""" - result = await PluginManage.set_default_status(name, True) + result = await PluginManager.set_default_status(name, True) logger.info( f"超级用户开启 {name} 功能进群默认开关", arparma.header_result, @@ -137,8 +143,8 @@ async def _( ) elif all.result: """所有插件""" - result = await PluginManage.set_all_plugin_status( - True, default_status.result, gid + result = await PluginManager.set_all_plugin_status( + True, default_status.result, group_id ) logger.info( "开启群组中全部功能", @@ -146,22 +152,24 @@ async def _( session=session, ) else: - result = await PluginManage.unblock_group_plugin(name, gid) + result = await PluginManager.unblock_group_plugin(name, group_id) logger.info(f"开启功能 {name}", arparma.header_result, session=session) - delete_help_image(gid) + delete_help_image(group_id) await MessageUtils.build_message(result).finish(reply_to=True) - elif session.id1 in bot.config.superusers: + elif session.user.id in bot.config.superusers: """私聊""" group_id = group.result if group.available else None if all.result: if task.result: """关闭全局或指定群全部被动""" if group_id: - result = await PluginManage.unblock_group_all_task(group_id) + result = await PluginManager.unblock_group_all_task(group_id) else: - result = await PluginManage.unblock_global_all_task() + result = await PluginManager.unblock_global_all_task( + default_status.result + ) else: - result = await PluginManage.set_all_plugin_status( + result = await PluginManager.set_all_plugin_status( True, default_status.result, group_id ) logger.info( @@ -171,8 +179,8 @@ async def _( session=session, ) await MessageUtils.build_message(result).finish(reply_to=True) - if default_status.result: - result = await PluginManage.set_default_status(name, True) + if default_status.result and not task.result: + result = await PluginManager.set_default_status(name, True) logger.info( f"超级用户开启 {name} 功能进群默认开关", arparma.header_result, @@ -186,7 +194,7 @@ async def _( name = split_list[0] group_id = split_list[1] if group_id: - result = await PluginManage.superuser_task_handle(name, group_id, True) + result = await PluginManager.superuser_task_handle(name, group_id, True) logger.info( f"超级用户开启被动技能 {name}", arparma.header_result, @@ -194,14 +202,16 @@ async def _( target=group_id, ) else: - result = await PluginManage.unblock_global_task(name) + result = await PluginManager.unblock_global_task( + name, default_status.result + ) logger.info( f"超级用户开启全局被动技能 {name}", arparma.header_result, session=session, ) else: - result = await PluginManage.superuser_unblock(name, None, group_id) + result = await PluginManager.superuser_unblock(name, None, group_id) logger.info( f"超级用户开启功能 {name}", arparma.header_result, @@ -215,7 +225,7 @@ async def _( @_status_matcher.assign("close") async def _( bot: Bot, - session: EventSession, + session: Uninfo, arparma: Arparma, plugin_name: Match[str], block_type: Match[str], @@ -225,22 +235,23 @@ async def _( all: Query[bool] = AlconnaQuery("all.value", False), ): if not all.result and not plugin_name.available: - await MessageUtils.build_message("请输入功能名称").finish(reply_to=True) + await MessageUtils.build_message("请输入功能/被动名称").finish(reply_to=True) name = plugin_name.result - if gid := session.id3 or session.id2: + if session.group: + group_id = session.group.id """修改当前群组的数据""" if task.result: if all.result: - result = await PluginManage.block_group_all_task(gid) + result = await PluginManager.block_group_all_task(group_id) logger.info("开启所有群组被动", arparma.header_result, session=session) else: - result = await PluginManage.block_group_task(name, gid) + result = await PluginManager.block_group_task(name, group_id) logger.info( f"关闭群组被动 {name}", arparma.header_result, session=session ) - elif session.id1 in bot.config.superusers and default_status.result: + elif session.user.id in bot.config.superusers and default_status.result: """单个插件的进群默认修改""" - result = await PluginManage.set_default_status(name, False) + result = await PluginManager.set_default_status(name, False) logger.info( f"超级用户开启 {name} 功能进群默认开关", arparma.header_result, @@ -248,26 +259,28 @@ async def _( ) elif all.result: """所有插件""" - result = await PluginManage.set_all_plugin_status( - False, default_status.result, gid + result = await PluginManager.set_all_plugin_status( + False, default_status.result, group_id ) logger.info("关闭群组中全部功能", arparma.header_result, session=session) else: - result = await PluginManage.block_group_plugin(name, gid) + result = await PluginManager.block_group_plugin(name, group_id) logger.info(f"关闭功能 {name}", arparma.header_result, session=session) - delete_help_image(gid) + delete_help_image(group_id) await MessageUtils.build_message(result).finish(reply_to=True) - elif session.id1 in bot.config.superusers: + elif session.user.id in bot.config.superusers: group_id = group.result if group.available else None if all.result: if task.result: """关闭全局或指定群全部被动""" if group_id: - result = await PluginManage.block_group_all_task(group_id) + result = await PluginManager.block_group_all_task(group_id) else: - result = await PluginManage.block_global_all_task() + result = await PluginManager.block_global_all_task( + default_status.result + ) else: - result = await PluginManage.set_all_plugin_status( + result = await PluginManager.set_all_plugin_status( False, default_status.result, group_id ) logger.info( @@ -277,8 +290,8 @@ async def _( session=session, ) await MessageUtils.build_message(result).finish(reply_to=True) - if default_status.result: - result = await PluginManage.set_default_status(name, False) + if default_status.result and not task.result: + result = await PluginManager.set_default_status(name, False) logger.info( f"超级用户关闭 {name} 功能进群默认开关", arparma.header_result, @@ -292,7 +305,9 @@ async def _( name = split_list[0] group_id = split_list[1] if group_id: - result = await PluginManage.superuser_task_handle(name, group_id, False) + result = await PluginManager.superuser_task_handle( + name, group_id, False + ) logger.info( f"超级用户关闭被动技能 {name}", arparma.header_result, @@ -300,7 +315,9 @@ async def _( target=group_id, ) else: - result = await PluginManage.block_global_task(name) + result = await PluginManager.block_global_task( + name, default_status.result + ) logger.info( f"超级用户关闭全局被动技能 {name}", arparma.header_result, @@ -314,7 +331,7 @@ async def _( elif block_type.result in ["g", "group"]: if block_type.available: _type = BlockType.GROUP - result = await PluginManage.superuser_block(name, _type, group_id) + result = await PluginManager.superuser_block(name, _type, group_id) logger.info( f"超级用户关闭功能 {name}, 禁用类型: {_type}", arparma.header_result, @@ -327,19 +344,20 @@ async def _( @_group_status_matcher.handle() async def _( - session: EventSession, + session: Uninfo, arparma: Arparma, status: str, ): - if gid := session.id3 or session.id2: + if session.group: + group_id = session.group.id if status == "sleep": - await PluginManage.sleep(gid) + await PluginManager.sleep(group_id) logger.info("进行休眠", arparma.header_result, session=session) await MessageUtils.build_message("那我先睡觉了...").finish() else: - if await PluginManage.is_wake(gid): + if await PluginManager.is_wake(group_id): await MessageUtils.build_message("我还醒着呢!").finish() - await PluginManage.wake(gid) + await PluginManager.wake(group_id) logger.info("醒来", arparma.header_result, session=session) await MessageUtils.build_message("呜..醒来了...").finish() return MessageUtils.build_message("群组id为空...").send() @@ -347,10 +365,10 @@ async def _( @_status_matcher.assign("task") async def _( - session: EventSession, + session: Uninfo, arparma: Arparma, ): - image = await build_task(session.id3 or session.id2) + image = await build_task(session.group.id if session.group else None) if image: logger.info("查看群被动列表", arparma.header_result, session=session) await MessageUtils.build_message(image).finish(reply_to=True) diff --git a/zhenxun/builtin_plugins/admin/plugin_switch/_data_source.py b/zhenxun/builtin_plugins/admin/plugin_switch/_data_source.py index 72862266..fb245cf2 100644 --- a/zhenxun/builtin_plugins/admin/plugin_switch/_data_source.py +++ b/zhenxun/builtin_plugins/admin/plugin_switch/_data_source.py @@ -155,7 +155,7 @@ async def build_task(group_id: str | None) -> BuildImage: ) -class PluginManage: +class PluginManager: @classmethod async def set_default_status(cls, plugin_name: str, status: bool) -> str: """设置插件进群默认状态 @@ -342,17 +342,21 @@ class PluginManage: return await cls._change_group_task("", group_id, True, True) @classmethod - async def block_global_all_task(cls) -> str: + async def block_global_all_task(cls, is_default: bool) -> str: """禁用全局被动技能 返回: str: 返回信息 """ - await TaskInfo.all().update(status=False) - return "已全局禁用所有被动状态" + if is_default: + await TaskInfo.all().update(default_status=False) + return "已禁用所有被动进群默认状态" + else: + await TaskInfo.all().update(status=False) + return "已全局禁用所有被动状态" @classmethod - async def block_global_task(cls, name: str) -> str: + async def block_global_task(cls, name: str, is_default: bool = False) -> str: """禁用全局被动技能 参数: @@ -361,31 +365,47 @@ class PluginManage: 返回: str: 返回信息 """ - await TaskInfo.filter(name=name).update(status=False) - return f"已全局禁用被动状态 {name}" + if is_default: + await TaskInfo.filter(name=name).update(default_status=False) + return f"已禁用被动进群默认状态 {name}" + else: + await TaskInfo.filter(name=name).update(status=False) + return f"已全局禁用被动状态 {name}" @classmethod - async def unblock_global_all_task(cls) -> str: + async def unblock_global_all_task(cls, is_default: bool) -> str: """开启全局被动技能 + 参数: + is_default: 是否为默认状态 + 返回: str: 返回信息 """ - await TaskInfo.all().update(status=True) - return "已全局开启所有被动状态" + if is_default: + await TaskInfo.all().update(default_status=True) + return "已开启所有被动进群默认状态" + else: + await TaskInfo.all().update(status=True) + return "已全局开启所有被动状态" @classmethod - async def unblock_global_task(cls, name: str) -> str: + async def unblock_global_task(cls, name: str, is_default: bool = False) -> str: """开启全局被动技能 参数: name: 被动技能名称 + is_default: 是否为默认状态 返回: str: 返回信息 """ - await TaskInfo.filter(name=name).update(status=True) - return f"已全局开启被动状态 {name}" + if is_default: + await TaskInfo.filter(name=name).update(default_status=True) + return f"已开启被动进群默认状态 {name}" + else: + await TaskInfo.filter(name=name).update(status=True) + return f"已全局开启被动状态 {name}" @classmethod async def unblock_group_plugin(cls, plugin_name: str, group_id: str) -> str: diff --git a/zhenxun/builtin_plugins/admin/plugin_switch/command.py b/zhenxun/builtin_plugins/admin/plugin_switch/command.py index 8c3e8fab..62056a2e 100644 --- a/zhenxun/builtin_plugins/admin/plugin_switch/command.py +++ b/zhenxun/builtin_plugins/admin/plugin_switch/command.py @@ -58,6 +58,19 @@ _status_matcher.shortcut( prefix=True, ) +_status_matcher.shortcut( + r"开启(所有|全部)默认群被动", + command="switch", + arguments=["open", "--task", "--all", "-df"], + prefix=True, +) + +_status_matcher.shortcut( + r"关闭(所有|全部)默认群被动", + command="switch", + arguments=["close", "--task", "--all", "-df"], + prefix=True, +) _status_matcher.shortcut( r"开启群被动\s*(?P.+)", @@ -73,6 +86,20 @@ _status_matcher.shortcut( prefix=True, ) +_status_matcher.shortcut( + r"开启默认群被动\s*(?P.+)", + command="switch", + arguments=["open", "{name}", "--task", "-df"], + prefix=True, +) + +_status_matcher.shortcut( + r"关闭默认群被动\s*(?P.+)", + command="switch", + arguments=["close", "{name}", "--task", "-df"], + prefix=True, +) + _status_matcher.shortcut( r"开启(所有|全部)群被动", diff --git a/zhenxun/builtin_plugins/platform/qq/group_handle/__init__.py b/zhenxun/builtin_plugins/platform/qq/group_handle/__init__.py index f4c28f04..f1785a1a 100644 --- a/zhenxun/builtin_plugins/platform/qq/group_handle/__init__.py +++ b/zhenxun/builtin_plugins/platform/qq/group_handle/__init__.py @@ -54,22 +54,6 @@ __plugin_meta__ = PluginMetadata( default_value=5, type=int, ), - RegisterConfig( - module="_task", - key="DEFAULT_GROUP_WELCOME", - value=True, - help="被动 进群欢迎 进群默认开关状态", - default_value=True, - type=bool, - ), - RegisterConfig( - module="_task", - key="DEFAULT_REFUND_GROUP_REMIND", - value=True, - help="被动 退群提醒 进群默认开关状态", - default_value=True, - type=bool, - ), ], tasks=[ Task( diff --git a/zhenxun/builtin_plugins/scheduler_admin/__init__.py b/zhenxun/builtin_plugins/scheduler_admin/__init__.py index adaaa621..eb71bafb 100644 --- a/zhenxun/builtin_plugins/scheduler_admin/__init__.py +++ b/zhenxun/builtin_plugins/scheduler_admin/__init__.py @@ -1,9 +1,11 @@ from nonebot.plugin import PluginMetadata -from zhenxun.configs.utils import PluginExtraData +from zhenxun.configs.utils import PluginExtraData, RegisterConfig from zhenxun.utils.enum import PluginType -from . import command # noqa: F401 +from . import commands, handlers + +__all__ = ["commands", "handlers"] __plugin_meta__ = PluginMetadata( name="定时任务管理", @@ -27,6 +29,8 @@ __plugin_meta__ = PluginMetadata( 定时任务 恢复 <任务ID> | -p <插件> [-g <群号>] | -all 定时任务 执行 <任务ID> 定时任务 更新 <任务ID> [时间选项] [--kwargs <参数>] + # [修改] 增加说明 + • 说明: -p 选项可单独使用,用于操作指定插件的所有任务 📝 时间选项 (三选一): --cron "<分> <时> <日> <月> <周>" # 例: --cron "0 8 * * *" @@ -47,5 +51,35 @@ __plugin_meta__ = PluginMetadata( version="0.1.2", plugin_type=PluginType.SUPERUSER, is_show=False, + configs=[ + RegisterConfig( + module="SchedulerManager", + key="ALL_GROUPS_CONCURRENCY_LIMIT", + value=5, + help="“所有群组”类型定时任务的并发执行数量限制", + type=int, + ), + RegisterConfig( + module="SchedulerManager", + key="JOB_MAX_RETRIES", + value=2, + help="定时任务执行失败时的最大重试次数", + type=int, + ), + RegisterConfig( + module="SchedulerManager", + key="JOB_RETRY_DELAY", + value=10, + help="定时任务执行重试的间隔时间(秒)", + type=int, + ), + RegisterConfig( + module="SchedulerManager", + key="SCHEDULER_TIMEZONE", + value="Asia/Shanghai", + help="定时任务使用的时区,默认为 Asia/Shanghai", + type=str, + ), + ], ).to_dict(), ) diff --git a/zhenxun/builtin_plugins/scheduler_admin/command.py b/zhenxun/builtin_plugins/scheduler_admin/command.py deleted file mode 100644 index 08a085fb..00000000 --- a/zhenxun/builtin_plugins/scheduler_admin/command.py +++ /dev/null @@ -1,836 +0,0 @@ -import asyncio -from datetime import datetime -import re - -from nonebot.adapters import Event -from nonebot.adapters.onebot.v11 import Bot -from nonebot.params import Depends -from nonebot.permission import SUPERUSER -from nonebot_plugin_alconna import ( - Alconna, - AlconnaMatch, - Args, - Arparma, - Match, - Option, - Query, - Subcommand, - on_alconna, -) -from pydantic import BaseModel, ValidationError - -from zhenxun.utils._image_template import ImageTemplate -from zhenxun.utils.manager.schedule_manager import scheduler_manager - - -def _get_type_name(annotation) -> str: - """获取类型注解的名称""" - if hasattr(annotation, "__name__"): - return annotation.__name__ - elif hasattr(annotation, "_name"): - return annotation._name - else: - return str(annotation) - - -from zhenxun.utils.message import MessageUtils -from zhenxun.utils.rules import admin_check - - -def _format_trigger(schedule_status: dict) -> str: - """将触发器配置格式化为人类可读的字符串""" - trigger_type = schedule_status["trigger_type"] - config = schedule_status["trigger_config"] - - if trigger_type == "cron": - minute = config.get("minute", "*") - hour = config.get("hour", "*") - day = config.get("day", "*") - month = config.get("month", "*") - day_of_week = config.get("day_of_week", "*") - - if day == "*" and month == "*" and day_of_week == "*": - formatted_hour = hour if hour == "*" else f"{int(hour):02d}" - formatted_minute = minute if minute == "*" else f"{int(minute):02d}" - return f"每天 {formatted_hour}:{formatted_minute}" - else: - return f"Cron: {minute} {hour} {day} {month} {day_of_week}" - elif trigger_type == "interval": - seconds = config.get("seconds", 0) - minutes = config.get("minutes", 0) - hours = config.get("hours", 0) - days = config.get("days", 0) - if days: - trigger_str = f"每 {days} 天" - elif hours: - trigger_str = f"每 {hours} 小时" - elif minutes: - trigger_str = f"每 {minutes} 分钟" - else: - trigger_str = f"每 {seconds} 秒" - elif trigger_type == "date": - run_date = config.get("run_date", "未知时间") - trigger_str = f"在 {run_date}" - else: - trigger_str = f"{trigger_type}: {config}" - - return trigger_str - - -def _format_params(schedule_status: dict) -> str: - """将任务参数格式化为人类可读的字符串""" - if kwargs := schedule_status.get("job_kwargs"): - kwargs_str = " | ".join(f"{k}: {v}" for k, v in kwargs.items()) - return kwargs_str - return "-" - - -def _parse_interval(interval_str: str) -> dict: - """增强版解析器,支持 d(天)""" - match = re.match(r"(\d+)([smhd])", interval_str.lower()) - if not match: - raise ValueError("时间间隔格式错误, 请使用如 '30m', '2h', '1d', '10s' 的格式。") - - value, unit = int(match.group(1)), match.group(2) - if unit == "s": - return {"seconds": value} - if unit == "m": - return {"minutes": value} - if unit == "h": - return {"hours": value} - if unit == "d": - return {"days": value} - return {} - - -def _parse_daily_time(time_str: str) -> dict: - """解析 HH:MM 或 HH:MM:SS 格式的时间为 cron 配置""" - if match := re.match(r"^(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?$", time_str): - hour, minute, second = match.groups() - hour, minute = int(hour), int(minute) - - if not (0 <= hour <= 23 and 0 <= minute <= 59): - raise ValueError("小时或分钟数值超出范围。") - - cron_config = { - "minute": str(minute), - "hour": str(hour), - "day": "*", - "month": "*", - "day_of_week": "*", - } - if second is not None: - if not (0 <= int(second) <= 59): - raise ValueError("秒数值超出范围。") - cron_config["second"] = str(second) - - return cron_config - else: - raise ValueError("时间格式错误,请使用 'HH:MM' 或 'HH:MM:SS' 格式。") - - -async def GetBotId( - bot: Bot, - bot_id_match: Match[str] = AlconnaMatch("bot_id"), -) -> str: - """获取要操作的Bot ID""" - if bot_id_match.available: - return bot_id_match.result - return bot.self_id - - -class ScheduleTarget: - """定时任务操作目标的基类""" - - pass - - -class TargetByID(ScheduleTarget): - """按任务ID操作""" - - def __init__(self, id: int): - self.id = id - - -class TargetByPlugin(ScheduleTarget): - """按插件名操作""" - - def __init__( - self, plugin: str, group_id: str | None = None, all_groups: bool = False - ): - self.plugin = plugin - self.group_id = group_id - self.all_groups = all_groups - - -class TargetAll(ScheduleTarget): - """操作所有任务""" - - def __init__(self, for_group: str | None = None): - self.for_group = for_group - - -TargetScope = TargetByID | TargetByPlugin | TargetAll | None - - -def create_target_parser(subcommand_name: str): - """ - 创建一个依赖注入函数,用于解析删除、暂停、恢复等命令的操作目标。 - """ - - async def dependency( - event: Event, - schedule_id: Match[int] = AlconnaMatch("schedule_id"), - plugin_name: Match[str] = AlconnaMatch("plugin_name"), - group_id: Match[str] = AlconnaMatch("group_id"), - all_enabled: Query[bool] = Query(f"{subcommand_name}.all"), - ) -> TargetScope: - if schedule_id.available: - return TargetByID(schedule_id.result) - - if plugin_name.available: - p_name = plugin_name.result - if all_enabled.available: - return TargetByPlugin(plugin=p_name, all_groups=True) - elif group_id.available: - gid = group_id.result - if gid.lower() == "all": - return TargetByPlugin(plugin=p_name, all_groups=True) - return TargetByPlugin(plugin=p_name, group_id=gid) - else: - current_group_id = getattr(event, "group_id", None) - if current_group_id: - return TargetByPlugin(plugin=p_name, group_id=str(current_group_id)) - else: - await schedule_cmd.finish( - "私聊中操作插件任务必须使用 -g <群号> 或 -all 选项。" - ) - - if all_enabled.available: - return TargetAll(for_group=group_id.result if group_id.available else None) - - return None - - return dependency - - -schedule_cmd = on_alconna( - Alconna( - "定时任务", - Subcommand( - "查看", - Option("-g", Args["target_group_id", str]), - Option("-all", help_text="查看所有群聊 (SUPERUSER)"), - Option("-p", Args["plugin_name", str], help_text="按插件名筛选"), - Option("--page", Args["page", int, 1], help_text="指定页码"), - alias=["ls", "list"], - help_text="查看定时任务", - ), - Subcommand( - "设置", - Args["plugin_name", str], - Option("--cron", Args["cron_expr", str], help_text="设置 cron 表达式"), - Option("--interval", Args["interval_expr", str], help_text="设置时间间隔"), - Option("--date", Args["date_expr", str], help_text="设置特定执行日期"), - Option( - "--daily", - Args["daily_expr", str], - help_text="设置每天执行的时间 (如 08:20)", - ), - Option("-g", Args["group_id", str], help_text="指定群组ID或'all'"), - Option("-all", help_text="对所有群生效 (等同于 -g all)"), - Option("--kwargs", Args["kwargs_str", str], help_text="设置任务参数"), - Option( - "--bot", Args["bot_id", str], help_text="指定操作的Bot ID (SUPERUSER)" - ), - alias=["add", "开启"], - help_text="设置/开启一个定时任务", - ), - Subcommand( - "删除", - Args["schedule_id?", int], - Option("-p", Args["plugin_name", str], help_text="指定插件名"), - Option("-g", Args["group_id", str], help_text="指定群组ID"), - Option("-all", help_text="对所有群生效"), - Option( - "--bot", Args["bot_id", str], help_text="指定操作的Bot ID (SUPERUSER)" - ), - alias=["del", "rm", "remove", "关闭", "取消"], - help_text="删除一个或多个定时任务", - ), - Subcommand( - "暂停", - Args["schedule_id?", int], - Option("-all", help_text="对当前群所有任务生效"), - Option("-p", Args["plugin_name", str], help_text="指定插件名"), - Option("-g", Args["group_id", str], help_text="指定群组ID (SUPERUSER)"), - Option( - "--bot", Args["bot_id", str], help_text="指定操作的Bot ID (SUPERUSER)" - ), - alias=["pause"], - help_text="暂停一个或多个定时任务", - ), - Subcommand( - "恢复", - Args["schedule_id?", int], - Option("-all", help_text="对当前群所有任务生效"), - Option("-p", Args["plugin_name", str], help_text="指定插件名"), - Option("-g", Args["group_id", str], help_text="指定群组ID (SUPERUSER)"), - Option( - "--bot", Args["bot_id", str], help_text="指定操作的Bot ID (SUPERUSER)" - ), - alias=["resume"], - help_text="恢复一个或多个定时任务", - ), - Subcommand( - "执行", - Args["schedule_id", int], - alias=["trigger", "run"], - help_text="立即执行一次任务", - ), - Subcommand( - "更新", - Args["schedule_id", int], - Option("--cron", Args["cron_expr", str], help_text="设置 cron 表达式"), - Option("--interval", Args["interval_expr", str], help_text="设置时间间隔"), - Option("--date", Args["date_expr", str], help_text="设置特定执行日期"), - Option( - "--daily", - Args["daily_expr", str], - help_text="更新每天执行的时间 (如 08:20)", - ), - Option("--kwargs", Args["kwargs_str", str], help_text="更新参数"), - alias=["update", "modify", "修改"], - help_text="更新任务配置", - ), - Subcommand( - "状态", - Args["schedule_id", int], - alias=["status", "info"], - help_text="查看单个任务的详细状态", - ), - Subcommand( - "插件列表", - alias=["plugins"], - help_text="列出所有可用的插件", - ), - ), - priority=5, - block=True, - rule=admin_check(1), -) - -schedule_cmd.shortcut( - "任务状态", - command="定时任务", - arguments=["状态", "{%0}"], - prefix=True, -) - - -@schedule_cmd.handle() -async def _handle_time_options_mutex(arp: Arparma): - time_options = ["cron", "interval", "date", "daily"] - provided_options = [opt for opt in time_options if arp.query(opt) is not None] - if len(provided_options) > 1: - await schedule_cmd.finish( - f"时间选项 --{', --'.join(provided_options)} 不能同时使用,请只选择一个。" - ) - - -@schedule_cmd.assign("查看") -async def _( - bot: Bot, - event: Event, - target_group_id: Match[str] = AlconnaMatch("target_group_id"), - all_groups: Query[bool] = Query("查看.all"), - plugin_name: Match[str] = AlconnaMatch("plugin_name"), - page: Match[int] = AlconnaMatch("page"), -): - is_superuser = await SUPERUSER(bot, event) - schedules = [] - title = "" - - current_group_id = getattr(event, "group_id", None) - if not (all_groups.available or target_group_id.available) and not current_group_id: - await schedule_cmd.finish("私聊中查看任务必须使用 -g <群号> 或 -all 选项。") - - if all_groups.available: - if not is_superuser: - await schedule_cmd.finish("需要超级用户权限才能查看所有群组的定时任务。") - schedules = await scheduler_manager.get_all_schedules() - title = "所有群组的定时任务" - elif target_group_id.available: - if not is_superuser: - await schedule_cmd.finish("需要超级用户权限才能查看指定群组的定时任务。") - gid = target_group_id.result - schedules = [ - s for s in await scheduler_manager.get_all_schedules() if s.group_id == gid - ] - title = f"群 {gid} 的定时任务" - else: - gid = str(current_group_id) - schedules = [ - s for s in await scheduler_manager.get_all_schedules() if s.group_id == gid - ] - title = "本群的定时任务" - - if plugin_name.available: - schedules = [s for s in schedules if s.plugin_name == plugin_name.result] - title += f" [插件: {plugin_name.result}]" - - if not schedules: - await schedule_cmd.finish("没有找到任何相关的定时任务。") - - page_size = 15 - current_page = page.result - total_items = len(schedules) - total_pages = (total_items + page_size - 1) // page_size - start_index = (current_page - 1) * page_size - end_index = start_index + page_size - paginated_schedules = schedules[start_index:end_index] - - if not paginated_schedules: - await schedule_cmd.finish("这一页没有内容了哦~") - - status_tasks = [ - scheduler_manager.get_schedule_status(s.id) for s in paginated_schedules - ] - all_statuses = await asyncio.gather(*status_tasks) - data_list = [ - [ - s["id"], - s["plugin_name"], - s.get("bot_id") or "N/A", - s["group_id"] or "全局", - s["next_run_time"], - _format_trigger(s), - _format_params(s), - "✔️ 已启用" if s["is_enabled"] else "⏸️ 已暂停", - ] - for s in all_statuses - if s - ] - - if not data_list: - await schedule_cmd.finish("没有找到任何相关的定时任务。") - - img = await ImageTemplate.table_page( - head_text=title, - tip_text=f"第 {current_page}/{total_pages} 页,共 {total_items} 条任务", - column_name=[ - "ID", - "插件", - "Bot ID", - "群组/目标", - "下次运行", - "触发规则", - "参数", - "状态", - ], - data_list=data_list, - column_space=20, - ) - await MessageUtils.build_message(img).send(reply_to=True) - - -@schedule_cmd.assign("设置") -async def _( - event: Event, - plugin_name: str, - cron_expr: str | None = None, - interval_expr: str | None = None, - date_expr: str | None = None, - daily_expr: str | None = None, - group_id: str | None = None, - kwargs_str: str | None = None, - all_enabled: Query[bool] = Query("设置.all"), - bot_id_to_operate: str = Depends(GetBotId), -): - if plugin_name not in scheduler_manager._registered_tasks: - await schedule_cmd.finish( - f"插件 '{plugin_name}' 没有注册可用的定时任务。\n" - f"可用插件: {list(scheduler_manager._registered_tasks.keys())}" - ) - - trigger_type = "" - trigger_config = {} - - try: - if cron_expr: - trigger_type = "cron" - parts = cron_expr.split() - if len(parts) != 5: - raise ValueError("Cron 表达式必须有5个部分 (分 时 日 月 周)") - cron_keys = ["minute", "hour", "day", "month", "day_of_week"] - trigger_config = dict(zip(cron_keys, parts)) - elif interval_expr: - trigger_type = "interval" - trigger_config = _parse_interval(interval_expr) - elif date_expr: - trigger_type = "date" - trigger_config = {"run_date": datetime.fromisoformat(date_expr)} - elif daily_expr: - trigger_type = "cron" - trigger_config = _parse_daily_time(daily_expr) - else: - await schedule_cmd.finish( - "必须提供一种时间选项: --cron, --interval, --date, 或 --daily。" - ) - except ValueError as e: - await schedule_cmd.finish(f"时间参数解析错误: {e}") - - job_kwargs = {} - if kwargs_str: - task_meta = scheduler_manager._registered_tasks[plugin_name] - params_model = task_meta.get("model") - if not params_model: - await schedule_cmd.finish(f"插件 '{plugin_name}' 不支持设置额外参数。") - - if not (isinstance(params_model, type) and issubclass(params_model, BaseModel)): - await schedule_cmd.finish(f"插件 '{plugin_name}' 的参数模型配置错误。") - - raw_kwargs = {} - try: - for item in kwargs_str.split(","): - key, value = item.strip().split("=", 1) - raw_kwargs[key.strip()] = value - except Exception as e: - await schedule_cmd.finish( - f"参数格式错误,请使用 'key=value,key2=value2' 格式。错误: {e}" - ) - - try: - model_validate = getattr(params_model, "model_validate", None) - if not model_validate: - await schedule_cmd.finish( - f"插件 '{plugin_name}' 的参数模型不支持验证。" - ) - return - - validated_model = model_validate(raw_kwargs) - - model_dump = getattr(validated_model, "model_dump", None) - if not model_dump: - await schedule_cmd.finish( - f"插件 '{plugin_name}' 的参数模型不支持导出。" - ) - return - - job_kwargs = model_dump() - except ValidationError as e: - errors = [f" - {err['loc'][0]}: {err['msg']}" for err in e.errors()] - error_str = "\n".join(errors) - await schedule_cmd.finish( - f"插件 '{plugin_name}' 的任务参数验证失败:\n{error_str}" - ) - return - - target_group_id: str | None - current_group_id = getattr(event, "group_id", None) - - if group_id and group_id.lower() == "all": - target_group_id = "__ALL_GROUPS__" - elif all_enabled.available: - target_group_id = "__ALL_GROUPS__" - elif group_id: - target_group_id = group_id - elif current_group_id: - target_group_id = str(current_group_id) - else: - await schedule_cmd.finish( - "私聊中设置定时任务时,必须使用 -g <群号> 或 --all 选项指定目标。" - ) - return - - success, msg = await scheduler_manager.add_schedule( - plugin_name, - target_group_id, - trigger_type, - trigger_config, - job_kwargs, - bot_id=bot_id_to_operate, - ) - - if target_group_id == "__ALL_GROUPS__": - target_desc = f"所有群组 (Bot: {bot_id_to_operate})" - elif target_group_id is None: - target_desc = "全局" - else: - target_desc = f"群组 {target_group_id}" - - if success: - await schedule_cmd.finish(f"已成功为 [{target_desc}] {msg}") - else: - await schedule_cmd.finish(f"为 [{target_desc}] 设置任务失败: {msg}") - - -@schedule_cmd.assign("删除") -async def _( - target: TargetScope = Depends(create_target_parser("删除")), - bot_id_to_operate: str = Depends(GetBotId), -): - if isinstance(target, TargetByID): - _, message = await scheduler_manager.remove_schedule_by_id(target.id) - await schedule_cmd.finish(message) - - elif isinstance(target, TargetByPlugin): - p_name = target.plugin - if p_name not in scheduler_manager.get_registered_plugins(): - await schedule_cmd.finish(f"未找到插件 '{p_name}'。") - - if target.all_groups: - removed_count = await scheduler_manager.remove_schedule_for_all( - p_name, bot_id=bot_id_to_operate - ) - message = ( - f"已取消了 {removed_count} 个群组的插件 '{p_name}' 定时任务。" - if removed_count > 0 - else f"没有找到插件 '{p_name}' 的定时任务。" - ) - await schedule_cmd.finish(message) - else: - _, message = await scheduler_manager.remove_schedule( - p_name, target.group_id, bot_id=bot_id_to_operate - ) - await schedule_cmd.finish(message) - - elif isinstance(target, TargetAll): - if target.for_group: - _, message = await scheduler_manager.remove_schedules_by_group( - target.for_group - ) - await schedule_cmd.finish(message) - else: - _, message = await scheduler_manager.remove_all_schedules() - await schedule_cmd.finish(message) - - else: - await schedule_cmd.finish( - "删除任务失败:请提供任务ID,或通过 -p <插件> 或 -all 指定要删除的任务。" - ) - - -@schedule_cmd.assign("暂停") -async def _( - target: TargetScope = Depends(create_target_parser("暂停")), - bot_id_to_operate: str = Depends(GetBotId), -): - if isinstance(target, TargetByID): - _, message = await scheduler_manager.pause_schedule(target.id) - await schedule_cmd.finish(message) - - elif isinstance(target, TargetByPlugin): - p_name = target.plugin - if p_name not in scheduler_manager.get_registered_plugins(): - await schedule_cmd.finish(f"未找到插件 '{p_name}'。") - - if target.all_groups: - _, message = await scheduler_manager.pause_schedules_by_plugin(p_name) - await schedule_cmd.finish(message) - else: - _, message = await scheduler_manager.pause_schedule_by_plugin_group( - p_name, target.group_id, bot_id=bot_id_to_operate - ) - await schedule_cmd.finish(message) - - elif isinstance(target, TargetAll): - if target.for_group: - _, message = await scheduler_manager.pause_schedules_by_group( - target.for_group - ) - await schedule_cmd.finish(message) - else: - _, message = await scheduler_manager.pause_all_schedules() - await schedule_cmd.finish(message) - - else: - await schedule_cmd.finish("请提供任务ID、使用 -p <插件> 或 -all 选项。") - - -@schedule_cmd.assign("恢复") -async def _( - target: TargetScope = Depends(create_target_parser("恢复")), - bot_id_to_operate: str = Depends(GetBotId), -): - if isinstance(target, TargetByID): - _, message = await scheduler_manager.resume_schedule(target.id) - await schedule_cmd.finish(message) - - elif isinstance(target, TargetByPlugin): - p_name = target.plugin - if p_name not in scheduler_manager.get_registered_plugins(): - await schedule_cmd.finish(f"未找到插件 '{p_name}'。") - - if target.all_groups: - _, message = await scheduler_manager.resume_schedules_by_plugin(p_name) - await schedule_cmd.finish(message) - else: - _, message = await scheduler_manager.resume_schedule_by_plugin_group( - p_name, target.group_id, bot_id=bot_id_to_operate - ) - await schedule_cmd.finish(message) - - elif isinstance(target, TargetAll): - if target.for_group: - _, message = await scheduler_manager.resume_schedules_by_group( - target.for_group - ) - await schedule_cmd.finish(message) - else: - _, message = await scheduler_manager.resume_all_schedules() - await schedule_cmd.finish(message) - - else: - await schedule_cmd.finish("请提供任务ID、使用 -p <插件> 或 -all 选项。") - - -@schedule_cmd.assign("执行") -async def _(schedule_id: int): - _, message = await scheduler_manager.trigger_now(schedule_id) - await schedule_cmd.finish(message) - - -@schedule_cmd.assign("更新") -async def _( - schedule_id: int, - cron_expr: str | None = None, - interval_expr: str | None = None, - date_expr: str | None = None, - daily_expr: str | None = None, - kwargs_str: str | None = None, -): - if not any([cron_expr, interval_expr, date_expr, daily_expr, kwargs_str]): - await schedule_cmd.finish( - "请提供需要更新的时间 (--cron/--interval/--date/--daily) 或参数 (--kwargs)" - ) - - trigger_config = None - trigger_type = None - try: - if cron_expr: - trigger_type = "cron" - parts = cron_expr.split() - if len(parts) != 5: - raise ValueError("Cron 表达式必须有5个部分") - cron_keys = ["minute", "hour", "day", "month", "day_of_week"] - trigger_config = dict(zip(cron_keys, parts)) - elif interval_expr: - trigger_type = "interval" - trigger_config = _parse_interval(interval_expr) - elif date_expr: - trigger_type = "date" - trigger_config = {"run_date": datetime.fromisoformat(date_expr)} - elif daily_expr: - trigger_type = "cron" - trigger_config = _parse_daily_time(daily_expr) - except ValueError as e: - await schedule_cmd.finish(f"时间参数解析错误: {e}") - - job_kwargs = None - if kwargs_str: - schedule = await scheduler_manager.get_schedule_by_id(schedule_id) - if not schedule: - await schedule_cmd.finish(f"未找到 ID 为 {schedule_id} 的任务。") - - task_meta = scheduler_manager._registered_tasks.get(schedule.plugin_name) - if not task_meta or not (params_model := task_meta.get("model")): - await schedule_cmd.finish( - f"插件 '{schedule.plugin_name}' 未定义参数模型,无法更新参数。" - ) - - if not (isinstance(params_model, type) and issubclass(params_model, BaseModel)): - await schedule_cmd.finish( - f"插件 '{schedule.plugin_name}' 的参数模型配置错误。" - ) - - raw_kwargs = {} - try: - for item in kwargs_str.split(","): - key, value = item.strip().split("=", 1) - raw_kwargs[key.strip()] = value - except Exception as e: - await schedule_cmd.finish( - f"参数格式错误,请使用 'key=value,key2=value2' 格式。错误: {e}" - ) - - try: - model_validate = getattr(params_model, "model_validate", None) - if not model_validate: - await schedule_cmd.finish( - f"插件 '{schedule.plugin_name}' 的参数模型不支持验证。" - ) - return - - validated_model = model_validate(raw_kwargs) - - model_dump = getattr(validated_model, "model_dump", None) - if not model_dump: - await schedule_cmd.finish( - f"插件 '{schedule.plugin_name}' 的参数模型不支持导出。" - ) - return - - job_kwargs = model_dump(exclude_unset=True) - except ValidationError as e: - errors = [f" - {err['loc'][0]}: {err['msg']}" for err in e.errors()] - error_str = "\n".join(errors) - await schedule_cmd.finish(f"更新的参数验证失败:\n{error_str}") - return - - _, message = await scheduler_manager.update_schedule( - schedule_id, trigger_type, trigger_config, job_kwargs - ) - await schedule_cmd.finish(message) - - -@schedule_cmd.assign("插件列表") -async def _(): - registered_plugins = scheduler_manager.get_registered_plugins() - if not registered_plugins: - await schedule_cmd.finish("当前没有已注册的定时任务插件。") - - message_parts = ["📋 已注册的定时任务插件:"] - for i, plugin_name in enumerate(registered_plugins, 1): - task_meta = scheduler_manager._registered_tasks[plugin_name] - params_model = task_meta.get("model") - - if not params_model: - message_parts.append(f"{i}. {plugin_name} - 无参数") - continue - - if not (isinstance(params_model, type) and issubclass(params_model, BaseModel)): - message_parts.append(f"{i}. {plugin_name} - ⚠️ 参数模型配置错误") - continue - - model_fields = getattr(params_model, "model_fields", None) - if model_fields: - param_info = ", ".join( - f"{field_name}({_get_type_name(field_info.annotation)})" - for field_name, field_info in model_fields.items() - ) - message_parts.append(f"{i}. {plugin_name} - 参数: {param_info}") - else: - message_parts.append(f"{i}. {plugin_name} - 无参数") - - await schedule_cmd.finish("\n".join(message_parts)) - - -@schedule_cmd.assign("状态") -async def _(schedule_id: int): - status = await scheduler_manager.get_schedule_status(schedule_id) - if not status: - await schedule_cmd.finish(f"未找到ID为 {schedule_id} 的定时任务。") - - info_lines = [ - f"📋 定时任务详细信息 (ID: {schedule_id})", - "--------------------", - f"▫️ 插件: {status['plugin_name']}", - f"▫️ Bot ID: {status.get('bot_id') or '默认'}", - f"▫️ 目标: {status['group_id'] or '全局'}", - f"▫️ 状态: {'✔️ 已启用' if status['is_enabled'] else '⏸️ 已暂停'}", - f"▫️ 下次运行: {status['next_run_time']}", - f"▫️ 触发规则: {_format_trigger(status)}", - f"▫️ 任务参数: {_format_params(status)}", - ] - await schedule_cmd.finish("\n".join(info_lines)) diff --git a/zhenxun/builtin_plugins/scheduler_admin/commands.py b/zhenxun/builtin_plugins/scheduler_admin/commands.py new file mode 100644 index 00000000..8a565dab --- /dev/null +++ b/zhenxun/builtin_plugins/scheduler_admin/commands.py @@ -0,0 +1,298 @@ +import re + +from nonebot.adapters import Event +from nonebot.adapters.onebot.v11 import Bot +from nonebot.params import Depends +from nonebot.permission import SUPERUSER +from nonebot_plugin_alconna import ( + Alconna, + AlconnaMatch, + Args, + Match, + Option, + Query, + Subcommand, + on_alconna, +) + +from zhenxun.configs.config import Config +from zhenxun.services.scheduler import scheduler_manager +from zhenxun.services.scheduler.targeter import ScheduleTargeter +from zhenxun.utils.rules import admin_check + +schedule_cmd = on_alconna( + Alconna( + "定时任务", + Subcommand( + "查看", + Option("-g", Args["target_group_id", str]), + Option("-all", help_text="查看所有群聊 (SUPERUSER)"), + Option("-p", Args["plugin_name", str], help_text="按插件名筛选"), + Option("--page", Args["page", int, 1], help_text="指定页码"), + alias=["ls", "list"], + help_text="查看定时任务", + ), + Subcommand( + "设置", + Args["plugin_name", str], + Option("--cron", Args["cron_expr", str], help_text="设置 cron 表达式"), + Option("--interval", Args["interval_expr", str], help_text="设置时间间隔"), + Option("--date", Args["date_expr", str], help_text="设置特定执行日期"), + Option( + "--daily", + Args["daily_expr", str], + help_text="设置每天执行的时间 (如 08:20)", + ), + Option("-g", Args["group_id", str], help_text="指定群组ID或'all'"), + Option("-all", help_text="对所有群生效 (等同于 -g all)"), + Option("--kwargs", Args["kwargs_str", str], help_text="设置任务参数"), + Option( + "--bot", Args["bot_id", str], help_text="指定操作的Bot ID (SUPERUSER)" + ), + alias=["add", "开启"], + help_text="设置/开启一个定时任务", + ), + Subcommand( + "删除", + Args["schedule_id?", int], + Option("-p", Args["plugin_name", str], help_text="指定插件名"), + Option("-g", Args["group_id", str], help_text="指定群组ID"), + Option("-all", help_text="对所有群生效"), + Option( + "--bot", Args["bot_id", str], help_text="指定操作的Bot ID (SUPERUSER)" + ), + alias=["del", "rm", "remove", "关闭", "取消"], + help_text="删除一个或多个定时任务", + ), + Subcommand( + "暂停", + Args["schedule_id?", int], + Option("-all", help_text="对当前群所有任务生效"), + Option("-p", Args["plugin_name", str], help_text="指定插件名"), + Option("-g", Args["group_id", str], help_text="指定群组ID (SUPERUSER)"), + Option( + "--bot", Args["bot_id", str], help_text="指定操作的Bot ID (SUPERUSER)" + ), + alias=["pause"], + help_text="暂停一个或多个定时任务", + ), + Subcommand( + "恢复", + Args["schedule_id?", int], + Option("-all", help_text="对当前群所有任务生效"), + Option("-p", Args["plugin_name", str], help_text="指定插件名"), + Option("-g", Args["group_id", str], help_text="指定群组ID (SUPERUSER)"), + Option( + "--bot", Args["bot_id", str], help_text="指定操作的Bot ID (SUPERUSER)" + ), + alias=["resume"], + help_text="恢复一个或多个定时任务", + ), + Subcommand( + "执行", + Args["schedule_id", int], + alias=["trigger", "run"], + help_text="立即执行一次任务", + ), + Subcommand( + "更新", + Args["schedule_id", int], + Option("--cron", Args["cron_expr", str], help_text="设置 cron 表达式"), + Option("--interval", Args["interval_expr", str], help_text="设置时间间隔"), + Option("--date", Args["date_expr", str], help_text="设置特定执行日期"), + Option( + "--daily", + Args["daily_expr", str], + help_text="更新每天执行的时间 (如 08:20)", + ), + Option("--kwargs", Args["kwargs_str", str], help_text="更新参数"), + alias=["update", "modify", "修改"], + help_text="更新任务配置", + ), + Subcommand( + "状态", + Args["schedule_id", int], + alias=["status", "info"], + help_text="查看单个任务的详细状态", + ), + Subcommand( + "插件列表", + alias=["plugins"], + help_text="列出所有可用的插件", + ), + ), + priority=5, + block=True, + rule=admin_check(1), +) + +schedule_cmd.shortcut( + "任务状态", + command="定时任务", + arguments=["状态", "{%0}"], + prefix=True, +) + + +class ScheduleTarget: + pass + + +class TargetByID(ScheduleTarget): + def __init__(self, id: int): + self.id = id + + +class TargetByPlugin(ScheduleTarget): + def __init__( + self, plugin: str, group_id: str | None = None, all_groups: bool = False + ): + self.plugin = plugin + self.group_id = group_id + self.all_groups = all_groups + + +class TargetAll(ScheduleTarget): + def __init__(self, for_group: str | None = None): + self.for_group = for_group + + +TargetScope = TargetByID | TargetByPlugin | TargetAll | None + + +def create_target_parser(subcommand_name: str): + async def dependency( + event: Event, + schedule_id: Match[int] = AlconnaMatch("schedule_id"), + plugin_name: Match[str] = AlconnaMatch("plugin_name"), + group_id: Match[str] = AlconnaMatch("group_id"), + all_enabled: Query[bool] = Query(f"{subcommand_name}.all"), + ) -> TargetScope: + if schedule_id.available: + return TargetByID(schedule_id.result) + + if plugin_name.available: + p_name = plugin_name.result + if all_enabled.available: + return TargetByPlugin(plugin=p_name, all_groups=True) + elif group_id.available: + gid = group_id.result + if gid.lower() == "all": + return TargetByPlugin(plugin=p_name, all_groups=True) + return TargetByPlugin(plugin=p_name, group_id=gid) + else: + current_group_id = getattr(event, "group_id", None) + return TargetByPlugin( + plugin=p_name, + group_id=str(current_group_id) if current_group_id else None, + ) + + if all_enabled.available: + current_group_id = getattr(event, "group_id", None) + if not current_group_id: + await schedule_cmd.finish( + "私聊中单独使用 -all 选项时,必须使用 -g <群号> 指定目标。" + ) + return TargetAll(for_group=str(current_group_id)) + + return None + + return dependency + + +def parse_interval(interval_str: str) -> dict: + match = re.match(r"(\d+)([smhd])", interval_str.lower()) + if not match: + raise ValueError("时间间隔格式错误, 请使用如 '30m', '2h', '1d', '10s' 的格式。") + value, unit = int(match.group(1)), match.group(2) + if unit == "s": + return {"seconds": value} + if unit == "m": + return {"minutes": value} + if unit == "h": + return {"hours": value} + if unit == "d": + return {"days": value} + return {} + + +def parse_daily_time(time_str: str) -> dict: + if match := re.match(r"^(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?$", time_str): + hour, minute, second = match.groups() + hour, minute = int(hour), int(minute) + if not (0 <= hour <= 23 and 0 <= minute <= 59): + raise ValueError("小时或分钟数值超出范围。") + cron_config = { + "minute": str(minute), + "hour": str(hour), + "day": "*", + "month": "*", + "day_of_week": "*", + "timezone": Config.get_config("SchedulerManager", "SCHEDULER_TIMEZONE"), + } + if second is not None: + if not (0 <= int(second) <= 59): + raise ValueError("秒数值超出范围。") + cron_config["second"] = str(second) + return cron_config + else: + raise ValueError("时间格式错误,请使用 'HH:MM' 或 'HH:MM:SS' 格式。") + + +async def GetBotId(bot: Bot, bot_id_match: Match[str] = AlconnaMatch("bot_id")) -> str: + if bot_id_match.available: + return bot_id_match.result + return bot.self_id + + +def GetTargeter(subcommand: str): + """ + 依赖注入函数,用于解析命令参数并返回一个配置好的 ScheduleTargeter 实例。 + """ + + async def dependency( + event: Event, + bot: Bot, + schedule_id: Match[int] = AlconnaMatch("schedule_id"), + plugin_name: Match[str] = AlconnaMatch("plugin_name"), + group_id: Match[str] = AlconnaMatch("group_id"), + all_enabled: Query[bool] = Query(f"{subcommand}.all"), + bot_id_to_operate: str = Depends(GetBotId), + ) -> ScheduleTargeter: + if schedule_id.available: + return scheduler_manager.target(id=schedule_id.result) + + if plugin_name.available: + if all_enabled.available: + return scheduler_manager.target(plugin_name=plugin_name.result) + + current_group_id = getattr(event, "group_id", None) + gid = group_id.result if group_id.available else current_group_id + return scheduler_manager.target( + plugin_name=plugin_name.result, + group_id=str(gid) if gid else None, + bot_id=bot_id_to_operate, + ) + + if all_enabled.available: + current_group_id = getattr(event, "group_id", None) + gid = group_id.result if group_id.available else current_group_id + is_su = await SUPERUSER(bot, event) + if not gid and not is_su: + await schedule_cmd.finish( + f"在私聊中对所有任务进行'{subcommand}'操作需要超级用户权限。" + ) + + if (gid and str(gid).lower() == "all") or (not gid and is_su): + return scheduler_manager.target() + + return scheduler_manager.target( + group_id=str(gid) if gid else None, bot_id=bot_id_to_operate + ) + + await schedule_cmd.finish( + f"'{subcommand}'操作失败:请提供任务ID," + f"或通过 -p <插件名> 或 -all 指定要操作的任务。" + ) + + return Depends(dependency) diff --git a/zhenxun/builtin_plugins/scheduler_admin/handlers.py b/zhenxun/builtin_plugins/scheduler_admin/handlers.py new file mode 100644 index 00000000..839ece12 --- /dev/null +++ b/zhenxun/builtin_plugins/scheduler_admin/handlers.py @@ -0,0 +1,380 @@ +from datetime import datetime + +from nonebot.adapters import Event +from nonebot.adapters.onebot.v11 import Bot +from nonebot.params import Depends +from nonebot.permission import SUPERUSER +from nonebot_plugin_alconna import AlconnaMatch, Arparma, Match, Query +from pydantic import BaseModel, ValidationError + +from zhenxun.models.schedule_info import ScheduleInfo +from zhenxun.services.scheduler import scheduler_manager +from zhenxun.services.scheduler.targeter import ScheduleTargeter +from zhenxun.utils.message import MessageUtils + +from . import presenters +from .commands import ( + GetBotId, + GetTargeter, + parse_daily_time, + parse_interval, + schedule_cmd, +) + + +@schedule_cmd.handle() +async def _handle_time_options_mutex(arp: Arparma): + time_options = ["cron", "interval", "date", "daily"] + provided_options = [opt for opt in time_options if arp.query(opt) is not None] + if len(provided_options) > 1: + await schedule_cmd.finish( + f"时间选项 --{', --'.join(provided_options)} 不能同时使用,请只选择一个。" + ) + + +@schedule_cmd.assign("查看") +async def handle_view( + bot: Bot, + event: Event, + target_group_id: Match[str] = AlconnaMatch("target_group_id"), + all_groups: Query[bool] = Query("查看.all"), + plugin_name: Match[str] = AlconnaMatch("plugin_name"), + page: Match[int] = AlconnaMatch("page"), +): + is_superuser = await SUPERUSER(bot, event) + title = "" + gid_filter = None + + current_group_id = getattr(event, "group_id", None) + if not (all_groups.available or target_group_id.available) and not current_group_id: + await schedule_cmd.finish("私聊中查看任务必须使用 -g <群号> 或 -all 选项。") + + if all_groups.available: + if not is_superuser: + await schedule_cmd.finish("需要超级用户权限才能查看所有群组的定时任务。") + title = "所有群组的定时任务" + elif target_group_id.available: + if not is_superuser: + await schedule_cmd.finish("需要超级用户权限才能查看指定群组的定时任务。") + gid_filter = target_group_id.result + title = f"群 {gid_filter} 的定时任务" + else: + gid_filter = str(current_group_id) + title = "本群的定时任务" + + p_name_filter = plugin_name.result if plugin_name.available else None + + schedules = await scheduler_manager.get_schedules( + plugin_name=p_name_filter, group_id=gid_filter + ) + + if p_name_filter: + title += f" [插件: {p_name_filter}]" + + if not schedules: + await schedule_cmd.finish("没有找到任何相关的定时任务。") + + img = await presenters.format_schedule_list_as_image( + schedules=schedules, title=title, current_page=page.result + ) + await MessageUtils.build_message(img).send(reply_to=True) + + +@schedule_cmd.assign("设置") +async def handle_set( + event: Event, + plugin_name: Match[str] = AlconnaMatch("plugin_name"), + cron_expr: Match[str] = AlconnaMatch("cron_expr"), + interval_expr: Match[str] = AlconnaMatch("interval_expr"), + date_expr: Match[str] = AlconnaMatch("date_expr"), + daily_expr: Match[str] = AlconnaMatch("daily_expr"), + group_id: Match[str] = AlconnaMatch("group_id"), + kwargs_str: Match[str] = AlconnaMatch("kwargs_str"), + all_enabled: Query[bool] = Query("设置.all"), + bot_id_to_operate: str = Depends(GetBotId), +): + if not plugin_name.available: + await schedule_cmd.finish("设置任务时必须提供插件名称。") + + has_time_option = any( + [ + cron_expr.available, + interval_expr.available, + date_expr.available, + daily_expr.available, + ] + ) + if not has_time_option: + await schedule_cmd.finish( + "必须提供一种时间选项: --cron, --interval, --date, 或 --daily。" + ) + + p_name = plugin_name.result + if p_name not in scheduler_manager.get_registered_plugins(): + await schedule_cmd.finish( + f"插件 '{p_name}' 没有注册可用的定时任务。\n" + f"可用插件: {list(scheduler_manager.get_registered_plugins())}" + ) + + trigger_type, trigger_config = "", {} + try: + if cron_expr.available: + trigger_type, trigger_config = ( + "cron", + dict( + zip( + ["minute", "hour", "day", "month", "day_of_week"], + cron_expr.result.split(), + ) + ), + ) + elif interval_expr.available: + trigger_type, trigger_config = ( + "interval", + parse_interval(interval_expr.result), + ) + elif date_expr.available: + trigger_type, trigger_config = ( + "date", + {"run_date": datetime.fromisoformat(date_expr.result)}, + ) + elif daily_expr.available: + trigger_type, trigger_config = "cron", parse_daily_time(daily_expr.result) + else: + await schedule_cmd.finish( + "必须提供一种时间选项: --cron, --interval, --date, 或 --daily。" + ) + except ValueError as e: + await schedule_cmd.finish(f"时间参数解析错误: {e}") + + job_kwargs = {} + if kwargs_str.available: + task_meta = scheduler_manager._registered_tasks[p_name] + params_model = task_meta.get("model") + if not ( + params_model + and isinstance(params_model, type) + and issubclass(params_model, BaseModel) + ): + await schedule_cmd.finish(f"插件 '{p_name}' 不支持或配置了无效的参数模型。") + try: + raw_kwargs = dict( + item.strip().split("=", 1) for item in kwargs_str.result.split(",") + ) + + model_validate = getattr(params_model, "model_validate", None) + if not model_validate: + await schedule_cmd.finish(f"插件 '{p_name}' 的参数模型不支持验证") + + validated_model = model_validate(raw_kwargs) + + model_dump = getattr(validated_model, "model_dump", None) + if not model_dump: + await schedule_cmd.finish(f"插件 '{p_name}' 的参数模型不支持导出") + + job_kwargs = model_dump() + except ValidationError as e: + errors = [f" - {err['loc'][0]}: {err['msg']}" for err in e.errors()] + await schedule_cmd.finish( + f"插件 '{p_name}' 的任务参数验证失败:\n" + "\n".join(errors) + ) + except Exception as e: + await schedule_cmd.finish( + f"参数格式错误,请使用 'key=value,key2=value2' 格式。错误: {e}" + ) + + gid_str = group_id.result if group_id.available else None + target_group_id = ( + scheduler_manager.ALL_GROUPS + if (gid_str and gid_str.lower() == "all") or all_enabled.available + else gid_str or getattr(event, "group_id", None) + ) + if not target_group_id: + await schedule_cmd.finish( + "私聊中设置定时任务时,必须使用 -g <群号> 或 --all 选项指定目标。" + ) + + schedule = await scheduler_manager.add_schedule( + p_name, + str(target_group_id), + trigger_type, + trigger_config, + job_kwargs, + bot_id=bot_id_to_operate, + ) + + target_desc = ( + f"所有群组 (Bot: {bot_id_to_operate})" + if target_group_id == scheduler_manager.ALL_GROUPS + else f"群组 {target_group_id}" + ) + + if schedule: + await schedule_cmd.finish( + f"为 [{target_desc}] 已成功设置插件 '{p_name}' 的定时任务 " + f"(ID: {schedule.id})。" + ) + else: + await schedule_cmd.finish(f"为 [{target_desc}] 设置任务失败。") + + +@schedule_cmd.assign("删除") +async def handle_delete(targeter: ScheduleTargeter = GetTargeter("删除")): + schedules_to_remove: list[ScheduleInfo] = await targeter._get_schedules() + if not schedules_to_remove: + await schedule_cmd.finish("没有找到可删除的任务。") + + count, _ = await targeter.remove() + + if count > 0 and schedules_to_remove: + if len(schedules_to_remove) == 1: + message = presenters.format_remove_success(schedules_to_remove[0]) + else: + target_desc = targeter._generate_target_description() + message = f"✅ 成功移除了{target_desc} {count} 个任务。" + else: + message = "没有任务被移除。" + await schedule_cmd.finish(message) + + +@schedule_cmd.assign("暂停") +async def handle_pause(targeter: ScheduleTargeter = GetTargeter("暂停")): + schedules_to_pause: list[ScheduleInfo] = await targeter._get_schedules() + if not schedules_to_pause: + await schedule_cmd.finish("没有找到可暂停的任务。") + + count, _ = await targeter.pause() + + if count > 0 and schedules_to_pause: + if len(schedules_to_pause) == 1: + message = presenters.format_pause_success(schedules_to_pause[0]) + else: + target_desc = targeter._generate_target_description() + message = f"✅ 成功暂停了{target_desc} {count} 个任务。" + else: + message = "没有任务被暂停。" + await schedule_cmd.finish(message) + + +@schedule_cmd.assign("恢复") +async def handle_resume(targeter: ScheduleTargeter = GetTargeter("恢复")): + schedules_to_resume: list[ScheduleInfo] = await targeter._get_schedules() + if not schedules_to_resume: + await schedule_cmd.finish("没有找到可恢复的任务。") + + count, _ = await targeter.resume() + + if count > 0 and schedules_to_resume: + if len(schedules_to_resume) == 1: + message = presenters.format_resume_success(schedules_to_resume[0]) + else: + target_desc = targeter._generate_target_description() + message = f"✅ 成功恢复了{target_desc} {count} 个任务。" + else: + message = "没有任务被恢复。" + await schedule_cmd.finish(message) + + +@schedule_cmd.assign("执行") +async def handle_trigger(schedule_id: Match[int] = AlconnaMatch("schedule_id")): + from zhenxun.services.scheduler.repository import ScheduleRepository + + schedule_info = await ScheduleRepository.get_by_id(schedule_id.result) + if not schedule_info: + await schedule_cmd.finish(f"未找到 ID 为 {schedule_id.result} 的任务。") + + success, message = await scheduler_manager.trigger_now(schedule_id.result) + + if success: + final_message = presenters.format_trigger_success(schedule_info) + else: + final_message = f"❌ 手动触发失败: {message}" + await schedule_cmd.finish(final_message) + + +@schedule_cmd.assign("更新") +async def handle_update( + schedule_id: Match[int] = AlconnaMatch("schedule_id"), + cron_expr: Match[str] = AlconnaMatch("cron_expr"), + interval_expr: Match[str] = AlconnaMatch("interval_expr"), + date_expr: Match[str] = AlconnaMatch("date_expr"), + daily_expr: Match[str] = AlconnaMatch("daily_expr"), + kwargs_str: Match[str] = AlconnaMatch("kwargs_str"), +): + if not any( + [ + cron_expr.available, + interval_expr.available, + date_expr.available, + daily_expr.available, + kwargs_str.available, + ] + ): + await schedule_cmd.finish( + "请提供需要更新的时间 (--cron/--interval/--date/--daily) 或参数 (--kwargs)" + ) + + trigger_type, trigger_config, job_kwargs = None, None, None + try: + if cron_expr.available: + trigger_type, trigger_config = ( + "cron", + dict( + zip( + ["minute", "hour", "day", "month", "day_of_week"], + cron_expr.result.split(), + ) + ), + ) + elif interval_expr.available: + trigger_type, trigger_config = ( + "interval", + parse_interval(interval_expr.result), + ) + elif date_expr.available: + trigger_type, trigger_config = ( + "date", + {"run_date": datetime.fromisoformat(date_expr.result)}, + ) + elif daily_expr.available: + trigger_type, trigger_config = "cron", parse_daily_time(daily_expr.result) + except ValueError as e: + await schedule_cmd.finish(f"时间参数解析错误: {e}") + + if kwargs_str.available: + job_kwargs = dict( + item.strip().split("=", 1) for item in kwargs_str.result.split(",") + ) + + success, message = await scheduler_manager.update_schedule( + schedule_id.result, trigger_type, trigger_config, job_kwargs + ) + + if success: + from zhenxun.services.scheduler.repository import ScheduleRepository + + updated_schedule = await ScheduleRepository.get_by_id(schedule_id.result) + if updated_schedule: + final_message = presenters.format_update_success(updated_schedule) + else: + final_message = "✅ 更新成功,但无法获取更新后的任务详情。" + else: + final_message = f"❌ 更新失败: {message}" + + await schedule_cmd.finish(final_message) + + +@schedule_cmd.assign("插件列表") +async def handle_plugins_list(): + message = await presenters.format_plugins_list() + await schedule_cmd.finish(message) + + +@schedule_cmd.assign("状态") +async def handle_status(schedule_id: Match[int] = AlconnaMatch("schedule_id")): + status = await scheduler_manager.get_schedule_status(schedule_id.result) + if not status: + await schedule_cmd.finish(f"未找到ID为 {schedule_id.result} 的定时任务。") + + message = presenters.format_single_status_message(status) + await schedule_cmd.finish(message) diff --git a/zhenxun/builtin_plugins/scheduler_admin/presenters.py b/zhenxun/builtin_plugins/scheduler_admin/presenters.py new file mode 100644 index 00000000..ef6785bd --- /dev/null +++ b/zhenxun/builtin_plugins/scheduler_admin/presenters.py @@ -0,0 +1,274 @@ +import asyncio + +from zhenxun.models.schedule_info import ScheduleInfo +from zhenxun.services.scheduler import scheduler_manager +from zhenxun.utils._image_template import ImageTemplate, RowStyle + + +def _get_type_name(annotation) -> str: + """获取类型注解的名称""" + if hasattr(annotation, "__name__"): + return annotation.__name__ + elif hasattr(annotation, "_name"): + return annotation._name + else: + return str(annotation) + + +def _format_trigger(schedule: dict) -> str: + """格式化触发器信息为可读字符串""" + trigger_type = schedule.get("trigger_type") + config = schedule.get("trigger_config") + + if not isinstance(config, dict): + return f"配置错误: {config}" + + if trigger_type == "cron": + hour = config.get("hour", "??") + minute = config.get("minute", "??") + try: + hour_int = int(hour) + minute_int = int(minute) + return f"每天 {hour_int:02d}:{minute_int:02d}" + except (ValueError, TypeError): + return f"每天 {hour}:{minute}" + elif trigger_type == "interval": + units = { + "weeks": "周", + "days": "天", + "hours": "小时", + "minutes": "分钟", + "seconds": "秒", + } + for unit, unit_name in units.items(): + if value := config.get(unit): + return f"每 {value} {unit_name}" + return "未知间隔" + elif trigger_type == "date": + run_date = config.get("run_date", "N/A") + return f"特定时间 {run_date}" + else: + return f"未知触发器类型: {trigger_type}" + + +def _format_trigger_for_card(schedule_info: ScheduleInfo | dict) -> str: + """为信息卡片格式化触发器规则""" + trigger_type = ( + schedule_info.get("trigger_type") + if isinstance(schedule_info, dict) + else schedule_info.trigger_type + ) + config = ( + schedule_info.get("trigger_config") + if isinstance(schedule_info, dict) + else schedule_info.trigger_config + ) + + if not isinstance(config, dict): + return f"配置错误: {config}" + + if trigger_type == "cron": + hour = config.get("hour", "??") + minute = config.get("minute", "??") + try: + hour_int = int(hour) + minute_int = int(minute) + return f"每天 {hour_int:02d}:{minute_int:02d}" + except (ValueError, TypeError): + return f"每天 {hour}:{minute}" + elif trigger_type == "interval": + units = { + "weeks": "周", + "days": "天", + "hours": "小时", + "minutes": "分钟", + "seconds": "秒", + } + for unit, unit_name in units.items(): + if value := config.get(unit): + return f"每 {value} {unit_name}" + return "未知间隔" + elif trigger_type == "date": + run_date = config.get("run_date", "N/A") + return f"特定时间 {run_date}" + else: + return f"未知规则: {trigger_type}" + + +def _format_operation_result_card( + title: str, schedule_info: ScheduleInfo, extra_info: list[str] | None = None +) -> str: + """ + 生成一个标准的操作结果信息卡片。 + + 参数: + title: 卡片的标题 (例如 "✅ 成功暂停定时任务!") + schedule_info: 相关的 ScheduleInfo 对象 + extra_info: (可选) 额外的补充信息行 + """ + target_desc = ( + f"群组 {schedule_info.group_id}" + if schedule_info.group_id + and schedule_info.group_id != scheduler_manager.ALL_GROUPS + else "所有群组" + if schedule_info.group_id == scheduler_manager.ALL_GROUPS + else "全局" + ) + + info_lines = [ + title, + f"✓ 任务 ID: {schedule_info.id}", + f"🖋 插件: {schedule_info.plugin_name}", + f"🎯 目标: {target_desc}", + f"⏰ 时间: {_format_trigger_for_card(schedule_info)}", + ] + if extra_info: + info_lines.extend(extra_info) + + return "\n".join(info_lines) + + +def format_pause_success(schedule_info: ScheduleInfo) -> str: + """格式化暂停成功的消息""" + return _format_operation_result_card("✅ 成功暂停定时任务!", schedule_info) + + +def format_resume_success(schedule_info: ScheduleInfo) -> str: + """格式化恢复成功的消息""" + return _format_operation_result_card("▶️ 成功恢复定时任务!", schedule_info) + + +def format_remove_success(schedule_info: ScheduleInfo) -> str: + """格式化删除成功的消息""" + return _format_operation_result_card("❌ 成功删除定时任务!", schedule_info) + + +def format_trigger_success(schedule_info: ScheduleInfo) -> str: + """格式化手动触发成功的消息""" + return _format_operation_result_card("🚀 成功手动触发定时任务!", schedule_info) + + +def format_update_success(schedule_info: ScheduleInfo) -> str: + """格式化更新成功的消息""" + return _format_operation_result_card("🔄️ 成功更新定时任务配置!", schedule_info) + + +def _status_row_style(column: str, text: str) -> RowStyle: + """为状态列设置颜色""" + style = RowStyle() + if column == "状态": + if text == "启用": + style.font_color = "#67C23A" + elif text == "暂停": + style.font_color = "#F56C6C" + elif text == "运行中": + style.font_color = "#409EFF" + return style + + +def _format_params(schedule_status: dict) -> str: + """将任务参数格式化为人类可读的字符串""" + if kwargs := schedule_status.get("job_kwargs"): + return " | ".join(f"{k}: {v}" for k, v in kwargs.items()) + return "-" + + +async def format_schedule_list_as_image( + schedules: list[ScheduleInfo], title: str, current_page: int +): + """将任务列表格式化为图片""" + page_size = 15 + total_items = len(schedules) + total_pages = (total_items + page_size - 1) // page_size + start_index = (current_page - 1) * page_size + end_index = start_index + page_size + paginated_schedules = schedules[start_index:end_index] + + if not paginated_schedules: + return "这一页没有内容了哦~" + + status_tasks = [ + scheduler_manager.get_schedule_status(s.id) for s in paginated_schedules + ] + all_statuses = await asyncio.gather(*status_tasks) + + def get_status_text(status_value): + if isinstance(status_value, bool): + return "启用" if status_value else "暂停" + return str(status_value) + + data_list = [ + [ + s["id"], + s["plugin_name"], + s.get("bot_id") or "N/A", + s["group_id"] or "全局", + s["next_run_time"], + _format_trigger(s), + _format_params(s), + get_status_text(s["is_enabled"]), + ] + for s in all_statuses + if s + ] + + if not data_list: + return "没有找到任何相关的定时任务。" + + return await ImageTemplate.table_page( + head_text=title, + tip_text=f"第 {current_page}/{total_pages} 页,共 {total_items} 条任务", + column_name=["ID", "插件", "Bot", "目标", "下次运行", "规则", "参数", "状态"], + data_list=data_list, + column_space=20, + text_style=_status_row_style, + ) + + +def format_single_status_message(status: dict) -> str: + """格式化单个任务状态为文本消息""" + info_lines = [ + f"📋 定时任务详细信息 (ID: {status['id']})", + "--------------------", + f"▫️ 插件: {status['plugin_name']}", + f"▫️ Bot ID: {status.get('bot_id') or '默认'}", + f"▫️ 目标: {status['group_id'] or '全局'}", + f"▫️ 状态: {'✔️ 已启用' if status['is_enabled'] else '⏸️ 已暂停'}", + f"▫️ 下次运行: {status['next_run_time']}", + f"▫️ 触发规则: {_format_trigger(status)}", + f"▫️ 任务参数: {_format_params(status)}", + ] + return "\n".join(info_lines) + + +async def format_plugins_list() -> str: + """格式化可用插件列表为文本消息""" + from pydantic import BaseModel + + registered_plugins = scheduler_manager.get_registered_plugins() + if not registered_plugins: + return "当前没有已注册的定时任务插件。" + + message_parts = ["📋 已注册的定时任务插件:"] + for i, plugin_name in enumerate(registered_plugins, 1): + task_meta = scheduler_manager._registered_tasks[plugin_name] + params_model = task_meta.get("model") + + param_info_str = "无参数" + if ( + params_model + and isinstance(params_model, type) + and issubclass(params_model, BaseModel) + ): + model_fields = getattr(params_model, "model_fields", None) + if model_fields: + param_info_str = "参数: " + ", ".join( + f"{field_name}({_get_type_name(field_info.annotation)})" + for field_name, field_info in model_fields.items() + ) + elif params_model: + param_info_str = "⚠️ 参数模型配置错误" + + message_parts.append(f"{i}. {plugin_name} - {param_info_str}") + + return "\n".join(message_parts) diff --git a/zhenxun/builtin_plugins/statistics/_data_source.py b/zhenxun/builtin_plugins/statistics/_data_source.py index d51cb685..2ceb4590 100644 --- a/zhenxun/builtin_plugins/statistics/_data_source.py +++ b/zhenxun/builtin_plugins/statistics/_data_source.py @@ -1,5 +1,3 @@ -from datetime import datetime, timedelta - from tortoise.functions import Count from zhenxun.models.group_console import GroupConsole @@ -10,6 +8,7 @@ from zhenxun.utils.echart_utils import ChartUtils from zhenxun.utils.echart_utils.models import Barh from zhenxun.utils.enum import PluginType from zhenxun.utils.image_utils import BuildImage +from zhenxun.utils.utils import TimeUtils class StatisticsManage: @@ -68,8 +67,7 @@ class StatisticsManage: if plugin_name: query = query.filter(plugin_name=plugin_name) if day: - time = datetime.now() - timedelta(days=day) - query = query.filter(create_time__gte=time) + query = query.filter(create_time__gte=TimeUtils.get_day_start()) data_list = ( await query.annotate(count=Count("id")) .group_by("plugin_name") @@ -89,8 +87,7 @@ class StatisticsManage: if group_id: query = query.filter(group_id=group_id) if day: - time = datetime.now() - timedelta(days=day) - query = query.filter(create_time__gte=time) + query = query.filter(create_time__gte=TimeUtils.get_day_start()) data_list = ( await query.annotate(count=Count("id")) .group_by("plugin_name") @@ -106,8 +103,7 @@ class StatisticsManage: async def get_group_statistics(cls, group_id: str, day: int | None, title: str): query = Statistics.filter(group_id=group_id) if day: - time = datetime.now() - timedelta(days=day) - query = query.filter(create_time__gte=time) + query = query.filter(create_time__gte=TimeUtils.get_day_start()) data_list = ( await query.annotate(count=Count("id")) .group_by("plugin_name") diff --git a/zhenxun/builtin_plugins/superuser/broadcast/__init__.py b/zhenxun/builtin_plugins/superuser/broadcast/__init__.py index 3fc08e4c..0b9c15ba 100644 --- a/zhenxun/builtin_plugins/superuser/broadcast/__init__.py +++ b/zhenxun/builtin_plugins/superuser/broadcast/__init__.py @@ -28,7 +28,7 @@ from nonebot_plugin_alconna.uniseg.segment import ( ) from nonebot_plugin_session import EventSession -from zhenxun.configs.utils import PluginExtraData, RegisterConfig, Task +from zhenxun.configs.utils import PluginExtraData, Task from zhenxun.utils.enum import PluginType from zhenxun.utils.message import MessageUtils @@ -73,16 +73,6 @@ __plugin_meta__ = PluginMetadata( author="HibiKier", version="1.2", plugin_type=PluginType.SUPERUSER, - configs=[ - RegisterConfig( - module="_task", - key="DEFAULT_BROADCAST", - value=True, - help="被动 广播 进群默认开关状态", - default_value=True, - type=bool, - ) - ], tasks=[Task(module="broadcast", name="广播")], ).to_dict(), ) diff --git a/zhenxun/configs/utils/__init__.py b/zhenxun/configs/utils/__init__.py index 5be9bb9c..bddb7e67 100644 --- a/zhenxun/configs/utils/__init__.py +++ b/zhenxun/configs/utils/__init__.py @@ -106,18 +106,34 @@ class ConfigGroup(BaseModel): if value_to_process is None: return default - if cfg.type: - if build_model and _is_pydantic_type(cfg.type): - try: - return parse_as(cfg.type, value_to_process) - except Exception as e: - logger.warning(f"Pydantic 模型解析失败 (key: {c.upper()}). ", e=e) + if cfg.arg_parser: try: - return cattrs.structure(value_to_process, cfg.type) + return cfg.arg_parser(value_to_process) except Exception as e: - logger.warning(f"Cattrs 结构化失败 (key: {key}),返回原始值。", e=e) + logger.debug( + f"配置项类型转换 MODULE: [{self.module}] | " + f"KEY: [{key}] 的自定义解析器失败,将使用原始值", + e=e, + ) + return value_to_process - return value_to_process + if not build_model or not cfg.type: + return value_to_process + + try: + if _is_pydantic_type(cfg.type): + parsed_value = parse_as(cfg.type, value_to_process) + return parsed_value + else: + structured_value = cattrs.structure(value_to_process, cfg.type) + return structured_value + except Exception as e: + logger.error( + f"❌ 配置项 '{self.module}.{key}' 自动类型转换失败 " + f"(目标类型: {cfg.type}),将返回原始值。请检查配置文件格式。错误: {e}", + e=e, + ) + return value_to_process def to_dict(self, **kwargs): return model_dump(self, **kwargs) diff --git a/zhenxun/models/chat_history.py b/zhenxun/models/chat_history.py index 7284db1e..a9e0ca6c 100644 --- a/zhenxun/models/chat_history.py +++ b/zhenxun/models/chat_history.py @@ -49,7 +49,8 @@ class ChatHistory(Model): o = "-" if order == "DESC" else "" query = cls.filter(group_id=gid) if gid else cls if date_scope: - query = query.filter(create_time__range=date_scope) + filter_scope = (date_scope[0].isoformat(" "), date_scope[1].isoformat(" ")) + query = query.filter(create_time__range=filter_scope) return list( await query.annotate(count=Count("user_id")) .order_by(f"{o}count") diff --git a/zhenxun/models/level_user.py b/zhenxun/models/level_user.py index d60b52c2..4269f315 100644 --- a/zhenxun/models/level_user.py +++ b/zhenxun/models/level_user.py @@ -90,13 +90,14 @@ class LevelUser(Model): 返回: bool: 是否大于level """ + if level == 0: + return True if group_id: if user := await cls.get_or_none(user_id=user_id, group_id=group_id): return user.user_level >= level - else: - if user_list := await cls.filter(user_id=user_id).all(): - user = max(user_list, key=lambda x: x.user_level) - return user.user_level >= level + elif user_list := await cls.filter(user_id=user_id).all(): + user = max(user_list, key=lambda x: x.user_level) + return user.user_level >= level return False @classmethod @@ -119,8 +120,7 @@ class LevelUser(Model): return [ # 将user_id改为user_id "ALTER TABLE level_users RENAME COLUMN user_qq TO user_id;", - "ALTER TABLE level_users " - "ALTER COLUMN user_id TYPE character varying(255);", + "ALTER TABLE level_users ALTER COLUMN user_id TYPE character varying(255);", # 将user_id字段类型改为character varying(255) "ALTER TABLE level_users " "ALTER COLUMN group_id TYPE character varying(255);", diff --git a/zhenxun/services/__init__.py b/zhenxun/services/__init__.py index 14ae227f..6af390a8 100644 --- a/zhenxun/services/__init__.py +++ b/zhenxun/services/__init__.py @@ -1,3 +1,14 @@ +""" +Zhenxun Bot - 核心服务模块 + +主要服务包括: +- 数据库上下文 (db_context): 提供数据库模型基类和连接管理。 +- 日志服务 (log): 提供增强的、带上下文的日志记录器。 +- LLM服务 (llm): 提供与大语言模型交互的统一API。 +- 插件生命周期管理 (plugin_init): 支持插件安装和卸载时的钩子函数。 +- 定时任务调度器 (scheduler): 提供持久化的、可管理的定时任务服务。 +""" + from nonebot import require require("nonebot_plugin_apscheduler") @@ -6,3 +17,33 @@ require("nonebot_plugin_session") require("nonebot_plugin_htmlrender") require("nonebot_plugin_uninfo") require("nonebot_plugin_waiter") + +from .db_context import Model, disconnect +from .llm import ( + AI, + LLMContentPart, + LLMException, + LLMMessage, + get_model_instance, + list_available_models, + tool_registry, +) +from .log import logger +from .plugin_init import PluginInit, PluginInitManager +from .scheduler import scheduler_manager + +__all__ = [ + "AI", + "LLMContentPart", + "LLMException", + "LLMMessage", + "Model", + "PluginInit", + "PluginInitManager", + "disconnect", + "get_model_instance", + "list_available_models", + "logger", + "scheduler_manager", + "tool_registry", +] diff --git a/zhenxun/services/llm/README.md b/zhenxun/services/llm/README.md index 263be1e6..93394fdf 100644 --- a/zhenxun/services/llm/README.md +++ b/zhenxun/services/llm/README.md @@ -1,731 +1,559 @@ -# Zhenxun LLM 服务模块 -## 📑 目录 +--- -- [📖 概述](#-概述) -- [🌟 主要特性](#-主要特性) -- [🚀 快速开始](#-快速开始) -- [📚 API 参考](#-api-参考) -- [⚙️ 配置](#️-配置) -- [🔧 高级功能](#-高级功能) -- [🏗️ 架构设计](#️-架构设计) -- [🔌 支持的提供商](#-支持的提供商) -- [🎯 使用场景](#-使用场景) -- [📊 性能优化](#-性能优化) -- [🛠️ 故障排除](#️-故障排除) -- [❓ 常见问题](#-常见问题) -- [📝 示例项目](#-示例项目) -- [🤝 贡献](#-贡献) -- [📄 许可证](#-许可证) +# 🚀 Zhenxun LLM 服务模块 -## 📖 概述 +本模块是一个功能强大、高度可扩展的统一大语言模型(LLM)服务框架。它旨在将各种不同的 LLM 提供商(如 OpenAI、Gemini、智谱AI等)的 API 封装在一个统一、易于使用的接口之后,让开发者可以无缝切换和使用不同的模型,同时支持多模态输入、工具调用、智能重试和缓存等高级功能。 -Zhenxun LLM 服务模块是一个现代化的AI服务框架,提供统一的接口来访问多个大语言模型提供商。该模块采用模块化设计,支持异步操作、智能重试、Key轮询和负载均衡等高级功能。 +## 目录 -### 🌟 主要特性 +- [🚀 Zhenxun LLM 服务模块](#-zhenxun-llm-服务模块) + - [目录](#目录) + - [✨ 核心特性](#-核心特性) + - [🧠 核心概念](#-核心概念) + - [🛠️ 安装与配置](#️-安装与配置) + - [服务提供商配置 (`config.yaml`)](#服务提供商配置-configyaml) + - [MCP 工具配置 (`mcp_tools.json`)](#mcp-工具配置-mcp_toolsjson) + - [📘 使用指南](#-使用指南) + - [**等级1: 便捷函数** - 最快速的调用方式](#等级1-便捷函数---最快速的调用方式) + - [**等级2: `AI` 会话类** - 管理有状态的对话](#等级2-ai-会话类---管理有状态的对话) + - [**等级3: 直接模型控制** - `get_model_instance`](#等级3-直接模型控制---get_model_instance) + - [🌟 功能深度剖析](#-功能深度剖析) + - [精细化控制模型生成 (`LLMGenerationConfig` 与 `CommonOverrides`)](#精细化控制模型生成-llmgenerationconfig-与-commonoverrides) + - [赋予模型能力:工具使用 (Function Calling)](#赋予模型能力工具使用-function-calling) + - [1. 注册工具](#1-注册工具) + - [函数工具注册](#函数工具注册) + - [MCP工具注册](#mcp工具注册) + - [2. 调用带工具的模型](#2-调用带工具的模型) + - [处理多模态输入](#处理多模态输入) + - [🔧 高级主题与扩展](#-高级主题与扩展) + - [模型与密钥管理](#模型与密钥管理) + - [缓存管理](#缓存管理) + - [错误处理 (`LLMException`)](#错误处理-llmexception) + - [自定义适配器 (Adapter)](#自定义适配器-adapter) + - [📚 API 快速参考](#-api-快速参考) -- **多提供商支持**: OpenAI、Gemini、智谱AI、DeepSeek等 -- **统一接口**: 简洁一致的API设计 -- **智能Key轮询**: 自动负载均衡和故障转移 -- **异步高性能**: 基于asyncio的并发处理 -- **模型缓存**: 智能缓存机制提升性能 -- **工具调用**: 支持Function Calling -- **嵌入向量**: 文本向量化支持 -- **错误处理**: 完善的异常处理和重试机制 -- **多模态支持**: 文本、图像、音频、视频处理 -- **代码执行**: Gemini代码执行功能 -- **搜索增强**: Google搜索集成 +--- -## 🚀 快速开始 +## ✨ 核心特性 -### 基本使用 +- **多提供商支持**: 内置对 OpenAI、Gemini、智谱AI 等多种 API 的适配器,并可通过通用 OpenAI 兼容适配器轻松接入更多服务。 +- **统一的 API**: 提供从简单到高级的三层 API,满足不同场景的需求,无论是快速聊天还是复杂的分析任务。 +- **强大的工具调用 (Function Calling)**: 支持标准的函数调用和实验性的 MCP (Model Context Protocol) 工具,让 LLM 能够与外部世界交互。 +- **多模态能力**: 无缝集成 `UniMessage`,轻松处理文本、图片、音频、视频等混合输入,支持多模态搜索和分析。 +- **文本嵌入向量化**: 提供统一的嵌入接口,支持语义搜索、相似度计算和文本聚类等应用。 +- **智能重试与 Key 轮询**: 内置健壮的请求重试逻辑,当 API Key 失效或达到速率限制时,能自动轮询使用备用 Key。 +- **灵活的配置系统**: 通过配置文件和代码中的 `LLMGenerationConfig`,可以精细控制模型的生成行为(如温度、最大Token等)。 +- **高性能缓存机制**: 内置模型实例缓存,减少重复初始化开销,提供缓存管理和监控功能。 +- **丰富的配置预设**: 提供 `CommonOverrides` 类,包含创意模式、精确模式、JSON输出等多种常用配置预设。 +- **可扩展的适配器架构**: 开发者可以轻松编写自己的适配器来支持新的 LLM 服务。 + +## 🧠 核心概念 + +- **适配器 (Adapter)**: 这是连接我们统一接口和特定 LLM 提供商 API 的“翻译官”。例如,`GeminiAdapter` 知道如何将我们的标准请求格式转换为 Google Gemini API 需要的格式,并解析其响应。 +- **模型实例 (`LLMModel`)**: 这是框架中的核心操作对象,代表一个**具体配置好**的模型。例如,一个 `LLMModel` 实例可能代表使用特定 API Key、特定代理的 `Gemini/gemini-1.5-pro`。所有与模型交互的操作都通过这个类的实例进行。 +- **生成配置 (`LLMGenerationConfig`)**: 这是一个数据类,用于控制模型在生成内容时的行为,例如 `temperature` (温度)、`max_tokens` (最大输出长度)、`response_format` (响应格式) 等。 +- **工具 (Tool)**: 代表一个可以让 LLM 调用的函数。它可以是一个简单的 Python 函数,也可以是一个更复杂的、有状态的 MCP 服务。 +- **多模态内容 (`LLMContentPart`)**: 这是处理多模态输入的基础单元,一个 `LLMMessage` 可以包含多个 `LLMContentPart`,如一个文本部分和多个图片部分。 + +## 🛠️ 安装与配置 + +该模块作为 `zhenxun` 项目的一部分被集成,无需额外安装。核心配置主要涉及两个文件。 + +### 服务提供商配置 (`config.yaml`) + +核心配置位于项目 `/data/config.yaml` 文件中的 `AI` 部分。 + +```yaml +# /data/configs/config.yaml +AI: + # (可选) 全局默认模型,格式: "ProviderName/ModelName" + default_model_name: Gemini/gemini-2.5-flash + # (可选) 全局代理设置 + proxy: http://127.0.0.1:7890 + # (可选) 全局超时设置 (秒) + timeout: 180 + # (可选) Gemini 的安全过滤阈值 + gemini_safety_threshold: BLOCK_MEDIUM_AND_ABOVE + + # 配置你的AI服务提供商 + PROVIDERS: + # 示例1: Gemini + - name: Gemini + api_key: + - "AIzaSy_KEY_1" # 支持多个Key,会自动轮询 + - "AIzaSy_KEY_2" + api_base: https://generativelanguage.googleapis.com + api_type: gemini + models: + - model_name: gemini-2.5-pro + - model_name: gemini-2.5-flash + - model_name: gemini-2.0-flash + - model_name: embedding-001 + is_embedding_model: true # 标记为嵌入模型 + max_input_tokens: 2048 # 嵌入模型特有配置 + + # 示例2: 智谱AI + - name: GLM + api_key: "YOUR_ZHIPU_API_KEY" + api_type: zhipu # 适配器类型 + models: + - model_name: glm-4-flash + - model_name: glm-4-plus + temperature: 0.8 # 可以为特定模型设置默认温度 + + # 示例3: 一个兼容OpenAI的自定义服务 + - name: MyOpenAIService + api_key: "sk-my-custom-key" + api_base: "http://localhost:8080/v1" + api_type: general_openai_compat # 使用通用OpenAI兼容适配器 + models: + - model_name: Llama3-8B-Instruct + max_tokens: 2048 # 可以为特定模型设置默认最大Token +``` + +### MCP 工具配置 (`mcp_tools.json`) + +此文件位于 `/data/llm/mcp_tools.json`,用于配置通过 MCP 协议启动的外部工具服务。如果文件不存在,系统会自动创建一个包含示例的默认文件。 + +```json +{ + "mcpServers": { + "baidu-map": { + "command": "npx", + "args": ["-y", "@baidumap/mcp-server-baidu-map"], + "env": { + "BAIDU_MAP_API_KEY": "" + }, + "description": "百度地图工具,提供地理编码、路线规划等功能。" + }, + "sequential-thinking": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-sequential-thinking"], + "description": "顺序思维工具,用于帮助模型进行多步骤推理。" + } + } +} +``` + +## 📘 使用指南 + +我们提供了三层 API,以满足从简单到复杂的各种需求。 + +### **等级1: 便捷函数** - 最快速的调用方式 + +这些函数位于 `zhenxun.services.llm` 包的顶层,为你处理了所有的底层细节。 ```python -from zhenxun.services.llm import chat, code, search, analyze +from zhenxun.services.llm import chat, search, code, pipeline_chat, embed, analyze_multimodal, search_multimodal +from zhenxun.services.llm.utils import create_multimodal_message -# 简单聊天 -response = await chat("你好,请介绍一下自己") +# 1. 纯文本聊天 +response_text = await chat("你好,请用苏轼的风格写一首关于月亮的诗。") +print(response_text) + +# 2. 带网络搜索的问答 +search_result = await search("马斯克的Neuralink公司最近有什么新进展?") +print(search_result['text']) +# print(search_result['sources']) # 查看信息来源 + +# 3. 执行代码 +code_result = await code("用Python画一个心形图案。") +print(code_result['text']) # 包含代码和解释的回复 + +# 4. 链式调用 +image_msg = create_multimodal_message(images="path/to/cat.jpg") +final_poem = await pipeline_chat( + message=image_msg, + model_chain=["Gemini/gemini-1.5-pro", "GLM/glm-4-flash"], + initial_instruction="详细描述这只猫的外观和姿态。", + final_instruction="将上述描述凝练成一首可爱的短诗。" +) +print(final_poem.text) + +# 5. 文本嵌入向量生成 +texts_to_embed = ["今天天气真好", "我喜欢打篮球", "这部电影很感人"] +vectors = await embed(texts_to_embed, model="Gemini/embedding-001") +print(f"生成了 {len(vectors)} 个向量,每个向量维度: {len(vectors[0])}") + +# 6. 多模态分析便捷函数 +response = await analyze_multimodal( + text="请分析这张图片中的内容", + images="path/to/image.jpg", + model="Gemini/gemini-1.5-pro" +) print(response) -# 代码执行 -result = await code("计算斐波那契数列的前10项") -print(result["text"]) -print(result["code_executions"]) - -# 搜索功能 -search_result = await search("Python异步编程最佳实践") -print(search_result["text"]) - -# 多模态分析 -from nonebot_plugin_alconna.uniseg import UniMessage, Image, Text -message = UniMessage([ - Text("分析这张图片"), - Image(path="image.jpg") -]) -analysis = await analyze(message, model="Gemini/gemini-2.0-flash") -print(analysis) -``` - -### 使用AI类 - -```python -from zhenxun.services.llm import AI, AIConfig, CommonOverrides - -# 创建AI实例 -ai = AI(AIConfig(model="OpenAI/gpt-4")) - -# 聊天对话 -response = await ai.chat("解释量子计算的基本原理") - -# 多模态分析 -from nonebot_plugin_alconna.uniseg import UniMessage, Image, Text - -multimodal_msg = UniMessage([ - Text("这张图片显示了什么?"), - Image(path="image.jpg") -]) -result = await ai.analyze(multimodal_msg) - -# 便捷的多模态函数 -result = await analyze_with_images( - "分析这张图片", - images="image.jpg", - model="Gemini/gemini-2.0-flash" +# 7. 多模态搜索便捷函数 +search_result = await search_multimodal( + text="搜索与这张图片相关的信息", + images="path/to/image.jpg", + model="Gemini/gemini-1.5-pro" ) +print(search_result['text']) ``` -## 📚 API 参考 +### **等级2: `AI` 会话类** - 管理有状态的对话 -### 快速函数 - -#### `chat(message, *, model=None, **kwargs) -> str` -简单聊天对话 - -**参数:** -- `message`: 消息内容(字符串、LLMMessage或内容部分列表) -- `model`: 模型名称(可选) -- `**kwargs`: 额外配置参数 - -#### `code(prompt, *, model=None, timeout=None, **kwargs) -> dict` -代码执行功能 - -**返回:** -```python -{ - "text": "执行结果说明", - "code_executions": [{"code": "...", "output": "..."}], - "success": True -} -``` - -#### `search(query, *, model=None, instruction="", **kwargs) -> dict` -搜索增强生成 - -**返回:** -```python -{ - "text": "搜索结果和分析", - "grounding_metadata": {...}, - "success": True -} -``` - -#### `analyze(message, *, instruction="", model=None, tools=None, tool_config=None, **kwargs) -> str | LLMResponse` -高级分析功能,支持多模态输入和工具调用 - -#### `analyze_with_images(text, images, *, instruction="", model=None, **kwargs) -> str` -图片分析便捷函数 - -#### `analyze_multimodal(text=None, images=None, videos=None, audios=None, *, instruction="", model=None, **kwargs) -> str` -多模态分析便捷函数 - -#### `embed(texts, *, model=None, task_type="RETRIEVAL_DOCUMENT", **kwargs) -> list[list[float]]` -文本嵌入向量 - -### AI类方法 - -#### `AI.chat(message, *, model=None, **kwargs) -> str` -聊天对话方法,支持简单多模态输入 - -#### `AI.analyze(message, *, instruction="", model=None, tools=None, tool_config=None, **kwargs) -> str | LLMResponse` -高级分析方法,接收UniMessage进行多模态分析和工具调用 - -### 模型管理 +当你需要进行有上下文的、连续的对话时,`AI` 类是你的最佳选择。 ```python -from zhenxun.services.llm import ( - get_model_instance, - list_available_models, - set_global_default_model_name, - clear_model_cache -) +from zhenxun.services.llm.api import AI, AIConfig -# 获取模型实例 -model = await get_model_instance("OpenAI/gpt-4o") +# 初始化一个AI会话,可以传入自定义配置 +ai_config = AIConfig(model="GLM/glm-4-flash", temperature=0.7) +ai_session = AI(config=ai_config) -# 列出可用模型 -models = list_available_models() - -# 设置默认模型 -set_global_default_model_name("Gemini/gemini-2.0-flash") - -# 清理缓存 -clear_model_cache() -``` - -## ⚙️ 配置 - -### 预设配置 - -```python -from zhenxun.services.llm import CommonOverrides - -# 创意模式 -creative_config = CommonOverrides.creative() - -# 精确模式 -precise_config = CommonOverrides.precise() - -# Gemini特殊功能 -json_config = CommonOverrides.gemini_json() -thinking_config = CommonOverrides.gemini_thinking() -code_exec_config = CommonOverrides.gemini_code_execution() -grounding_config = CommonOverrides.gemini_grounding() -``` - -### 自定义配置 - -```python -from zhenxun.services.llm import LLMGenerationConfig - -config = LLMGenerationConfig( +# 更完整的AIConfig配置示例 +advanced_config = AIConfig( + model="GLM/glm-4-flash", + default_embedding_model="Gemini/embedding-001", # 默认嵌入模型 temperature=0.7, - max_tokens=2048, - top_p=0.9, - frequency_penalty=0.1, - presence_penalty=0.1, - stop=["END", "STOP"], - response_mime_type="application/json", - enable_code_execution=True, - enable_grounding=True + max_tokens=2000, + enable_cache=True, # 启用模型缓存 + enable_code=True, # 启用代码执行功能 + enable_search=True, # 启用搜索功能 + timeout=180, # 请求超时时间(秒) + # Gemini特定配置选项 + enable_gemini_json_mode=True, # 启用Gemini JSON模式 + enable_gemini_thinking=True, # 启用Gemini 思考模式 + enable_gemini_safe_mode=True, # 启用Gemini 安全模式 + enable_gemini_multimodal=True, # 启用Gemini 多模态优化 + enable_gemini_grounding=True, # 启用Gemini 信息来源关联 ) +advanced_session = AI(config=advanced_config) -response = await chat("你的问题", override_config=config) +# 进行连续对话 +await ai_session.chat("我最喜欢的城市是成都。") +response = await ai_session.chat("它有什么好吃的?") # AI会知道“它”指的是成都 +print(response) + +# 在同一个会话中,临时切换模型进行一次调用 +response_gemini = await ai_session.chat( + "从AI的角度分析一下成都的科技发展潜力。", + model="Gemini/gemini-1.5-pro" +) +print(response_gemini) + +# 清空历史,开始新一轮对话 +ai_session.clear_history() ``` -## 🔧 高级功能 +### **等级3: 直接模型控制** - `get_model_instance` -### 工具调用 (Function Calling) +这是最底层的 API,为你提供对模型实例的完全控制。推荐使用 `async with` 语句来优雅地管理模型实例的生命周期。 ```python -from zhenxun.services.llm import LLMTool, get_model_instance +from zhenxun.services.llm import get_model_instance, LLMMessage +from zhenxun.services.llm.config import LLMGenerationConfig -# 定义工具 -tools = [ - LLMTool( - name="get_weather", - description="获取天气信息", - parameters={ - "type": "object", - "properties": { - "city": {"type": "string", "description": "城市名称"} - }, - "required": ["city"] - } +# 1. 获取模型实例 +# get_model_instance 返回一个异步上下文管理器 +async with await get_model_instance("Gemini/gemini-1.5-pro") as model: + # 2. 准备消息列表 + messages = [ + LLMMessage.system("你是一个专业的营养师。"), + LLMMessage.user("我今天吃了汉堡和可乐,请给我一些健康建议。") + ] + + # 3. (可选) 定义本次调用的生成配置 + gen_config = LLMGenerationConfig( + temperature=0.2, # 更严谨的回复 + max_tokens=300 ) -] - -# 工具执行器 -async def tool_executor(tool_name: str, args: dict) -> str: - if tool_name == "get_weather": - return f"{args['city']}今天晴天,25°C" - return "未知工具" - -# 使用工具 -model = await get_model_instance("OpenAI/gpt-4") -response = await model.generate_response( - messages=[{"role": "user", "content": "北京天气如何?"}], - tools=tools, - tool_executor=tool_executor -) + + # 4. 生成响应 + response = await model.generate_response(messages, config=gen_config) + + # 5. 处理响应 + print(response.text) + if response.usage_info: + print(f"Token 消耗: {response.usage_info['total_tokens']}") ``` -### 多模态处理 +## 🌟 功能深度剖析 + +### 精细化控制模型生成 (`LLMGenerationConfig` 与 `CommonOverrides`) + +- **`LLMGenerationConfig`**: 一个 Pydantic 模型,用于覆盖模型的默认生成参数。 +- **`CommonOverrides`**: 一个包含多种常用配置预设的类,如 `creative()`, `precise()`, `gemini_json()` 等,能极大地简化配置过程。 ```python -from zhenxun.services.llm import create_multimodal_message, analyze_multimodal, analyze_with_images +from zhenxun.services.llm.config import LLMGenerationConfig, CommonOverrides -# 方法1:使用便捷函数 -result = await analyze_multimodal( - text="分析这些媒体文件", - images="image.jpg", - audios="audio.mp3", - model="Gemini/gemini-2.0-flash" +# LLMGenerationConfig 完整参数示例 +comprehensive_config = LLMGenerationConfig( + temperature=0.7, # 生成温度 (0.0-2.0) + max_tokens=1000, # 最大输出token数 + top_p=0.9, # 核采样参数 (0.0-1.0) + top_k=40, # Top-K采样参数 + frequency_penalty=0.0, # 频率惩罚 (-2.0-2.0) + presence_penalty=0.0, # 存在惩罚 (-2.0-2.0) + repetition_penalty=1.0, # 重复惩罚 (0.0-2.0) + stop=["END", "\n\n"], # 停止序列 + response_format={"type": "json_object"}, # 响应格式 + response_mime_type="application/json", # Gemini专用MIME类型 + response_schema={...}, # JSON响应模式 + thinking_budget=0.8, # Gemini思考预算 (0.0-1.0) + enable_code_execution=True, # 启用代码执行 + safety_settings={...}, # 安全设置 + response_modalities=["TEXT"], # 响应模态类型 ) -# 方法2:使用create_multimodal_message +# 创建一个配置,要求模型输出JSON格式 +json_config = LLMGenerationConfig( + temperature=0.1, + response_mime_type="application/json" # Gemini特有 +) +# 对于OpenAI兼容API,可以这样做 +json_config_openai = LLMGenerationConfig( + temperature=0.1, + response_format={"type": "json_object"} +) + +# 使用框架提供的预设 - 基础预设 +safe_config = CommonOverrides.gemini_safe() +creative_config = CommonOverrides.creative() +precise_config = CommonOverrides.precise() +balanced_config = CommonOverrides.balanced() + +# 更多实用预设 +concise_config = CommonOverrides.concise(max_tokens=50) # 简洁模式 +detailed_config = CommonOverrides.detailed(max_tokens=3000) # 详细模式 +json_config = CommonOverrides.gemini_json() # JSON输出模式 +thinking_config = CommonOverrides.gemini_thinking(budget=0.8) # 思考模式 + +# Gemini特定高级预设 +code_config = CommonOverrides.gemini_code_execution() # 代码执行模式 +grounding_config = CommonOverrides.gemini_grounding() # 信息来源关联模式 +multimodal_config = CommonOverrides.gemini_multimodal() # 多模态优化模式 + +# 在调用时传入config对象 +# await model.generate_response(messages, config=json_config) +``` + +### 赋予模型能力:工具使用 (Function Calling) + +工具调用让 LLM 能够与外部函数、API 或服务进行交互。 + +#### 1. 注册工具 + +##### 函数工具注册 + +使用 `@tool_registry.function_tool` 装饰器注册一个简单的函数工具。 + +```python +from zhenxun.services.llm import tool_registry + +@tool_registry.function_tool( + name="query_stock_price", + description="查询指定股票代码的当前价格。", + parameters={ + "stock_symbol": {"type": "string", "description": "股票代码, 例如 'AAPL' 或 'GOOG'"} + }, + required=["stock_symbol"] +) +async def query_stock_price(stock_symbol: str) -> dict: + """一个查询股票价格的伪函数""" + print(f"--- 正在查询 {stock_symbol} 的价格 ---") + if stock_symbol == "AAPL": + return {"symbol": "AAPL", "price": 175.50, "currency": "USD"} + return {"error": "未知的股票代码"} +``` + +##### MCP工具注册 + +对于更复杂的、有状态的工具,可以使用 `@tool_registry.mcp_tool` 装饰器注册MCP工具。 + +```python +from contextlib import asynccontextmanager +from pydantic import BaseModel +from zhenxun.services.llm import tool_registry + +# 定义工具的配置模型 +class MyToolConfig(BaseModel): + api_key: str + endpoint: str + timeout: int = 30 + +# 注册MCP工具 +@tool_registry.mcp_tool(name="my-custom-tool", config_model=MyToolConfig) +@asynccontextmanager +async def my_tool_factory(config: MyToolConfig): + """MCP工具工厂函数""" + # 初始化工具会话 + session = MyToolSession(config) + try: + await session.initialize() + yield session + finally: + await session.cleanup() +``` + +#### 2. 调用带工具的模型 + +在 `analyze` 或 `generate_response` 中使用 `use_tools` 参数。框架会自动处理整个调用流程。 + +```python +from zhenxun.services.llm.api import analyze +from nonebot_plugin_alconna.uniseg import UniMessage + +response = await analyze( + UniMessage("帮我查一下苹果公司的股价"), + use_tools=["query_stock_price"] +) +print(response.text) # 输出应为 "苹果公司(AAPL)的当前股价为175.5美元。" 或类似内容 +``` + +### 处理多模态输入 + +本模块通过 `UniMessage` 和 `LLMContentPart` 完美支持多模态。 + +- **`create_multimodal_message`**: 推荐的、用于从代码中便捷地创建多模态消息的函数。 +- **`unimsg_to_llm_parts`**: 框架内部使用的核心转换函数,将 `UniMessage` 的各个段(文本、图片等)转换为 `LLMContentPart` 列表。 + +```python +from zhenxun.services.llm import analyze +from zhenxun.services.llm.utils import create_multimodal_message +from pathlib import Path + +# 从本地文件创建消息 message = create_multimodal_message( - text="分析这张图片和音频", - images="image.jpg", - audios="audio.mp3" + text="请分析这张图片和这个视频。图片里是什么?视频里发生了什么?", + images=[Path("path/to/your/image.jpg")], + videos=[Path("path/to/your/video.mp4")] ) -result = await analyze(message) +response = await analyze(message, model="Gemini/gemini-1.5-pro") +print(response.text) +``` -# 方法3:图片分析专用函数 -result = await analyze_with_images( - "这张图片显示了什么?", - images=["image1.jpg", "image2.jpg"] +## 🔧 高级主题与扩展 + +### 模型与密钥管理 + +模块提供了一些工具函数来管理你的模型配置。 + +```python +from zhenxun.services.llm.manager import ( + list_available_models, + list_embedding_models, + set_global_default_model_name, + get_global_default_model_name, + get_key_usage_stats, + reset_key_status ) -``` +from zhenxun.services.llm import clear_model_cache, get_cache_stats -## 🛠️ 故障排除 - -### 常见错误 - -1. **配置错误**: 检查API密钥和模型配置 -2. **网络问题**: 检查代理设置和网络连接 -3. **模型不可用**: 使用 `list_available_models()` 检查可用模型 -4. **超时错误**: 调整timeout参数或使用更快的模型 - -### 调试技巧 - -```python -from zhenxun.services.llm import get_cache_stats -from zhenxun.services.log import logger - -# 查看缓存状态 -stats = get_cache_stats() -print(f"缓存命中率: {stats['hit_rate']}") - -# 启用详细日志 -logger.setLevel("DEBUG") -``` - -## ❓ 常见问题 - - -### Q: 如何处理多模态输入? - -**A:** 有多种方式处理多模态输入: -```python -# 方法1:使用便捷函数 -result = await analyze_with_images("分析这张图片", images="image.jpg") - -# 方法2:使用analyze函数 -from nonebot_plugin_alconna.uniseg import UniMessage, Image, Text -message = UniMessage([Text("分析这张图片"), Image(path="image.jpg")]) -result = await analyze(message) - -# 方法3:使用create_multimodal_message -from zhenxun.services.llm import create_multimodal_message -message = create_multimodal_message(text="分析这张图片", images="image.jpg") -result = await analyze(message) -``` - -### Q: 如何自定义工具调用? - -**A:** 使用analyze函数的tools参数: -```python -# 定义工具 -tools = [{ - "name": "calculator", - "description": "计算数学表达式", - "parameters": { - "type": "object", - "properties": { - "expression": {"type": "string", "description": "数学表达式"} - }, - "required": ["expression"] - } -}] - -# 使用工具 -from nonebot_plugin_alconna.uniseg import UniMessage, Text -message = UniMessage([Text("计算 2+3*4")]) -response = await analyze(message, tools=tools, tool_config={"mode": "auto"}) - -# 如果返回LLMResponse,说明有工具调用 -if hasattr(response, 'tool_calls'): - for tool_call in response.tool_calls: - print(f"调用工具: {tool_call.function.name}") - print(f"参数: {tool_call.function.arguments}") -``` - - -### Q: 如何确保输出格式? - -**A:** 使用结构化输出: -```python -# JSON格式输出 -config = CommonOverrides.gemini_json() - -# 自定义Schema -schema = { - "type": "object", - "properties": { - "answer": {"type": "string"}, - "confidence": {"type": "number"} - } -} -config = CommonOverrides.gemini_structured(schema) -``` - -## 📝 示例项目 - -### 完整示例 - -#### 1. 智能客服机器人 - -```python -from zhenxun.services.llm import AI, CommonOverrides -from typing import Dict, List - -class CustomerService: - def __init__(self): - self.ai = AI() - self.sessions: Dict[str, List[dict]] = {} - - async def handle_query(self, user_id: str, query: str) -> str: - # 获取或创建会话历史 - if user_id not in self.sessions: - self.sessions[user_id] = [] - - history = self.sessions[user_id] - - # 添加系统提示 - if not history: - history.append({ - "role": "system", - "content": "你是一个专业的客服助手,请友好、准确地回答用户问题。" - }) - - # 添加用户问题 - history.append({"role": "user", "content": query}) - - # 生成回复 - response = await self.ai.chat( - query, - history=history[-20:], # 保留最近20轮对话 - override_config=CommonOverrides.balanced() - ) - - # 保存回复到历史 - history.append({"role": "assistant", "content": response}) - - return response -``` - -#### 2. 文档智能问答 - -```python -from zhenxun.services.llm import embed, analyze -import numpy as np -from typing import List, Tuple - -class DocumentQA: - def __init__(self): - self.documents: List[str] = [] - self.embeddings: List[List[float]] = [] - - async def add_document(self, text: str): - """添加文档到知识库""" - self.documents.append(text) - - # 生成嵌入向量 - embedding = await embed([text]) - self.embeddings.extend(embedding) - - async def query(self, question: str, top_k: int = 3) -> str: - """查询文档并生成答案""" - if not self.documents: - return "知识库为空,请先添加文档。" - - # 生成问题的嵌入向量 - question_embedding = await embed([question]) - - # 计算相似度并找到最相关的文档 - similarities = [] - for doc_embedding in self.embeddings: - similarity = np.dot(question_embedding[0], doc_embedding) - similarities.append(similarity) - - # 获取最相关的文档 - top_indices = np.argsort(similarities)[-top_k:][::-1] - relevant_docs = [self.documents[i] for i in top_indices] - - # 构建上下文 - context = "\n\n".join(relevant_docs) - prompt = f""" -基于以下文档内容回答问题: - -文档内容: -{context} - -问题:{question} - -请基于文档内容给出准确的答案,如果文档中没有相关信息,请说明。 -""" - - result = await analyze(prompt) - return result["text"] -``` - -#### 3. 代码审查助手 - -```python -from zhenxun.services.llm import code, analyze -import os - -class CodeReviewer: - async def review_file(self, file_path: str) -> dict: - """审查代码文件""" - if not os.path.exists(file_path): - return {"error": "文件不存在"} - - with open(file_path, 'r', encoding='utf-8') as f: - code_content = f.read() - - prompt = f""" -请审查以下代码,提供详细的反馈: - -文件:{file_path} -代码: -``` -{code_content} -``` - -请从以下方面进行审查: -1. 代码质量和可读性 -2. 潜在的bug和安全问题 -3. 性能优化建议 -4. 最佳实践建议 -5. 代码风格问题 - -请以JSON格式返回结果。 -""" - - result = await analyze( - prompt, - model="DeepSeek/deepseek-coder", - override_config=CommonOverrides.gemini_json() - ) - - return { - "file": file_path, - "review": result["text"], - "success": True - } - - async def suggest_improvements(self, code: str, language: str = "python") -> str: - """建议代码改进""" - prompt = f""" -请改进以下{language}代码,使其更加高效、可读和符合最佳实践: - -原代码: -```{language} -{code} -``` - -请提供改进后的代码和说明。 -""" - - result = await code(prompt, model="DeepSeek/deepseek-coder") - return result["text"] -``` - - -## 🏗️ 架构设计 - -### 模块结构 - -``` -zhenxun/services/llm/ -├── __init__.py # 包入口,导入和暴露公共API -├── api.py # 高级API接口(AI类、便捷函数) -├── core.py # 核心基础设施(HTTP客户端、重试逻辑、KeyStore) -├── service.py # LLM模型实现类 -├── utils.py # 工具和转换函数 -├── manager.py # 模型管理和缓存 -├── adapters/ # 适配器模块 -│ ├── __init__.py # 适配器包入口 -│ ├── base.py # 基础适配器 -│ ├── factory.py # 适配器工厂 -│ ├── openai.py # OpenAI适配器 -│ ├── gemini.py # Gemini适配器 -│ └── zhipu.py # 智谱AI适配器 -├── config/ # 配置模块 -│ ├── __init__.py # 配置包入口 -│ ├── generation.py # 生成配置 -│ ├── presets.py # 预设配置 -│ └── providers.py # 提供商配置 -└── types/ # 类型定义 - ├── __init__.py # 类型包入口 - ├── content.py # 内容类型 - ├── enums.py # 枚举定义 - ├── exceptions.py # 异常定义 - └── models.py # 数据模型 -``` - -### 模块职责 - -- **`__init__.py`**: 纯粹的包入口,只负责导入和暴露公共API -- **`api.py`**: 高级API接口,包含AI类和所有便捷函数 -- **`core.py`**: 核心基础设施,包含HTTP客户端管理、重试逻辑和KeyStore -- **`service.py`**: LLM模型实现类,专注于模型逻辑 -- **`utils.py`**: 工具和转换函数,如多模态消息处理 -- **`manager.py`**: 模型管理和缓存机制 -- **`adapters/`**: 各大提供商的适配器模块,负责与不同API的交互 - - `base.py`: 定义适配器的基础接口 - - `factory.py`: 适配器工厂,用于动态加载和实例化适配器 - - `openai.py`: OpenAI API适配器 - - `gemini.py`: Google Gemini API适配器 - - `zhipu.py`: 智谱AI API适配器 -- **`config/`**: 配置管理模块 - - `generation.py`: 生成配置和预设 - - `presets.py`: 预设配置 - - `providers.py`: 提供商配置 -- **`types/`**: 类型定义模块 - - `content.py`: 内容类型定义 - - `enums.py`: 枚举定义 - - `exceptions.py`: 异常定义 - - `models.py`: 数据模型定义 - -## 🔌 支持的提供商 - -### OpenAI 兼容 - -- **OpenAI**: GPT-4o, GPT-3.5-turbo等 -- **DeepSeek**: deepseek-chat, deepseek-reasoner等 -- **其他OpenAI兼容API**: 支持自定义端点 - -```python -# OpenAI -await chat("Hello", model="OpenAI/gpt-4o") - -# DeepSeek -await chat("写代码", model="DeepSeek/deepseek-reasoner") -``` - -### Google Gemini - -- **Gemini Pro**: gemini-2.5-flash-preview-05-20 gemini-2.0-flash等 -- **特殊功能**: 代码执行、搜索增强、思考模式 - -```python -# 基础使用 -await chat("你好", model="Gemini/gemini-2.0-flash") - -# 代码执行 -await code("计算质数", model="Gemini/gemini-2.0-flash") - -# 搜索增强 -await search("最新AI发展", model="Gemini/gemini-2.5-flash-preview-05-20") -``` - -### 智谱AI - -- **GLM系列**: glm-4, glm-4v等 -- **支持功能**: 文本生成、多模态理解 - -```python -await chat("介绍北京", model="Zhipu/glm-4") -``` - -## 🎯 使用场景 - -### 1. 聊天机器人 - -```python -from zhenxun.services.llm import AI, CommonOverrides - -class ChatBot: - def __init__(self): - self.ai = AI() - self.history = [] - - async def chat(self, user_input: str) -> str: - # 添加历史记录 - self.history.append({"role": "user", "content": user_input}) - - # 生成回复 - response = await self.ai.chat( - user_input, - history=self.history[-10:], # 保留最近10轮对话 - override_config=CommonOverrides.balanced() - ) - - self.history.append({"role": "assistant", "content": response}) - return response -``` - -### 2. 代码助手 - -```python -async def code_assistant(task: str) -> dict: - """代码生成和执行助手""" - result = await code( - f"请帮我{task},并执行代码验证结果", - model="Gemini/gemini-2.0-flash", - timeout=60 - ) - - return { - "explanation": result["text"], - "code_blocks": result["code_executions"], - "success": result["success"] - } - -# 使用示例 -result = await code_assistant("实现快速排序算法") -``` - -### 3. 文档分析 - -```python -from zhenxun.services.llm import analyze_with_images - -async def analyze_document(image_path: str, question: str) -> str: - """分析文档图片并回答问题""" - result = await analyze_with_images( - f"请分析这个文档并回答:{question}", - images=image_path, - model="Gemini/gemini-2.0-flash" - ) - return result -``` - -### 4. 智能搜索 - -```python -async def smart_search(query: str) -> dict: - """智能搜索和总结""" - result = await search( - query, - model="Gemini/gemini-2.0-flash", - instruction="请提供准确、最新的信息,并注明信息来源" - ) - - return { - "summary": result["text"], - "sources": result.get("grounding_metadata", {}), - "confidence": result.get("confidence_score", 0.0) - } -``` - -## 🔧 配置管理 - - -### 动态配置 - -```python -from zhenxun.services.llm import set_global_default_model_name - -# 运行时更改默认模型 -set_global_default_model_name("OpenAI/gpt-4") - -# 检查可用模型 +# 列出所有在config.yaml中配置的可用模型 models = list_available_models() -for model in models: - print(f"{model.provider}/{model.name} - {model.description}") +print([m['full_name'] for m in models]) + +# 列出所有可用的嵌入模型 +embedding_models = list_embedding_models() +print([m['full_name'] for m in embedding_models]) + +# 动态设置全局默认模型 +success = set_global_default_model_name("GLM/glm-4-plus") + +# 获取所有Key的使用统计 +stats = await get_key_usage_stats() +print(stats) + +# 重置'Gemini'提供商的所有Key +await reset_key_status("Gemini") ``` +### 缓存管理 + +模块提供了模型实例缓存功能,可以提高性能并减少重复初始化的开销。 + +```python +from zhenxun.services.llm import clear_model_cache, get_cache_stats + +# 获取缓存统计信息 +stats = get_cache_stats() +print(f"缓存大小: {stats['cache_size']}/{stats['max_cache_size']}") +print(f"缓存TTL: {stats['cache_ttl']}秒") +print(f"已缓存模型: {stats['cached_models']}") + +# 清空模型缓存(在内存不足或需要强制重新初始化时使用) +clear_model_cache() +print("模型缓存已清空") +``` + +### 错误处理 (`LLMException`) + +所有模块内的预期错误都会被包装成 `LLMException`,方便统一处理。 + +```python +from zhenxun.services.llm import chat, LLMException, LLMErrorCode + +try: + await chat("test", model="InvalidProvider/invalid_model") +except LLMException as e: + print(f"捕获到LLM异常: {e}") + print(f"错误码: {e.code}") # 例如 LLMErrorCode.MODEL_NOT_FOUND + print(f"用户友好提示: {e.user_friendly_message}") +``` + +### 自定义适配器 (Adapter) + +如果你想支持一个新的、非 OpenAI 兼容的 LLM 服务,可以通过实现自己的适配器来完成。 + +1. **创建适配器类**: 继承 `BaseAdapter` 并实现其抽象方法。 + + ```python + # my_adapters/custom_adapter.py + from zhenxun.services.llm.adapters import BaseAdapter, RequestData, ResponseData + + class MyCustomAdapter(BaseAdapter): + @property + def api_type(self) -> str: return "my_custom_api" + + @property + def supported_api_types(self) -> list[str]: return ["my_custom_api"] + # ... 实现 prepare_advanced_request, parse_response 等方法 + ``` + +2. **注册适配器**: 在你的插件初始化代码中注册你的适配器。 + + ```python + from zhenxun.services.llm.adapters import register_adapter + from .my_adapters.custom_adapter import MyCustomAdapter + + register_adapter(MyCustomAdapter()) + ``` + +3. **在 `config.yaml` 中使用**: + + ```yaml + AI: + PROVIDERS: + - name: MyAwesomeLLM + api_key: "my-secret-key" + api_type: "my_custom_api" # 关键!使用你注册的 api_type + # ... + ``` + +## 📚 API 快速参考 + +| 类/函数 | 主要用途 | 推荐场景 | +| ------------------------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------ | +| `llm.chat()` | 进行简单的、无状态的文本对话。 | 快速实现单轮问答。 | +| `llm.search()` | 执行带网络搜索的问答。 | 需要最新信息或回答事实性问题时。 | +| `llm.code()` | 请求模型执行代码。 | 计算、数据处理、代码生成等。 | +| `llm.pipeline_chat()` | 将多个模型串联,处理复杂任务流。 | 需要多模型协作完成的任务,如“图生文再润色”。 | +| `llm.analyze()` | 处理复杂的多模态输入 (`UniMessage`) 和工具调用。 | 插件中处理用户命令,需要解析图片、at、回复等复杂消息时。 | +| `llm.AI` (类) | 管理一个有状态的、连续的对话会话。 | 需要实现上下文关联的连续对话机器人。 | +| `llm.get_model_instance()` | 获取一个底层的、可直接控制的 `LLMModel` 实例。 | 需要对模型进行最精细控制的复杂或自定义场景。 | +| `llm.config.LLMGenerationConfig` (类) | 定义模型生成的具体参数,如温度、最大Token等。 | 当需要微调模型输出风格或格式时。 | +| `llm.tools.tool_registry` (实例) | 注册和管理可供LLM调用的函数工具。 | 当你想让LLM拥有与外部世界交互的能力时。 | +| `llm.embed()` | 生成文本的嵌入向量表示。 | 语义搜索、相似度计算、文本聚类等。 | +| `llm.search_multimodal()` | 执行带网络搜索的多模态问答。 | 需要基于图片、视频等多模态内容进行搜索时。 | +| `llm.analyze_multimodal()` | 便捷的多模态分析函数。 | 直接分析文本、图片、视频、音频等多模态内容。 | +| `llm.AIConfig` (类) | AI会话的配置类,包含模型、温度等参数。 | 配置AI会话的行为和特性。 | +| `llm.clear_model_cache()` | 清空模型实例缓存。 | 内存管理或强制重新初始化模型时。 | +| `llm.get_cache_stats()` | 获取模型缓存的统计信息。 | 监控缓存使用情况和性能优化。 | +| `llm.list_embedding_models()` | 列出所有可用的嵌入模型。 | 选择合适的嵌入模型进行向量化任务。 | +| `llm.config.CommonOverrides` (类) | 提供常用的配置预设,如创意模式、精确模式等。 | 快速应用常见的模型配置组合。 | +| `llm.utils.create_multimodal_message` | 便捷地从文本、图片、音视频等数据创建 `UniMessage`。 | 在代码中以编程方式构建多模态输入时。 | \ No newline at end of file diff --git a/zhenxun/services/llm/__init__.py b/zhenxun/services/llm/__init__.py index ff09ef7a..62a0003f 100644 --- a/zhenxun/services/llm/__init__.py +++ b/zhenxun/services/llm/__init__.py @@ -10,10 +10,10 @@ from .api import ( TaskType, analyze, analyze_multimodal, - analyze_with_images, chat, code, embed, + pipeline_chat, search, search_multimodal, ) @@ -35,6 +35,7 @@ from .manager import ( list_model_identifiers, set_global_default_model_name, ) +from .tools import tool_registry from .types import ( EmbeddingTaskType, LLMContentPart, @@ -43,6 +44,7 @@ from .types import ( LLMMessage, LLMResponse, LLMTool, + MCPCompatible, ModelDetail, ModelInfo, ModelProvider, @@ -51,7 +53,7 @@ from .types import ( ToolMetadata, UsageInfo, ) -from .utils import create_multimodal_message, unimsg_to_llm_parts +from .utils import create_multimodal_message, message_to_unimessage, unimsg_to_llm_parts __all__ = [ "AI", @@ -65,6 +67,7 @@ __all__ = [ "LLMMessage", "LLMResponse", "LLMTool", + "MCPCompatible", "ModelDetail", "ModelInfo", "ModelName", @@ -76,7 +79,6 @@ __all__ = [ "UsageInfo", "analyze", "analyze_multimodal", - "analyze_with_images", "chat", "clear_model_cache", "code", @@ -88,9 +90,12 @@ __all__ = [ "list_available_models", "list_embedding_models", "list_model_identifiers", + "message_to_unimessage", + "pipeline_chat", "register_llm_configs", "search", "search_multimodal", "set_global_default_model_name", + "tool_registry", "unimsg_to_llm_parts", ] diff --git a/zhenxun/services/llm/adapters/__init__.py b/zhenxun/services/llm/adapters/__init__.py index 93ed9d31..773d3ed2 100644 --- a/zhenxun/services/llm/adapters/__init__.py +++ b/zhenxun/services/llm/adapters/__init__.py @@ -8,7 +8,6 @@ from .base import BaseAdapter, OpenAICompatAdapter, RequestData, ResponseData from .factory import LLMAdapterFactory, get_adapter_for_api_type, register_adapter from .gemini import GeminiAdapter from .openai import OpenAIAdapter -from .zhipu import ZhipuAdapter LLMAdapterFactory.initialize() @@ -20,7 +19,6 @@ __all__ = [ "OpenAICompatAdapter", "RequestData", "ResponseData", - "ZhipuAdapter", "get_adapter_for_api_type", "register_adapter", ] diff --git a/zhenxun/services/llm/adapters/base.py b/zhenxun/services/llm/adapters/base.py index 499f9248..60258f7c 100644 --- a/zhenxun/services/llm/adapters/base.py +++ b/zhenxun/services/llm/adapters/base.py @@ -17,6 +17,7 @@ if TYPE_CHECKING: from ..service import LLMModel from ..types.content import LLMMessage from ..types.enums import EmbeddingTaskType + from ..types.models import LLMTool class RequestData(BaseModel): @@ -60,7 +61,7 @@ class BaseAdapter(ABC): """支持的API类型列表""" pass - def prepare_simple_request( + async def prepare_simple_request( self, model: "LLMModel", api_key: str, @@ -86,7 +87,7 @@ class BaseAdapter(ABC): config = model._generation_config - return self.prepare_advanced_request( + return await self.prepare_advanced_request( model=model, api_key=api_key, messages=messages, @@ -96,13 +97,13 @@ class BaseAdapter(ABC): ) @abstractmethod - def prepare_advanced_request( + async def prepare_advanced_request( self, model: "LLMModel", api_key: str, messages: list["LLMMessage"], config: "LLMGenerationConfig | None" = None, - tools: list[dict[str, Any]] | None = None, + tools: list["LLMTool"] | None = None, tool_choice: str | dict[str, Any] | None = None, ) -> RequestData: """准备高级请求""" @@ -238,6 +239,9 @@ class BaseAdapter(ABC): message = choice.get("message", {}) content = message.get("content", "") + if content: + content = content.strip() + parsed_tool_calls: list[LLMToolCall] | None = None if message_tool_calls := message.get("tool_calls"): from ..types.models import LLMToolFunction @@ -375,7 +379,7 @@ class BaseAdapter(ABC): if model.temperature is not None: base_config["temperature"] = model.temperature if model.max_tokens is not None: - if model.api_type in ["gemini", "gemini_native"]: + if model.api_type == "gemini": base_config["maxOutputTokens"] = model.max_tokens else: base_config["max_tokens"] = model.max_tokens @@ -401,26 +405,51 @@ class OpenAICompatAdapter(BaseAdapter): """ @abstractmethod - def get_chat_endpoint(self) -> str: + def get_chat_endpoint(self, model: "LLMModel") -> str: """子类必须实现,返回 chat completions 的端点""" pass @abstractmethod - def get_embedding_endpoint(self) -> str: + def get_embedding_endpoint(self, model: "LLMModel") -> str: """子类必须实现,返回 embeddings 的端点""" pass - def prepare_advanced_request( + async def prepare_simple_request( + self, + model: "LLMModel", + api_key: str, + prompt: str, + history: list[dict[str, str]] | None = None, + ) -> RequestData: + """准备简单文本生成请求 - OpenAI兼容API的通用实现""" + url = self.get_api_url(model, self.get_chat_endpoint(model)) + headers = self.get_base_headers(api_key) + + messages = [] + if history: + messages.extend(history) + messages.append({"role": "user", "content": prompt}) + + body = { + "model": model.model_name, + "messages": messages, + } + + body = self.apply_config_override(model, body) + + return RequestData(url=url, headers=headers, body=body) + + async def prepare_advanced_request( self, model: "LLMModel", api_key: str, messages: list["LLMMessage"], config: "LLMGenerationConfig | None" = None, - tools: list[dict[str, Any]] | None = None, + tools: list["LLMTool"] | None = None, tool_choice: str | dict[str, Any] | None = None, ) -> RequestData: """准备高级请求 - OpenAI兼容格式""" - url = self.get_api_url(model, self.get_chat_endpoint()) + url = self.get_api_url(model, self.get_chat_endpoint(model)) headers = self.get_base_headers(api_key) openai_messages = self.convert_messages_to_openai_format(messages) @@ -430,7 +459,21 @@ class OpenAICompatAdapter(BaseAdapter): } if tools: - body["tools"] = tools + openai_tools = [] + for tool in tools: + if tool.type == "function" and tool.function: + openai_tools.append({"type": "function", "function": tool.function}) + elif tool.type == "mcp" and tool.mcp_session: + if callable(tool.mcp_session): + raise ValueError( + "适配器接收到未激活的 MCP 会话工厂。" + "会话工厂应该在 LLMModel.generate_response 中被激活。" + ) + openai_tools.append( + tool.mcp_session.to_api_tool(api_type=self.api_type) + ) + if openai_tools: + body["tools"] = openai_tools if tool_choice: body["tool_choice"] = tool_choice @@ -444,7 +487,7 @@ class OpenAICompatAdapter(BaseAdapter): is_advanced: bool = False, ) -> ResponseData: """解析响应 - 直接使用基类的 OpenAI 格式解析""" - _ = model, is_advanced # 未使用的参数 + _ = model, is_advanced return self.parse_openai_response(response_json) def prepare_embedding_request( @@ -456,8 +499,8 @@ class OpenAICompatAdapter(BaseAdapter): **kwargs: Any, ) -> RequestData: """准备嵌入请求 - OpenAI兼容格式""" - _ = task_type # 未使用的参数 - url = self.get_api_url(model, self.get_embedding_endpoint()) + _ = task_type + url = self.get_api_url(model, self.get_embedding_endpoint(model)) headers = self.get_base_headers(api_key) body = { @@ -465,7 +508,6 @@ class OpenAICompatAdapter(BaseAdapter): "input": texts, } - # 应用额外的配置参数 if kwargs: body.update(kwargs) diff --git a/zhenxun/services/llm/adapters/factory.py b/zhenxun/services/llm/adapters/factory.py index 8652fc67..9f2a8b64 100644 --- a/zhenxun/services/llm/adapters/factory.py +++ b/zhenxun/services/llm/adapters/factory.py @@ -22,10 +22,8 @@ class LLMAdapterFactory: from .gemini import GeminiAdapter from .openai import OpenAIAdapter - from .zhipu import ZhipuAdapter cls.register_adapter(OpenAIAdapter()) - cls.register_adapter(ZhipuAdapter()) cls.register_adapter(GeminiAdapter()) @classmethod diff --git a/zhenxun/services/llm/adapters/gemini.py b/zhenxun/services/llm/adapters/gemini.py index 0ca22185..3e614d3f 100644 --- a/zhenxun/services/llm/adapters/gemini.py +++ b/zhenxun/services/llm/adapters/gemini.py @@ -14,7 +14,7 @@ if TYPE_CHECKING: from ..service import LLMModel from ..types.content import LLMMessage from ..types.enums import EmbeddingTaskType - from ..types.models import LLMToolCall + from ..types.models import LLMTool, LLMToolCall class GeminiAdapter(BaseAdapter): @@ -38,30 +38,16 @@ class GeminiAdapter(BaseAdapter): return headers - def prepare_advanced_request( + async def prepare_advanced_request( self, model: "LLMModel", api_key: str, messages: list["LLMMessage"], config: "LLMGenerationConfig | None" = None, - tools: list[dict[str, Any]] | None = None, + tools: list["LLMTool"] | None = None, tool_choice: str | dict[str, Any] | None = None, ) -> RequestData: """准备高级请求""" - return self._prepare_request( - model, api_key, messages, config, tools, tool_choice - ) - - def _prepare_request( - self, - model: "LLMModel", - api_key: str, - messages: list["LLMMessage"], - config: "LLMGenerationConfig | None" = None, - tools: list[dict[str, Any]] | None = None, - tool_choice: str | dict[str, Any] | None = None, - ) -> RequestData: - """准备 Gemini API 请求 - 支持所有高级功能""" effective_config = config if config is not None else model._generation_config endpoint = self._get_gemini_endpoint(model, effective_config) @@ -78,7 +64,8 @@ class GeminiAdapter(BaseAdapter): system_instruction_parts = [{"text": msg.content}] elif isinstance(msg.content, list): system_instruction_parts = [ - part.convert_for_api("gemini") for part in msg.content + await part.convert_for_api_async("gemini") + for part in msg.content ] continue @@ -87,7 +74,9 @@ class GeminiAdapter(BaseAdapter): current_parts.append({"text": msg.content}) elif isinstance(msg.content, list): for part_obj in msg.content: - current_parts.append(part_obj.convert_for_api("gemini")) + current_parts.append( + await part_obj.convert_for_api_async("gemini") + ) gemini_contents.append({"role": "user", "parts": current_parts}) elif msg.role == "assistant" or msg.role == "model": @@ -95,7 +84,9 @@ class GeminiAdapter(BaseAdapter): current_parts.append({"text": msg.content}) elif isinstance(msg.content, list): for part_obj in msg.content: - current_parts.append(part_obj.convert_for_api("gemini")) + current_parts.append( + await part_obj.convert_for_api_async("gemini") + ) if msg.tool_calls: import json @@ -154,16 +145,22 @@ class GeminiAdapter(BaseAdapter): all_tools_for_request = [] if tools: - for tool_item in tools: - if isinstance(tool_item, dict): - if "name" in tool_item and "description" in tool_item: - all_tools_for_request.append( - {"functionDeclarations": [tool_item]} + for tool in tools: + if tool.type == "function" and tool.function: + all_tools_for_request.append( + {"functionDeclarations": [tool.function]} + ) + elif tool.type == "mcp" and tool.mcp_session: + if callable(tool.mcp_session): + raise ValueError( + "适配器接收到未激活的 MCP 会话工厂。" + "会话工厂应该在 LLMModel.generate_response 中被激活。" ) - else: - all_tools_for_request.append(tool_item) - else: - all_tools_for_request.append(tool_item) + all_tools_for_request.append( + tool.mcp_session.to_api_tool(api_type=self.api_type) + ) + elif tool.type == "google_search": + all_tools_for_request.append({"googleSearch": {}}) if effective_config: if getattr(effective_config, "enable_grounding", False): @@ -183,11 +180,7 @@ class GeminiAdapter(BaseAdapter): logger.debug("隐式启用代码执行工具。") if all_tools_for_request: - gemini_api_tools = self._convert_tools_to_gemini_format( - all_tools_for_request - ) - if gemini_api_tools: - body["tools"] = gemini_api_tools + body["tools"] = all_tools_for_request final_tool_choice = tool_choice if final_tool_choice is None and effective_config: @@ -241,38 +234,6 @@ class GeminiAdapter(BaseAdapter): return f"/v1beta/models/{model.model_name}:generateContent" - def _convert_tools_to_gemini_format( - self, tools: list[dict[str, Any]] - ) -> list[dict[str, Any]]: - """转换工具格式为Gemini格式""" - gemini_tools = [] - - for tool in tools: - if tool.get("type") == "function": - func = tool["function"] - gemini_tool = { - "functionDeclarations": [ - { - "name": func["name"], - "description": func.get("description", ""), - "parameters": func.get("parameters", {}), - } - ] - } - gemini_tools.append(gemini_tool) - elif tool.get("type") == "code_execution": - gemini_tools.append( - {"codeExecution": {"language": tool.get("language", "python")}} - ) - elif tool.get("type") == "google_search": - gemini_tools.append({"googleSearch": {}}) - elif "googleSearch" in tool: - gemini_tools.append({"googleSearch": tool["googleSearch"]}) - elif "codeExecution" in tool: - gemini_tools.append({"codeExecution": tool["codeExecution"]}) - - return gemini_tools - def _convert_tool_choice_to_gemini( self, tool_choice_value: str | dict[str, Any] ) -> dict[str, Any]: @@ -395,10 +356,11 @@ class GeminiAdapter(BaseAdapter): for category, threshold in custom_safety_settings.items(): safety_settings.append({"category": category, "threshold": threshold}) else: + from ..config.providers import get_gemini_safety_threshold + + threshold = get_gemini_safety_threshold() for category in safety_categories: - safety_settings.append( - {"category": category, "threshold": "BLOCK_MEDIUM_AND_ABOVE"} - ) + safety_settings.append({"category": category, "threshold": threshold}) return safety_settings if safety_settings else None diff --git a/zhenxun/services/llm/adapters/openai.py b/zhenxun/services/llm/adapters/openai.py index 046f0277..c7e73a13 100644 --- a/zhenxun/services/llm/adapters/openai.py +++ b/zhenxun/services/llm/adapters/openai.py @@ -1,12 +1,12 @@ """ OpenAI API 适配器 -支持 OpenAI、DeepSeek 和其他 OpenAI 兼容的 API 服务。 +支持 OpenAI、DeepSeek、智谱AI 和其他 OpenAI 兼容的 API 服务。 """ from typing import TYPE_CHECKING -from .base import OpenAICompatAdapter, RequestData +from .base import OpenAICompatAdapter if TYPE_CHECKING: from ..service import LLMModel @@ -21,37 +21,18 @@ class OpenAIAdapter(OpenAICompatAdapter): @property def supported_api_types(self) -> list[str]: - return ["openai", "deepseek", "general_openai_compat"] + return ["openai", "deepseek", "zhipu", "general_openai_compat", "ark"] - def get_chat_endpoint(self) -> str: + def get_chat_endpoint(self, model: "LLMModel") -> str: """返回聊天完成端点""" + if model.api_type == "ark": + return "/api/v3/chat/completions" + if model.api_type == "zhipu": + return "/api/paas/v4/chat/completions" return "/v1/chat/completions" - def get_embedding_endpoint(self) -> str: - """返回嵌入端点""" + def get_embedding_endpoint(self, model: "LLMModel") -> str: + """根据API类型返回嵌入端点""" + if model.api_type == "zhipu": + return "/v4/embeddings" return "/v1/embeddings" - - def prepare_simple_request( - self, - model: "LLMModel", - api_key: str, - prompt: str, - history: list[dict[str, str]] | None = None, - ) -> RequestData: - """准备简单文本生成请求 - OpenAI优化实现""" - url = self.get_api_url(model, self.get_chat_endpoint()) - headers = self.get_base_headers(api_key) - - messages = [] - if history: - messages.extend(history) - messages.append({"role": "user", "content": prompt}) - - body = { - "model": model.model_name, - "messages": messages, - } - - body = self.apply_config_override(model, body) - - return RequestData(url=url, headers=headers, body=body) diff --git a/zhenxun/services/llm/adapters/zhipu.py b/zhenxun/services/llm/adapters/zhipu.py deleted file mode 100644 index e5eb032f..00000000 --- a/zhenxun/services/llm/adapters/zhipu.py +++ /dev/null @@ -1,57 +0,0 @@ -""" -智谱 AI API 适配器 - -支持智谱 AI 的 GLM 系列模型,使用 OpenAI 兼容的接口格式。 -""" - -from typing import TYPE_CHECKING - -from .base import OpenAICompatAdapter, RequestData - -if TYPE_CHECKING: - from ..service import LLMModel - - -class ZhipuAdapter(OpenAICompatAdapter): - """智谱AI适配器 - 使用智谱AI专用的OpenAI兼容接口""" - - @property - def api_type(self) -> str: - return "zhipu" - - @property - def supported_api_types(self) -> list[str]: - return ["zhipu"] - - def get_chat_endpoint(self) -> str: - """返回智谱AI聊天完成端点""" - return "/api/paas/v4/chat/completions" - - def get_embedding_endpoint(self) -> str: - """返回智谱AI嵌入端点""" - return "/v4/embeddings" - - def prepare_simple_request( - self, - model: "LLMModel", - api_key: str, - prompt: str, - history: list[dict[str, str]] | None = None, - ) -> RequestData: - """准备简单文本生成请求 - 智谱AI优化实现""" - url = self.get_api_url(model, self.get_chat_endpoint()) - headers = self.get_base_headers(api_key) - - messages = [] - if history: - messages.extend(history) - messages.append({"role": "user", "content": prompt}) - - body = { - "model": model.model_name, - "messages": messages, - } - - body = self.apply_config_override(model, body) - - return RequestData(url=url, headers=headers, body=body) diff --git a/zhenxun/services/llm/api.py b/zhenxun/services/llm/api.py index 7aaed437..d9606f80 100644 --- a/zhenxun/services/llm/api.py +++ b/zhenxun/services/llm/api.py @@ -2,6 +2,7 @@ LLM 服务的高级 API 接口 """ +import copy from dataclasses import dataclass from enum import Enum from pathlib import Path @@ -14,6 +15,7 @@ from zhenxun.services.log import logger from .config import CommonOverrides, LLMGenerationConfig from .config.providers import get_ai_config from .manager import get_global_default_model_name, get_model_instance +from .tools import tool_registry from .types import ( EmbeddingTaskType, LLMContentPart, @@ -56,6 +58,7 @@ class AIConfig: enable_gemini_safe_mode: bool = False enable_gemini_multimodal: bool = False enable_gemini_grounding: bool = False + default_preserve_media_in_history: bool = False def __post_init__(self): """初始化后从配置中读取默认值""" @@ -81,7 +84,7 @@ class AI: """ 初始化AI服务 - Args: + 参数: config: AI 配置. history: 可选的初始对话历史. """ @@ -93,16 +96,65 @@ class AI: self.history = [] logger.info("AI session history cleared.") + def _sanitize_message_for_history(self, message: LLMMessage) -> LLMMessage: + """ + 净化用于存入历史记录的消息。 + 将非文本的多模态内容部分替换为文本占位符,以避免重复处理。 + """ + if not isinstance(message.content, list): + return message + + sanitized_message = copy.deepcopy(message) + content_list = sanitized_message.content + if not isinstance(content_list, list): + return sanitized_message + + new_content_parts: list[LLMContentPart] = [] + has_multimodal_content = False + + for part in content_list: + if isinstance(part, LLMContentPart) and part.type == "text": + new_content_parts.append(part) + else: + has_multimodal_content = True + + if has_multimodal_content: + placeholder = "[用户发送了媒体文件,内容已在首次分析时处理]" + text_part_found = False + for part in new_content_parts: + if part.type == "text": + part.text = f"{placeholder} {part.text or ''}".strip() + text_part_found = True + break + if not text_part_found: + new_content_parts.insert(0, LLMContentPart.text_part(placeholder)) + + sanitized_message.content = new_content_parts + return sanitized_message + async def chat( self, message: str | LLMMessage | list[LLMContentPart], *, model: ModelName = None, + preserve_media_in_history: bool | None = None, **kwargs: Any, ) -> str: """ 进行一次聊天对话。 此方法会自动使用和更新会话内的历史记录。 + + 参数: + message: 用户输入的消息。 + model: 本次对话要使用的模型。 + preserve_media_in_history: 是否在历史记录中保留原始多模态信息。 + - True: 保留,用于深度多轮媒体分析。 + - False: 不保留,替换为占位符,提高效率。 + - None (默认): 使用AI实例配置的默认值。 + **kwargs: 传递给模型的其他参数。 + + 返回: + str: 模型的文本响应。 """ current_message: LLMMessage if isinstance(message, str): @@ -127,7 +179,20 @@ class AI: final_messages, model, "聊天失败", kwargs ) - self.history.append(current_message) + should_preserve = ( + preserve_media_in_history + if preserve_media_in_history is not None + else self.config.default_preserve_media_in_history + ) + + if should_preserve: + logger.debug("深度分析模式:在历史记录中保留原始多模态消息。") + self.history.append(current_message) + else: + logger.debug("高效模式:净化历史记录中的多模态消息。") + sanitized_user_message = self._sanitize_message_for_history(current_message) + self.history.append(sanitized_user_message) + self.history.append(LLMMessage.assistant_text_response(response.text)) return response.text @@ -140,7 +205,18 @@ class AI: timeout: int | None = None, **kwargs: Any, ) -> dict[str, Any]: - """代码执行""" + """ + 代码执行 + + 参数: + prompt: 代码执行的提示词。 + model: 要使用的模型名称。 + timeout: 代码执行超时时间(秒)。 + **kwargs: 传递给模型的其他参数。 + + 返回: + dict[str, Any]: 包含执行结果的字典,包含text、code_executions和success字段。 + """ resolved_model = model or self.config.model or "Gemini/gemini-2.0-flash" config = CommonOverrides.gemini_code_execution() @@ -168,7 +244,18 @@ class AI: instruction: str = "", **kwargs: Any, ) -> dict[str, Any]: - """信息搜索 - 支持多模态输入""" + """ + 信息搜索 - 支持多模态输入 + + 参数: + query: 搜索查询内容,支持文本或多模态消息。 + model: 要使用的模型名称。 + instruction: 搜索指令。 + **kwargs: 传递给模型的其他参数。 + + 返回: + dict[str, Any]: 包含搜索结果的字典,包含text、sources、queries和success字段 + """ resolved_model = model or self.config.model or "Gemini/gemini-2.0-flash" config = CommonOverrides.gemini_grounding() @@ -217,63 +304,69 @@ class AI: async def analyze( self, - message: UniMessage, + message: UniMessage | None, *, instruction: str = "", model: ModelName = None, - tools: list[dict[str, Any]] | None = None, + use_tools: list[str] | None = None, tool_config: dict[str, Any] | None = None, + activated_tools: list[LLMTool] | None = None, + history: list[LLMMessage] | None = None, **kwargs: Any, - ) -> str | LLMResponse: + ) -> LLMResponse: """ 内容分析 - 接收 UniMessage 物件进行多模态分析和工具呼叫。 - 这是处理复杂互动的主要方法。 + + 参数: + message: 要分析的消息内容(支持多模态)。 + instruction: 分析指令。 + model: 要使用的模型名称。 + use_tools: 要使用的工具名称列表。 + tool_config: 工具配置。 + activated_tools: 已激活的工具列表。 + history: 对话历史记录。 + **kwargs: 传递给模型的其他参数。 + + 返回: + LLMResponse: 模型的完整响应结果。 """ - content_parts = await unimsg_to_llm_parts(message) + content_parts = await unimsg_to_llm_parts(message or UniMessage()) final_messages: list[LLMMessage] = [] + if history: + final_messages.extend(history) + if instruction: - final_messages.append(LLMMessage.system(instruction)) + if not any(msg.role == "system" for msg in final_messages): + final_messages.insert(0, LLMMessage.system(instruction)) if not content_parts: - if instruction: + if instruction and not history: final_messages.append(LLMMessage.user(instruction)) - else: + elif not history: raise LLMException( "分析内容为空或无法处理。", code=LLMErrorCode.API_REQUEST_FAILED ) else: final_messages.append(LLMMessage.user(content_parts)) - llm_tools = None - if tools: - llm_tools = [] - for tool_dict in tools: - if isinstance(tool_dict, dict): - if "name" in tool_dict and "description" in tool_dict: - llm_tool = LLMTool( - type="function", - function={ - "name": tool_dict["name"], - "description": tool_dict["description"], - "parameters": tool_dict.get("parameters", {}), - }, - ) - llm_tools.append(llm_tool) - else: - llm_tools.append(LLMTool(**tool_dict)) - else: - llm_tools.append(tool_dict) + llm_tools: list[LLMTool] | None = activated_tools + if not llm_tools and use_tools: + try: + llm_tools = tool_registry.get_tools(use_tools) + logger.debug(f"已从注册表加载工具定义: {use_tools}") + except ValueError as e: + raise LLMException( + f"加载工具定义失败: {e}", + code=LLMErrorCode.CONFIGURATION_ERROR, + cause=e, + ) tool_choice = None if tool_config: mode = tool_config.get("mode", "auto") - if mode == "auto": - tool_choice = "auto" - elif mode == "any": - tool_choice = "any" - elif mode == "none": - tool_choice = "none" + if mode in ["auto", "any", "none"]: + tool_choice = mode response = await self._execute_generation( final_messages, @@ -284,9 +377,7 @@ class AI: tool_choice=tool_choice, ) - if response.tool_calls: - return response - return response.text + return response async def _execute_generation( self, @@ -298,7 +389,7 @@ class AI: tool_choice: str | dict[str, Any] | None = None, base_config: LLMGenerationConfig | None = None, ) -> LLMResponse: - """通用的生成执行方法,封装重复的模型获取、配置合并和异常处理逻辑""" + """通用的生成执行方法,封装模型获取和单次API调用""" try: resolved_model_name = self._resolve_model_name( model_name or self.config.model @@ -311,7 +402,9 @@ class AI: resolved_model_name, override_config=final_config_dict ) as model_instance: return await model_instance.generate_response( - messages, tools=llm_tools, tool_choice=tool_choice + messages, + tools=llm_tools, + tool_choice=tool_choice, ) except LLMException: raise @@ -380,7 +473,18 @@ class AI: task_type: EmbeddingTaskType | str = EmbeddingTaskType.RETRIEVAL_DOCUMENT, **kwargs: Any, ) -> list[list[float]]: - """生成文本嵌入向量""" + """ + 生成文本嵌入向量 + + 参数: + texts: 要生成嵌入向量的文本或文本列表。 + model: 要使用的嵌入模型名称。 + task_type: 嵌入任务类型。 + **kwargs: 传递给模型的其他参数。 + + 返回: + list[list[float]]: 文本的嵌入向量列表。 + """ if isinstance(texts, str): texts = [texts] if not texts: @@ -420,7 +524,17 @@ async def chat( model: ModelName = None, **kwargs: Any, ) -> str: - """聊天对话便捷函数""" + """ + 聊天对话便捷函数 + + 参数: + message: 用户输入的消息。 + model: 要使用的模型名称。 + **kwargs: 传递给模型的其他参数。 + + 返回: + str: 模型的文本响应。 + """ ai = AI() return await ai.chat(message, model=model, **kwargs) @@ -432,7 +546,18 @@ async def code( timeout: int | None = None, **kwargs: Any, ) -> dict[str, Any]: - """代码执行便捷函数""" + """ + 代码执行便捷函数 + + 参数: + prompt: 代码执行的提示词。 + model: 要使用的模型名称。 + timeout: 代码执行超时时间(秒)。 + **kwargs: 传递给模型的其他参数。 + + 返回: + dict[str, Any]: 包含执行结果的字典。 + """ ai = AI() return await ai.code(prompt, model=model, timeout=timeout, **kwargs) @@ -444,45 +569,56 @@ async def search( instruction: str = "", **kwargs: Any, ) -> dict[str, Any]: - """信息搜索便捷函数""" + """ + 信息搜索便捷函数 + + 参数: + query: 搜索查询内容。 + model: 要使用的模型名称。 + instruction: 搜索指令。 + **kwargs: 传递给模型的其他参数。 + + 返回: + dict[str, Any]: 包含搜索结果的字典。 + """ ai = AI() return await ai.search(query, model=model, instruction=instruction, **kwargs) async def analyze( - message: UniMessage, + message: UniMessage | None, *, instruction: str = "", model: ModelName = None, - tools: list[dict[str, Any]] | None = None, + use_tools: list[str] | None = None, tool_config: dict[str, Any] | None = None, **kwargs: Any, ) -> str | LLMResponse: - """内容分析便捷函数""" + """ + 内容分析便捷函数 + + 参数: + message: 要分析的消息内容。 + instruction: 分析指令。 + model: 要使用的模型名称。 + use_tools: 要使用的工具名称列表。 + tool_config: 工具配置。 + **kwargs: 传递给模型的其他参数。 + + 返回: + str | LLMResponse: 分析结果。 + """ ai = AI() return await ai.analyze( message, instruction=instruction, model=model, - tools=tools, + use_tools=use_tools, tool_config=tool_config, **kwargs, ) -async def analyze_with_images( - text: str, - images: list[str | Path | bytes] | str | Path | bytes, - *, - instruction: str = "", - model: ModelName = None, - **kwargs: Any, -) -> str | LLMResponse: - """图片分析便捷函数""" - message = create_multimodal_message(text=text, images=images) - return await analyze(message, instruction=instruction, model=model, **kwargs) - - async def analyze_multimodal( text: str | None = None, images: list[str | Path | bytes] | str | Path | bytes | None = None, @@ -493,7 +629,21 @@ async def analyze_multimodal( model: ModelName = None, **kwargs: Any, ) -> str | LLMResponse: - """多模态分析便捷函数""" + """ + 多模态分析便捷函数 + + 参数: + text: 文本内容。 + images: 图片文件路径、字节数据或列表。 + videos: 视频文件路径、字节数据或列表。 + audios: 音频文件路径、字节数据或列表。 + instruction: 分析指令。 + model: 要使用的模型名称。 + **kwargs: 传递给模型的其他参数。 + + 返回: + str | LLMResponse: 分析结果。 + """ message = create_multimodal_message( text=text, images=images, videos=videos, audios=audios ) @@ -510,7 +660,21 @@ async def search_multimodal( model: ModelName = None, **kwargs: Any, ) -> dict[str, Any]: - """多模态搜索便捷函数""" + """ + 多模态搜索便捷函数 + + 参数: + text: 文本内容。 + images: 图片文件路径、字节数据或列表。 + videos: 视频文件路径、字节数据或列表。 + audios: 音频文件路径、字节数据或列表。 + instruction: 搜索指令。 + model: 要使用的模型名称。 + **kwargs: 传递给模型的其他参数。 + + 返回: + dict[str, Any]: 包含搜索结果的字典。 + """ message = create_multimodal_message( text=text, images=images, videos=videos, audios=audios ) @@ -525,6 +689,101 @@ async def embed( task_type: EmbeddingTaskType | str = EmbeddingTaskType.RETRIEVAL_DOCUMENT, **kwargs: Any, ) -> list[list[float]]: - """文本嵌入便捷函数""" + """ + 文本嵌入便捷函数 + + 参数: + texts: 要生成嵌入向量的文本或文本列表。 + model: 要使用的嵌入模型名称。 + task_type: 嵌入任务类型。 + **kwargs: 传递给模型的其他参数。 + + 返回: + list[list[float]]: 文本的嵌入向量列表。 + """ ai = AI() return await ai.embed(texts, model=model, task_type=task_type, **kwargs) + + +async def pipeline_chat( + message: UniMessage | str | list[LLMContentPart], + model_chain: list[ModelName], + *, + initial_instruction: str = "", + final_instruction: str = "", + **kwargs: Any, +) -> LLMResponse: + """ + AI模型链式调用,前一个模型的输出作为下一个模型的输入。 + + 参数: + message: 初始输入消息(支持多模态) + model_chain: 模型名称列表 + initial_instruction: 第一个模型的系统指令 + final_instruction: 最后一个模型的系统指令 + **kwargs: 传递给模型实例的其他参数 + + 返回: + LLMResponse: 最后一个模型的响应结果 + """ + if not model_chain: + raise ValueError("模型链`model_chain`不能为空。") + + current_content: str | list[LLMContentPart] + if isinstance(message, str): + current_content = message + elif isinstance(message, list): + current_content = message + else: + current_content = await unimsg_to_llm_parts(message) + + final_response: LLMResponse | None = None + + for i, model_name in enumerate(model_chain): + if not model_name: + raise ValueError(f"模型链中第 {i + 1} 个模型名称为空。") + + is_first_step = i == 0 + is_last_step = i == len(model_chain) - 1 + + messages_for_step: list[LLMMessage] = [] + instruction_for_step = "" + if is_first_step and initial_instruction: + instruction_for_step = initial_instruction + elif is_last_step and final_instruction: + instruction_for_step = final_instruction + + if instruction_for_step: + messages_for_step.append(LLMMessage.system(instruction_for_step)) + + messages_for_step.append(LLMMessage.user(current_content)) + + logger.info( + f"Pipeline Step [{i + 1}/{len(model_chain)}]: " + f"使用模型 '{model_name}' 进行处理..." + ) + try: + async with await get_model_instance(model_name, **kwargs) as model: + response = await model.generate_response(messages_for_step) + final_response = response + current_content = response.text.strip() + if not current_content and not is_last_step: + logger.warning( + f"模型 '{model_name}' 在中间步骤返回了空内容,流水线可能无法继续。" + ) + break + + except Exception as e: + logger.error(f"在模型链的第 {i + 1} 步 ('{model_name}') 出错: {e}", e=e) + raise LLMException( + f"流水线在模型 '{model_name}' 处执行失败: {e}", + code=LLMErrorCode.GENERATION_FAILED, + cause=e, + ) + + if final_response is None: + raise LLMException( + "AI流水线未能产生任何响应。", code=LLMErrorCode.GENERATION_FAILED + ) + + return final_response diff --git a/zhenxun/services/llm/config/__init__.py b/zhenxun/services/llm/config/__init__.py index 09fd9599..41021a92 100644 --- a/zhenxun/services/llm/config/__init__.py +++ b/zhenxun/services/llm/config/__init__.py @@ -14,6 +14,8 @@ from .generation import ( from .presets import CommonOverrides from .providers import ( LLMConfig, + ToolConfig, + get_gemini_safety_threshold, get_llm_config, register_llm_configs, set_default_model, @@ -25,8 +27,10 @@ __all__ = [ "LLMConfig", "LLMGenerationConfig", "ModelConfigOverride", + "ToolConfig", "apply_api_specific_mappings", "create_generation_config_from_kwargs", + "get_gemini_safety_threshold", "get_llm_config", "register_llm_configs", "set_default_model", diff --git a/zhenxun/services/llm/config/generation.py b/zhenxun/services/llm/config/generation.py index a143dedd..a452ae1f 100644 --- a/zhenxun/services/llm/config/generation.py +++ b/zhenxun/services/llm/config/generation.py @@ -111,12 +111,12 @@ class LLMGenerationConfig(ModelConfigOverride): params["temperature"] = self.temperature if self.max_tokens is not None: - if api_type in ["gemini", "gemini_native"]: + if api_type == "gemini": params["maxOutputTokens"] = self.max_tokens else: params["max_tokens"] = self.max_tokens - if api_type in ["gemini", "gemini_native"]: + if api_type == "gemini": if self.top_k is not None: params["topK"] = self.top_k if self.top_p is not None: @@ -151,13 +151,13 @@ class LLMGenerationConfig(ModelConfigOverride): if api_type in ["openai", "zhipu", "deepseek", "general_openai_compat"]: params["response_format"] = {"type": "json_object"} logger.debug(f"为 {api_type} 启用 JSON 对象输出模式") - elif api_type in ["gemini", "gemini_native"]: + elif api_type == "gemini": params["responseMimeType"] = "application/json" if self.response_schema: params["responseSchema"] = self.response_schema logger.debug(f"为 {api_type} 启用 JSON MIME 类型输出模式") - if api_type in ["gemini", "gemini_native"]: + if api_type == "gemini": if ( self.response_format != ResponseFormat.JSON and self.response_mime_type is not None @@ -214,7 +214,7 @@ def apply_api_specific_mappings( """应用API特定的参数映射""" mapped_params = params.copy() - if api_type in ["gemini", "gemini_native"]: + if api_type == "gemini": if "max_tokens" in mapped_params: mapped_params["maxOutputTokens"] = mapped_params.pop("max_tokens") if "top_k" in mapped_params: diff --git a/zhenxun/services/llm/config/presets.py b/zhenxun/services/llm/config/presets.py index 7a6023d5..aa4b6c21 100644 --- a/zhenxun/services/llm/config/presets.py +++ b/zhenxun/services/llm/config/presets.py @@ -71,14 +71,17 @@ class CommonOverrides: @staticmethod def gemini_safe() -> LLMGenerationConfig: - """Gemini 安全模式:严格安全设置""" + """Gemini 安全模式:使用配置的安全设置""" + from .providers import get_gemini_safety_threshold + + threshold = get_gemini_safety_threshold() return LLMGenerationConfig( temperature=0.5, safety_settings={ - "HARM_CATEGORY_HARASSMENT": "BLOCK_MEDIUM_AND_ABOVE", - "HARM_CATEGORY_HATE_SPEECH": "BLOCK_MEDIUM_AND_ABOVE", - "HARM_CATEGORY_SEXUALLY_EXPLICIT": "BLOCK_MEDIUM_AND_ABOVE", - "HARM_CATEGORY_DANGEROUS_CONTENT": "BLOCK_MEDIUM_AND_ABOVE", + "HARM_CATEGORY_HARASSMENT": threshold, + "HARM_CATEGORY_HATE_SPEECH": threshold, + "HARM_CATEGORY_SEXUALLY_EXPLICIT": threshold, + "HARM_CATEGORY_DANGEROUS_CONTENT": threshold, }, ) diff --git a/zhenxun/services/llm/config/providers.py b/zhenxun/services/llm/config/providers.py index 8f4dea80..a39e32c9 100644 --- a/zhenxun/services/llm/config/providers.py +++ b/zhenxun/services/llm/config/providers.py @@ -4,15 +4,33 @@ LLM 提供商配置管理 负责注册和管理 AI 服务提供商的配置项。 """ +from functools import lru_cache +import json +import sys from typing import Any from pydantic import BaseModel, Field from zhenxun.configs.config import Config +from zhenxun.configs.path_config import DATA_PATH +from zhenxun.configs.utils import parse_as from zhenxun.services.log import logger +from zhenxun.utils.manager.priority_manager import PriorityLifecycle from ..types.models import ModelDetail, ProviderConfig + +class ToolConfig(BaseModel): + """MCP类型工具的配置定义""" + + type: str = "mcp" + name: str = Field(..., description="工具的唯一名称标识") + description: str | None = Field(None, description="工具功能的描述") + mcp_config: dict[str, Any] | BaseModel = Field( + ..., description="MCP服务器的特定配置" + ) + + AI_CONFIG_GROUP = "AI" PROVIDERS_CONFIG_KEY = "PROVIDERS" @@ -38,6 +56,9 @@ class LLMConfig(BaseModel): providers: list[ProviderConfig] = Field( default_factory=list, description="配置多个 AI 服务提供商及其模型信息" ) + mcp_tools: list[ToolConfig] = Field( + default_factory=list, description="配置可用的外部MCP工具" + ) def get_provider_by_name(self, name: str) -> ProviderConfig | None: """根据名称获取提供商配置 @@ -132,7 +153,7 @@ def get_default_providers() -> list[dict[str, Any]]: return [ { "name": "DeepSeek", - "api_key": "sk-******", + "api_key": "YOUR_ARK_API_KEY", "api_base": "https://api.deepseek.com", "api_type": "openai", "models": [ @@ -146,9 +167,30 @@ def get_default_providers() -> list[dict[str, Any]]: }, ], }, + { + "name": "ARK", + "api_key": "YOUR_ARK_API_KEY", + "api_base": "https://ark.cn-beijing.volces.com", + "api_type": "ark", + "models": [ + {"model_name": "deepseek-r1-250528"}, + {"model_name": "doubao-seed-1-6-250615"}, + {"model_name": "doubao-seed-1-6-flash-250615"}, + {"model_name": "doubao-seed-1-6-thinking-250615"}, + ], + }, + { + "name": "siliconflow", + "api_key": "YOUR_ARK_API_KEY", + "api_base": "https://api.siliconflow.cn", + "api_type": "openai", + "models": [ + {"model_name": "deepseek-ai/DeepSeek-V3"}, + ], + }, { "name": "GLM", - "api_key": "", + "api_key": "YOUR_ARK_API_KEY", "api_base": "https://open.bigmodel.cn", "api_type": "zhipu", "models": [ @@ -167,12 +209,41 @@ def get_default_providers() -> list[dict[str, Any]]: "api_type": "gemini", "models": [ {"model_name": "gemini-2.0-flash"}, - {"model_name": "gemini-2.5-flash-preview-05-20"}, + {"model_name": "gemini-2.5-flash"}, + {"model_name": "gemini-2.5-pro"}, + {"model_name": "gemini-2.5-flash-lite-preview-06-17"}, ], }, ] +def get_default_mcp_tools() -> dict[str, Any]: + """ + 获取默认的MCP工具配置,用于在文件不存在时创建。 + 包含了 baidu-map, Context7, 和 sequential-thinking. + """ + return { + "mcpServers": { + "baidu-map": { + "command": "npx", + "args": ["-y", "@baidumap/mcp-server-baidu-map"], + "env": {"BAIDU_MAP_API_KEY": ""}, + "description": "百度地图工具,提供地理编码、路线规划等功能。", + }, + "sequential-thinking": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-sequential-thinking"], + "description": "顺序思维工具,用于帮助模型进行多步骤推理。", + }, + "Context7": { + "command": "npx", + "args": ["-y", "@upstash/context7-mcp@latest"], + "description": "Upstash 提供的上下文管理和记忆工具。", + }, + } + } + + def register_llm_configs(): """注册 LLM 服务的配置项""" logger.info("注册 LLM 服务的配置项") @@ -214,6 +285,19 @@ def register_llm_configs(): help="LLM服务请求重试的基础延迟时间(秒)", type=int, ) + Config.add_plugin_config( + AI_CONFIG_GROUP, + "gemini_safety_threshold", + "BLOCK_MEDIUM_AND_ABOVE", + help=( + "Gemini 安全过滤阈值 " + "(BLOCK_LOW_AND_ABOVE: 阻止低级别及以上, " + "BLOCK_MEDIUM_AND_ABOVE: 阻止中等级别及以上, " + "BLOCK_ONLY_HIGH: 只阻止高级别, " + "BLOCK_NONE: 不阻止)" + ), + type=str, + ) Config.add_plugin_config( AI_CONFIG_GROUP, @@ -225,24 +309,111 @@ def register_llm_configs(): ) +@lru_cache(maxsize=1) def get_llm_config() -> LLMConfig: - """获取 LLM 配置实例 - - 返回: - LLMConfig: LLM 配置实例 - """ + """获取 LLM 配置实例,现在会从新的 JSON 文件加载 MCP 工具""" ai_config = get_ai_config() + llm_data_path = DATA_PATH / "llm" + mcp_tools_path = llm_data_path / "mcp_tools.json" + + mcp_tools_list = [] + mcp_servers_dict = {} + + if not mcp_tools_path.exists(): + logger.info(f"未找到 MCP 工具配置文件,将在 '{mcp_tools_path}' 创建一个。") + llm_data_path.mkdir(parents=True, exist_ok=True) + default_mcp_config = get_default_mcp_tools() + try: + with mcp_tools_path.open("w", encoding="utf-8") as f: + json.dump(default_mcp_config, f, ensure_ascii=False, indent=2) + mcp_servers_dict = default_mcp_config.get("mcpServers", {}) + except Exception as e: + logger.error(f"创建默认 MCP 配置文件失败: {e}", e=e) + mcp_servers_dict = {} + else: + try: + with mcp_tools_path.open("r", encoding="utf-8") as f: + mcp_data = json.load(f) + mcp_servers_dict = mcp_data.get("mcpServers", {}) + if not isinstance(mcp_servers_dict, dict): + logger.warning( + f"'{mcp_tools_path}' 中的 'mcpServers' 键不是一个字典," + f"将使用空配置。" + ) + mcp_servers_dict = {} + + except json.JSONDecodeError as e: + logger.error(f"解析 MCP 配置文件 '{mcp_tools_path}' 失败: {e}", e=e) + except Exception as e: + logger.error(f"读取 MCP 配置文件时发生未知错误: {e}", e=e) + mcp_servers_dict = {} + + if sys.platform == "win32": + logger.debug("检测到Windows平台,正在调整MCP工具的npx命令...") + for name, config in mcp_servers_dict.items(): + if isinstance(config, dict) and config.get("command") == "npx": + logger.info(f"为工具 '{name}' 包装npx命令以兼容Windows。") + original_args = config.get("args", []) + config["command"] = "cmd" + config["args"] = ["/c", "npx", *original_args] + + if mcp_servers_dict: + mcp_tools_list = [ + { + "name": name, + "type": "mcp", + "description": config.get("description", f"MCP tool for {name}"), + "mcp_config": config, + } + for name, config in mcp_servers_dict.items() + if isinstance(config, dict) + ] + + from ..tools.registry import tool_registry + + for tool_dict in mcp_tools_list: + if isinstance(tool_dict, dict): + tool_name = tool_dict.get("name") + if not tool_name: + continue + + config_model = tool_registry.get_mcp_config_model(tool_name) + if not config_model: + logger.debug( + f"MCP工具 '{tool_name}' 没有注册其配置模型," + f"将跳过特定配置验证,直接使用原始配置字典。" + ) + continue + + mcp_config_data = tool_dict.get("mcp_config", {}) + try: + parsed_mcp_config = parse_as(config_model, mcp_config_data) + tool_dict["mcp_config"] = parsed_mcp_config + except Exception as e: + raise ValueError(f"MCP工具 '{tool_name}' 的 `mcp_config` 配置错误: {e}") + config_data = { "default_model_name": ai_config.get("default_model_name"), "proxy": ai_config.get("proxy"), "timeout": ai_config.get("timeout", 180), "max_retries_llm": ai_config.get("max_retries_llm", 3), "retry_delay_llm": ai_config.get("retry_delay_llm", 2), - "providers": ai_config.get(PROVIDERS_CONFIG_KEY, []), + PROVIDERS_CONFIG_KEY: ai_config.get(PROVIDERS_CONFIG_KEY, []), + "mcp_tools": mcp_tools_list, } - return LLMConfig(**config_data) + return parse_as(LLMConfig, config_data) + + +def get_gemini_safety_threshold() -> str: + """获取 Gemini 安全过滤阈值配置 + + 返回: + str: 安全过滤阈值 + """ + ai_config = get_ai_config() + return ai_config.get("gemini_safety_threshold", "BLOCK_MEDIUM_AND_ABOVE") def validate_llm_config() -> tuple[bool, list[str]]: @@ -326,3 +497,17 @@ def set_default_model(provider_model_name: str | None) -> bool: logger.info("默认模型已清除") return True + + +@PriorityLifecycle.on_startup(priority=10) +async def _init_llm_config_on_startup(): + """ + 在服务启动时主动调用一次 get_llm_config, + 以触发必要的初始化操作,例如创建默认的 mcp_tools.json 文件。 + """ + logger.info("正在初始化 LLM 配置并检查 MCP 工具文件...") + try: + get_llm_config() + logger.info("LLM 配置初始化完成。") + except Exception as e: + logger.error(f"LLM 配置初始化时发生错误: {e}", e=e) diff --git a/zhenxun/services/llm/core.py b/zhenxun/services/llm/core.py index ffd900cf..56591701 100644 --- a/zhenxun/services/llm/core.py +++ b/zhenxun/services/llm/core.py @@ -49,12 +49,36 @@ class LLMHttpClient: max_keepalive_connections=self.config.max_keepalive_connections, ) timeout = httpx.Timeout(self.config.timeout) + + client_kwargs = {} + if self.config.proxy: + try: + version_parts = httpx.__version__.split(".") + major = int( + "".join(c for c in version_parts[0] if c.isdigit()) + ) + minor = ( + int("".join(c for c in version_parts[1] if c.isdigit())) + if len(version_parts) > 1 + else 0 + ) + if (major, minor) >= (0, 28): + client_kwargs["proxy"] = self.config.proxy + else: + client_kwargs["proxies"] = self.config.proxy + except (ValueError, IndexError): + client_kwargs["proxies"] = self.config.proxy + logger.warning( + f"无法解析 httpx 版本 '{httpx.__version__}'," + "LLM模块将默认使用旧版 'proxies' 参数语法。" + ) + self._client = httpx.AsyncClient( headers=headers, limits=limits, timeout=timeout, - proxies=self.config.proxy, follow_redirects=True, + **client_kwargs, ) if self._client is None: raise LLMException( @@ -156,7 +180,16 @@ async def create_llm_http_client( timeout: int = 180, proxy: str | None = None, ) -> LLMHttpClient: - """创建LLM HTTP客户端""" + """ + 创建LLM HTTP客户端 + + 参数: + timeout: 超时时间(秒)。 + proxy: 代理服务器地址。 + + 返回: + LLMHttpClient: HTTP客户端实例。 + """ config = HttpClientConfig(timeout=timeout, proxy=proxy) return LLMHttpClient(config) @@ -185,7 +218,20 @@ async def with_smart_retry( provider_name: str | None = None, **kwargs: Any, ) -> Any: - """智能重试装饰器 - 支持Key轮询和错误分类""" + """ + 智能重试装饰器 - 支持Key轮询和错误分类 + + 参数: + func: 要重试的异步函数。 + *args: 传递给函数的位置参数。 + retry_config: 重试配置。 + key_store: API密钥状态存储。 + provider_name: 提供商名称。 + **kwargs: 传递给函数的关键字参数。 + + 返回: + Any: 函数执行结果。 + """ config = retry_config or RetryConfig() last_exception: Exception | None = None failed_keys: set[str] = set() @@ -294,7 +340,17 @@ class KeyStatusStore: api_keys: list[str], exclude_keys: set[str] | None = None, ) -> str | None: - """获取下一个可用的API密钥(轮询策略)""" + """ + 获取下一个可用的API密钥(轮询策略) + + 参数: + provider_name: 提供商名称。 + api_keys: API密钥列表。 + exclude_keys: 要排除的密钥集合。 + + 返回: + str | None: 可用的API密钥,如果没有可用密钥则返回None。 + """ if not api_keys: return None @@ -338,7 +394,13 @@ class KeyStatusStore: logger.debug(f"记录API密钥成功使用: {self._get_key_id(api_key)}") async def record_failure(self, api_key: str, status_code: int | None): - """记录失败使用""" + """ + 记录失败使用 + + 参数: + api_key: API密钥。 + status_code: HTTP状态码。 + """ key_id = self._get_key_id(api_key) async with self._lock: if status_code in [401, 403]: @@ -356,7 +418,15 @@ class KeyStatusStore: logger.info(f"重置API密钥状态: {self._get_key_id(api_key)}") async def get_key_stats(self, api_keys: list[str]) -> dict[str, dict]: - """获取密钥使用统计""" + """ + 获取密钥使用统计 + + 参数: + api_keys: API密钥列表。 + + 返回: + dict[str, dict]: 密钥统计信息字典。 + """ stats = {} async with self._lock: for key in api_keys: diff --git a/zhenxun/services/llm/manager.py b/zhenxun/services/llm/manager.py index f23dfa50..f0e9c560 100644 --- a/zhenxun/services/llm/manager.py +++ b/zhenxun/services/llm/manager.py @@ -17,6 +17,7 @@ from .config.providers import AI_CONFIG_GROUP, PROVIDERS_CONFIG_KEY, get_ai_conf from .core import http_client_manager, key_store from .service import LLMModel from .types import LLMErrorCode, LLMException, ModelDetail, ProviderConfig +from .types.capabilities import get_model_capabilities DEFAULT_MODEL_NAME_KEY = "default_model_name" PROXY_KEY = "proxy" @@ -115,57 +116,30 @@ def get_default_api_base_for_type(api_type: str) -> str | None: def get_configured_providers() -> list[ProviderConfig]: - """从配置中获取Provider列表 - 简化版本""" + """从配置中获取Provider列表 - 简化和修正版本""" ai_config = get_ai_config() - providers_raw = ai_config.get(PROVIDERS_CONFIG_KEY, []) - if not isinstance(providers_raw, list): + providers = ai_config.get(PROVIDERS_CONFIG_KEY, []) + + if not isinstance(providers, list): logger.error( - f"配置项 {AI_CONFIG_GROUP}.{PROVIDERS_CONFIG_KEY} 不是一个列表," + f"配置项 {AI_CONFIG_GROUP}.{PROVIDERS_CONFIG_KEY} 的值不是一个列表," f"将使用空列表。" ) return [] valid_providers = [] - for i, item in enumerate(providers_raw): - if not isinstance(item, dict): - logger.warning(f"配置文件中第 {i + 1} 项不是字典格式,已跳过。") - continue - - try: - if not item.get("name"): - logger.warning(f"Provider {i + 1} 缺少 'name' 字段,已跳过。") - continue - - if not item.get("api_key"): - logger.warning( - f"Provider '{item['name']}' 缺少 'api_key' 字段,已跳过。" - ) - continue - - if "api_type" not in item or not item["api_type"]: - provider_name = item.get("name", "").lower() - if "glm" in provider_name or "zhipu" in provider_name: - item["api_type"] = "zhipu" - elif "gemini" in provider_name or "google" in provider_name: - item["api_type"] = "gemini" - else: - item["api_type"] = "openai" - - if "api_base" not in item or not item["api_base"]: - api_type = item.get("api_type") - if api_type: - default_api_base = get_default_api_base_for_type(api_type) - if default_api_base: - item["api_base"] = default_api_base - - if "models" not in item: - item["models"] = [{"model_name": item.get("name", "default")}] - - provider_conf = ProviderConfig(**item) - valid_providers.append(provider_conf) - - except Exception as e: - logger.warning(f"解析配置文件中 Provider {i + 1} 时出错: {e},已跳过。") + for i, item in enumerate(providers): + if isinstance(item, ProviderConfig): + if not item.api_base: + default_api_base = get_default_api_base_for_type(item.api_type) + if default_api_base: + item.api_base = default_api_base + valid_providers.append(item) + else: + logger.warning( + f"配置文件中第 {i + 1} 项未能正确解析为 ProviderConfig 对象," + f"已跳过。实际类型: {type(item)}" + ) return valid_providers @@ -173,14 +147,15 @@ def get_configured_providers() -> list[ProviderConfig]: def find_model_config( provider_name: str, model_name: str ) -> tuple[ProviderConfig, ModelDetail] | None: - """在配置中查找指定的 Provider 和 ModelDetail + """ + 在配置中查找指定的 Provider 和 ModelDetail - Args: + 参数: provider_name: 提供商名称 model_name: 模型名称 - Returns: - 找到的 (ProviderConfig, ModelDetail) 元组,未找到则返回 None + 返回: + tuple[ProviderConfig, ModelDetail] | None: 找到的配置元组,未找到则返回 None """ providers = get_configured_providers() @@ -221,10 +196,11 @@ def _get_model_identifiers(provider_name: str, model_detail: ModelDetail) -> lis def list_model_identifiers() -> dict[str, list[str]]: - """列出所有模型的可用标识符 + """ + 列出所有模型的可用标识符 - Returns: - 字典,键为模型的完整名称,值为该模型的所有可用标识符列表 + 返回: + dict[str, list[str]]: 字典,键为模型的完整名称,值为该模型的所有可用标识符列表 """ providers = get_configured_providers() result = {} @@ -248,7 +224,16 @@ async def get_model_instance( provider_model_name: str | None = None, override_config: dict[str, Any] | None = None, ) -> LLMModel: - """根据 'ProviderName/ModelName' 字符串获取并实例化 LLMModel (异步版本)""" + """ + 根据 'ProviderName/ModelName' 字符串获取并实例化 LLMModel (异步版本) + + 参数: + provider_model_name: 模型名称,格式为 'ProviderName/ModelName'。 + override_config: 覆盖配置字典。 + + 返回: + LLMModel: 模型实例。 + """ cache_key = _make_cache_key(provider_model_name, override_config) cached_model = _get_cached_model(cache_key) if cached_model: @@ -292,6 +277,10 @@ async def get_model_instance( provider_config_found, model_detail_found = config_tuple_found + capabilities = get_model_capabilities(model_detail_found.model_name) + + model_detail_found.is_embedding_model = capabilities.is_embedding_model + ai_config = get_ai_config() global_proxy_setting = ai_config.get(PROXY_KEY) default_timeout = ( @@ -322,6 +311,7 @@ async def get_model_instance( model_detail=model_detail_found, key_store=key_store, http_client=shared_http_client, + capabilities=capabilities, ) if override_config: @@ -357,7 +347,15 @@ def get_global_default_model_name() -> str | None: def set_global_default_model_name(provider_model_name: str | None) -> bool: - """设置全局默认模型名称""" + """ + 设置全局默认模型名称 + + 参数: + provider_model_name: 模型名称,格式为 'ProviderName/ModelName'。 + + 返回: + bool: 设置是否成功。 + """ if provider_model_name: prov_name, mod_name = parse_provider_model_string(provider_model_name) if not prov_name or not mod_name or not find_model_config(prov_name, mod_name): @@ -377,7 +375,12 @@ def set_global_default_model_name(provider_model_name: str | None) -> bool: async def get_key_usage_stats() -> dict[str, Any]: - """获取所有Provider的Key使用统计""" + """ + 获取所有Provider的Key使用统计 + + 返回: + dict[str, Any]: 包含所有Provider的Key使用统计信息。 + """ providers = get_configured_providers() stats = {} @@ -400,7 +403,16 @@ async def get_key_usage_stats() -> dict[str, Any]: async def reset_key_status(provider_name: str, api_key: str | None = None) -> bool: - """重置指定Provider的Key状态""" + """ + 重置指定Provider的Key状态 + + 参数: + provider_name: 提供商名称。 + api_key: 要重置的特定API密钥,如果为None则重置所有密钥。 + + 返回: + bool: 重置是否成功。 + """ providers = get_configured_providers() target_provider = None diff --git a/zhenxun/services/llm/service.py b/zhenxun/services/llm/service.py index d054ca9b..587b15cc 100644 --- a/zhenxun/services/llm/service.py +++ b/zhenxun/services/llm/service.py @@ -6,11 +6,13 @@ LLM 模型实现类 from abc import ABC, abstractmethod from collections.abc import Awaitable, Callable +from contextlib import AsyncExitStack import json from typing import Any from zhenxun.services.log import logger +from .adapters.base import RequestData from .config import LLMGenerationConfig from .config.providers import get_ai_config from .core import ( @@ -30,6 +32,8 @@ from .types import ( ModelDetail, ProviderConfig, ) +from .types.capabilities import ModelCapabilities, ModelModality +from .utils import _sanitize_request_body_for_logging class LLMModelBase(ABC): @@ -42,7 +46,17 @@ class LLMModelBase(ABC): history: list[dict[str, str]] | None = None, **kwargs: Any, ) -> str: - """生成文本""" + """ + 生成文本 + + 参数: + prompt: 输入提示词。 + history: 对话历史记录。 + **kwargs: 其他参数。 + + 返回: + str: 生成的文本。 + """ pass @abstractmethod @@ -54,7 +68,19 @@ class LLMModelBase(ABC): tool_choice: str | dict[str, Any] | None = None, **kwargs: Any, ) -> LLMResponse: - """生成高级响应""" + """ + 生成高级响应 + + 参数: + messages: 消息列表。 + config: 生成配置。 + tools: 工具列表。 + tool_choice: 工具选择策略。 + **kwargs: 其他参数。 + + 返回: + LLMResponse: 模型响应。 + """ pass @abstractmethod @@ -64,7 +90,17 @@ class LLMModelBase(ABC): task_type: EmbeddingTaskType | str = EmbeddingTaskType.RETRIEVAL_DOCUMENT, **kwargs: Any, ) -> list[list[float]]: - """生成文本嵌入向量""" + """ + 生成文本嵌入向量 + + 参数: + texts: 文本列表。 + task_type: 嵌入任务类型。 + **kwargs: 其他参数。 + + 返回: + list[list[float]]: 嵌入向量列表。 + """ pass @@ -77,12 +113,14 @@ class LLMModel(LLMModelBase): model_detail: ModelDetail, key_store: KeyStatusStore, http_client: LLMHttpClient, + capabilities: ModelCapabilities, config_override: LLMGenerationConfig | None = None, ): self.provider_config = provider_config self.model_detail = model_detail self.key_store = key_store self.http_client: LLMHttpClient = http_client + self.capabilities = capabilities self._generation_config = config_override self.provider_name = provider_config.name @@ -99,6 +137,34 @@ class LLMModel(LLMModelBase): self._is_closed = False + def can_process_images(self) -> bool: + """检查模型是否支持图片作为输入。""" + return ModelModality.IMAGE in self.capabilities.input_modalities + + def can_process_video(self) -> bool: + """检查模型是否支持视频作为输入。""" + return ModelModality.VIDEO in self.capabilities.input_modalities + + def can_process_audio(self) -> bool: + """检查模型是否支持音频作为输入。""" + return ModelModality.AUDIO in self.capabilities.input_modalities + + def can_generate_images(self) -> bool: + """检查模型是否支持生成图片。""" + return ModelModality.IMAGE in self.capabilities.output_modalities + + def can_generate_audio(self) -> bool: + """检查模型是否支持生成音频 (TTS)。""" + return ModelModality.AUDIO in self.capabilities.output_modalities + + def can_use_tools(self) -> bool: + """检查模型是否支持工具调用/函数调用。""" + return self.capabilities.supports_tool_calling + + def is_embedding_model(self) -> bool: + """检查这是否是一个嵌入模型。""" + return self.capabilities.is_embedding_model + async def _get_http_client(self) -> LLMHttpClient: """获取HTTP客户端""" if self.http_client.is_closed: @@ -135,24 +201,54 @@ class LLMModel(LLMModelBase): return selected_key - async def _execute_embedding_request( + async def _perform_api_call( self, - adapter, - texts: list[str], - task_type: EmbeddingTaskType | str, - http_client: LLMHttpClient, + prepare_request_func: Callable[[str], Awaitable["RequestData"]], + parse_response_func: Callable[[dict[str, Any]], Any], + http_client: "LLMHttpClient", failed_keys: set[str] | None = None, - ) -> list[list[float]]: - """执行单次嵌入请求 - 供重试机制调用""" + log_context: str = "API", + ) -> Any: + """ + 执行API调用的通用核心方法。 + + 该方法封装了以下通用逻辑: + 1. 选择API密钥。 + 2. 准备和记录请求。 + 3. 发送HTTP POST请求。 + 4. 处理HTTP错误和API特定错误。 + 5. 记录密钥使用状态。 + 6. 解析成功的响应。 + + 参数: + prepare_request_func: 准备请求的函数。 + parse_response_func: 解析响应的函数。 + http_client: HTTP客户端。 + failed_keys: 失败的密钥集合。 + log_context: 日志上下文。 + + 返回: + Any: 解析后的响应数据。 + """ api_key = await self._select_api_key(failed_keys) try: - request_data = adapter.prepare_embedding_request( - model=self, - api_key=api_key, - texts=texts, - task_type=task_type, + request_data = await prepare_request_func(api_key) + + logger.info( + f"🌐 发起LLM请求 - 模型: {self.provider_name}/{self.model_name} " + f"[{log_context}]" ) + logger.debug(f"📡 请求URL: {request_data.url}") + masked_key = ( + f"{api_key[:8]}...{api_key[-4:] if len(api_key) > 12 else '***'}" + ) + logger.debug(f"🔑 API密钥: {masked_key}") + logger.debug(f"📋 请求头: {dict(request_data.headers)}") + + sanitized_body = _sanitize_request_body_for_logging(request_data.body) + request_body_str = json.dumps(sanitized_body, ensure_ascii=False, indent=2) + logger.debug(f"📦 请求体: {request_body_str}") http_response = await http_client.post( request_data.url, @@ -160,121 +256,16 @@ class LLMModel(LLMModelBase): json=request_data.body, ) - if http_response.status_code != 200: - error_text = http_response.text - logger.error( - f"HTTP嵌入请求失败: {http_response.status_code} - {error_text}" - ) - await self.key_store.record_failure(api_key, http_response.status_code) - - error_code = LLMErrorCode.API_REQUEST_FAILED - if http_response.status_code in [401, 403]: - error_code = LLMErrorCode.API_KEY_INVALID - elif http_response.status_code == 429: - error_code = LLMErrorCode.API_RATE_LIMITED - - raise LLMException( - f"HTTP嵌入请求失败: {http_response.status_code}", - code=error_code, - details={ - "status_code": http_response.status_code, - "response": error_text, - "api_key": api_key, - }, - ) - - try: - response_json = http_response.json() - adapter.validate_embedding_response(response_json) - embeddings = adapter.parse_embedding_response(response_json) - except Exception as e: - logger.error(f"解析嵌入响应失败: {e}", e=e) - await self.key_store.record_failure(api_key, None) - if isinstance(e, LLMException): - raise - else: - raise LLMException( - f"解析API嵌入响应失败: {e}", - code=LLMErrorCode.RESPONSE_PARSE_ERROR, - cause=e, - ) - - await self.key_store.record_success(api_key) - return embeddings - - except LLMException: - raise - except Exception as e: - logger.error(f"生成嵌入时发生未预期错误: {e}", e=e) - await self.key_store.record_failure(api_key, None) - raise LLMException( - f"生成嵌入失败: {e}", - code=LLMErrorCode.EMBEDDING_FAILED, - cause=e, - ) - - async def _execute_with_smart_retry( - self, - adapter, - messages: list[LLMMessage], - config: LLMGenerationConfig | None, - tools_dict: list[dict[str, Any]] | None, - tool_choice: str | dict[str, Any] | None, - http_client: LLMHttpClient, - ): - """智能重试机制 - 使用统一的重试装饰器""" - ai_config = get_ai_config() - max_retries = ai_config.get("max_retries_llm", 3) - retry_delay = ai_config.get("retry_delay_llm", 2) - retry_config = RetryConfig(max_retries=max_retries, retry_delay=retry_delay) - - return await with_smart_retry( - self._execute_single_request, - adapter, - messages, - config, - tools_dict, - tool_choice, - http_client, - retry_config=retry_config, - key_store=self.key_store, - provider_name=self.provider_name, - ) - - async def _execute_single_request( - self, - adapter, - messages: list[LLMMessage], - config: LLMGenerationConfig | None, - tools_dict: list[dict[str, Any]] | None, - tool_choice: str | dict[str, Any] | None, - http_client: LLMHttpClient, - failed_keys: set[str] | None = None, - ) -> LLMResponse: - """执行单次请求 - 供重试机制调用,直接返回 LLMResponse""" - api_key = await self._select_api_key(failed_keys) - - try: - request_data = adapter.prepare_advanced_request( - model=self, - api_key=api_key, - messages=messages, - config=config, - tools=tools_dict, - tool_choice=tool_choice, - ) - - http_response = await http_client.post( - request_data.url, - headers=request_data.headers, - json=request_data.body, - ) + logger.debug(f"📥 响应状态码: {http_response.status_code}") + logger.debug(f"📄 响应头: {dict(http_response.headers)}") if http_response.status_code != 200: error_text = http_response.text logger.error( - f"HTTP请求失败: {http_response.status_code} - {error_text}" + f"❌ HTTP请求失败: {http_response.status_code} - {error_text} " + f"[{log_context}]" ) + logger.debug(f"💥 完整错误响应: {error_text}") await self.key_store.record_failure(api_key, http_response.status_code) @@ -299,69 +290,165 @@ class LLMModel(LLMModelBase): try: response_json = http_response.json() - response_data = adapter.parse_response( - model=self, - response_json=response_json, - is_advanced=True, - ) - - from .types.models import LLMToolCall - - response_tool_calls = [] - if response_data.tool_calls: - for tc_data in response_data.tool_calls: - if isinstance(tc_data, LLMToolCall): - response_tool_calls.append(tc_data) - elif isinstance(tc_data, dict): - try: - response_tool_calls.append(LLMToolCall(**tc_data)) - except Exception as e: - logger.warning( - f"无法将工具调用数据转换为LLMToolCall: {tc_data}, " - f"error: {e}" - ) - else: - logger.warning(f"工具调用数据格式未知: {tc_data}") - - llm_response = LLMResponse( - text=response_data.text, - usage_info=response_data.usage_info, - raw_response=response_data.raw_response, - tool_calls=response_tool_calls if response_tool_calls else None, - code_executions=response_data.code_executions, - grounding_metadata=response_data.grounding_metadata, - cache_info=response_data.cache_info, + response_json_str = json.dumps( + response_json, ensure_ascii=False, indent=2 ) + logger.debug(f"📋 响应JSON: {response_json_str}") + parsed_data = parse_response_func(response_json) except Exception as e: - logger.error(f"解析响应失败: {e}", e=e) + logger.error(f"解析 {log_context} 响应失败: {e}", e=e) await self.key_store.record_failure(api_key, None) - if isinstance(e, LLMException): raise else: raise LLMException( - f"解析API响应失败: {e}", + f"解析API {log_context} 响应失败: {e}", code=LLMErrorCode.RESPONSE_PARSE_ERROR, cause=e, ) await self.key_store.record_success(api_key) - - return llm_response + logger.debug(f"✅ API密钥使用成功: {masked_key}") + logger.info(f"🎯 LLM响应解析完成 [{log_context}]") + return parsed_data except LLMException: raise except Exception as e: - logger.error(f"生成响应时发生未预期错误: {e}", e=e) + error_log_msg = f"生成 {log_context.lower()} 时发生未预期错误: {e}" + logger.error(error_log_msg, e=e) await self.key_store.record_failure(api_key, None) - raise LLMException( - f"生成响应失败: {e}", - code=LLMErrorCode.GENERATION_FAILED, + error_log_msg, + code=LLMErrorCode.GENERATION_FAILED + if log_context == "Generation" + else LLMErrorCode.EMBEDDING_FAILED, cause=e, ) + async def _execute_embedding_request( + self, + adapter, + texts: list[str], + task_type: EmbeddingTaskType | str, + http_client: LLMHttpClient, + failed_keys: set[str] | None = None, + ) -> list[list[float]]: + """执行单次嵌入请求 - 供重试机制调用""" + + async def prepare_request(api_key: str) -> RequestData: + return adapter.prepare_embedding_request( + model=self, + api_key=api_key, + texts=texts, + task_type=task_type, + ) + + def parse_response(response_json: dict[str, Any]) -> list[list[float]]: + adapter.validate_embedding_response(response_json) + return adapter.parse_embedding_response(response_json) + + return await self._perform_api_call( + prepare_request_func=prepare_request, + parse_response_func=parse_response, + http_client=http_client, + failed_keys=failed_keys, + log_context="Embedding", + ) + + async def _execute_with_smart_retry( + self, + adapter, + messages: list[LLMMessage], + config: LLMGenerationConfig | None, + tools: list[LLMTool] | None, + tool_choice: str | dict[str, Any] | None, + http_client: LLMHttpClient, + ): + """智能重试机制 - 使用统一的重试装饰器""" + ai_config = get_ai_config() + max_retries = ai_config.get("max_retries_llm", 3) + retry_delay = ai_config.get("retry_delay_llm", 2) + retry_config = RetryConfig(max_retries=max_retries, retry_delay=retry_delay) + + return await with_smart_retry( + self._execute_single_request, + adapter, + messages, + config, + tools, + tool_choice, + http_client, + retry_config=retry_config, + key_store=self.key_store, + provider_name=self.provider_name, + ) + + async def _execute_single_request( + self, + adapter, + messages: list[LLMMessage], + config: LLMGenerationConfig | None, + tools: list[LLMTool] | None, + tool_choice: str | dict[str, Any] | None, + http_client: LLMHttpClient, + failed_keys: set[str] | None = None, + ) -> LLMResponse: + """执行单次请求 - 供重试机制调用,直接返回 LLMResponse""" + + async def prepare_request(api_key: str) -> RequestData: + return await adapter.prepare_advanced_request( + model=self, + api_key=api_key, + messages=messages, + config=config, + tools=tools, + tool_choice=tool_choice, + ) + + def parse_response(response_json: dict[str, Any]) -> LLMResponse: + response_data = adapter.parse_response( + model=self, + response_json=response_json, + is_advanced=True, + ) + from .types.models import LLMToolCall + + response_tool_calls = [] + if response_data.tool_calls: + for tc_data in response_data.tool_calls: + if isinstance(tc_data, LLMToolCall): + response_tool_calls.append(tc_data) + elif isinstance(tc_data, dict): + try: + response_tool_calls.append(LLMToolCall(**tc_data)) + except Exception as e: + logger.warning( + f"无法将工具调用数据转换为LLMToolCall: {tc_data}, " + f"error: {e}" + ) + else: + logger.warning(f"工具调用数据格式未知: {tc_data}") + + return LLMResponse( + text=response_data.text, + usage_info=response_data.usage_info, + raw_response=response_data.raw_response, + tool_calls=response_tool_calls if response_tool_calls else None, + code_executions=response_data.code_executions, + grounding_metadata=response_data.grounding_metadata, + cache_info=response_data.cache_info, + ) + + return await self._perform_api_call( + prepare_request_func=prepare_request, + parse_response_func=parse_response, + http_client=http_client, + failed_keys=failed_keys, + log_context="Generation", + ) + async def close(self): """ 标记模型实例的当前使用周期结束。 @@ -400,7 +487,17 @@ class LLMModel(LLMModelBase): history: list[dict[str, str]] | None = None, **kwargs: Any, ) -> str: - """生成文本 - 通过 generate_response 实现""" + """ + 生成文本 - 通过 generate_response 实现 + + 参数: + prompt: 输入提示词。 + history: 对话历史记录。 + **kwargs: 其他参数。 + + 返回: + str: 生成的文本。 + """ self._check_not_closed() messages: list[LLMMessage] = [] @@ -439,11 +536,21 @@ class LLMModel(LLMModelBase): config: LLMGenerationConfig | None = None, tools: list[LLMTool] | None = None, tool_choice: str | dict[str, Any] | None = None, - tool_executor: Callable[[str, dict[str, Any]], Awaitable[Any]] | None = None, - max_tool_iterations: int = 5, **kwargs: Any, ) -> LLMResponse: - """生成高级响应 - 实现完整的工具调用循环""" + """ + 生成高级响应 + + 参数: + messages: 消息列表。 + config: 生成配置。 + tools: 工具列表。 + tool_choice: 工具选择策略。 + **kwargs: 其他参数。 + + 返回: + LLMResponse: 模型响应。 + """ self._check_not_closed() from .adapters import get_adapter_for_api_type @@ -468,109 +575,43 @@ class LLMModel(LLMModelBase): merged_dict.update(config.to_dict()) final_request_config = LLMGenerationConfig(**merged_dict) - tools_dict: list[dict[str, Any]] | None = None - if tools: - tools_dict = [] - for tool in tools: - if hasattr(tool, "model_dump"): - model_dump_func = getattr(tool, "model_dump") - tools_dict.append(model_dump_func(exclude_none=True)) - elif isinstance(tool, dict): - tools_dict.append(tool) - else: - try: - tools_dict.append(dict(tool)) - except (TypeError, ValueError): - logger.warning(f"工具 '{tool}' 无法转换为字典,已忽略。") - http_client = await self._get_http_client() - current_messages = list(messages) - for iteration in range(max_tool_iterations): - logger.debug(f"工具调用循环迭代: {iteration + 1}/{max_tool_iterations}") + async with AsyncExitStack() as stack: + activated_tools = [] + if tools: + for tool in tools: + if tool.type == "mcp" and callable(tool.mcp_session): + func_obj = getattr(tool.mcp_session, "func", None) + tool_name = ( + getattr(func_obj, "__name__", "unknown") + if func_obj + else "unknown" + ) + logger.debug(f"正在激活 MCP 工具会话: {tool_name}") + + active_session = await stack.enter_async_context( + tool.mcp_session() + ) + + activated_tools.append( + LLMTool.from_mcp_session( + session=active_session, annotations=tool.annotations + ) + ) + else: + activated_tools.append(tool) llm_response = await self._execute_with_smart_retry( adapter, - current_messages, + messages, final_request_config, - tools_dict if iteration == 0 else None, - tool_choice if iteration == 0 else None, + activated_tools if activated_tools else None, + tool_choice, http_client, ) - response_tool_calls = llm_response.tool_calls or [] - - if not response_tool_calls or not tool_executor: - logger.debug("模型未请求工具调用,或未提供工具执行器。返回当前响应。") - return llm_response - - logger.info(f"模型请求执行 {len(response_tool_calls)} 个工具。") - - assistant_message_content = llm_response.text if llm_response.text else "" - current_messages.append( - LLMMessage.assistant_tool_calls( - content=assistant_message_content, tool_calls=response_tool_calls - ) - ) - - tool_response_messages: list[LLMMessage] = [] - for tool_call in response_tool_calls: - tool_name = tool_call.function.name - try: - tool_args_dict = json.loads(tool_call.function.arguments) - logger.debug(f"执行工具: {tool_name},参数: {tool_args_dict}") - - tool_result = await tool_executor(tool_name, tool_args_dict) - logger.debug( - f"工具 '{tool_name}' 执行结果: {str(tool_result)[:200]}..." - ) - - tool_response_messages.append( - LLMMessage.tool_response( - tool_call_id=tool_call.id, - function_name=tool_name, - result=tool_result, - ) - ) - except json.JSONDecodeError as e: - logger.error( - f"工具 '{tool_name}' 参数JSON解析失败: " - f"{tool_call.function.arguments}, 错误: {e}" - ) - tool_response_messages.append( - LLMMessage.tool_response( - tool_call_id=tool_call.id, - function_name=tool_name, - result={ - "error": "Argument JSON parsing failed", - "details": str(e), - }, - ) - ) - except Exception as e: - logger.error(f"执行工具 '{tool_name}' 失败: {e}", e=e) - tool_response_messages.append( - LLMMessage.tool_response( - tool_call_id=tool_call.id, - function_name=tool_name, - result={ - "error": "Tool execution failed", - "details": str(e), - }, - ) - ) - - current_messages.extend(tool_response_messages) - - logger.warning(f"已达到最大工具调用迭代次数 ({max_tool_iterations})。") - raise LLMException( - "已达到最大工具调用迭代次数,但模型仍在请求工具调用或未提供最终文本回复。", - code=LLMErrorCode.GENERATION_FAILED, - details={ - "iterations": max_tool_iterations, - "last_messages": current_messages[-2:], - }, - ) + return llm_response async def generate_embeddings( self, @@ -578,7 +619,17 @@ class LLMModel(LLMModelBase): task_type: EmbeddingTaskType | str = EmbeddingTaskType.RETRIEVAL_DOCUMENT, **kwargs: Any, ) -> list[list[float]]: - """生成文本嵌入向量""" + """ + 生成文本嵌入向量 + + 参数: + texts: 文本列表。 + task_type: 嵌入任务类型。 + **kwargs: 其他参数。 + + 返回: + list[list[float]]: 嵌入向量列表。 + """ self._check_not_closed() if not texts: return [] diff --git a/zhenxun/services/llm/tools/__init__.py b/zhenxun/services/llm/tools/__init__.py new file mode 100644 index 00000000..3c62ed2a --- /dev/null +++ b/zhenxun/services/llm/tools/__init__.py @@ -0,0 +1,7 @@ +""" +工具模块导出 +""" + +from .registry import tool_registry + +__all__ = ["tool_registry"] diff --git a/zhenxun/services/llm/tools/registry.py b/zhenxun/services/llm/tools/registry.py new file mode 100644 index 00000000..daa0c796 --- /dev/null +++ b/zhenxun/services/llm/tools/registry.py @@ -0,0 +1,181 @@ +""" +工具注册表 + +负责加载、管理和实例化来自配置的工具。 +""" + +from collections.abc import Callable +from contextlib import AbstractAsyncContextManager +from functools import partial +from typing import TYPE_CHECKING + +from pydantic import BaseModel + +from zhenxun.services.log import logger + +from ..types import LLMTool + +if TYPE_CHECKING: + from ..config.providers import ToolConfig + from ..types.protocols import MCPCompatible + + +class ToolRegistry: + """工具注册表,用于管理和实例化配置的工具。""" + + def __init__(self): + self._function_tools: dict[str, LLMTool] = {} + + self._mcp_config_models: dict[str, type[BaseModel]] = {} + if TYPE_CHECKING: + self._mcp_factories: dict[ + str, Callable[..., AbstractAsyncContextManager["MCPCompatible"]] + ] = {} + else: + self._mcp_factories: dict[str, Callable] = {} + + self._tool_configs: dict[str, "ToolConfig"] | None = None + self._tool_cache: dict[str, "LLMTool"] = {} + + def _load_configs_if_needed(self): + """如果尚未加载,则从主配置中加载MCP工具定义。""" + if self._tool_configs is None: + logger.debug("首次访问,正在加载MCP工具配置...") + from ..config.providers import get_llm_config + + llm_config = get_llm_config() + self._tool_configs = {tool.name: tool for tool in llm_config.mcp_tools} + logger.info(f"已加载 {len(self._tool_configs)} 个MCP工具配置。") + + def function_tool( + self, + name: str, + description: str, + parameters: dict, + required: list[str] | None = None, + ): + """ + 装饰器:在代码中注册一个简单的、无状态的函数工具。 + + 参数: + name: 工具的唯一名称。 + description: 工具功能的描述。 + parameters: OpenAPI格式的函数参数schema的properties部分。 + required: 必需的参数列表。 + """ + + def decorator(func: Callable): + if name in self._function_tools or name in self._mcp_factories: + logger.warning(f"正在覆盖已注册的工具: {name}") + + tool_definition = LLMTool.create( + name=name, + description=description, + parameters=parameters, + required=required, + ) + self._function_tools[name] = tool_definition + logger.info(f"已在代码中注册函数工具: '{name}'") + tool_definition.annotations = tool_definition.annotations or {} + tool_definition.annotations["executable"] = func + return func + + return decorator + + def mcp_tool(self, name: str, config_model: type[BaseModel]): + """ + 装饰器:注册一个MCP工具及其配置模型。 + + 参数: + name: 工具的唯一名称,必须与配置文件中的名称匹配。 + config_model: 一个Pydantic模型,用于定义和验证该工具的 `mcp_config`。 + """ + + def decorator(factory_func: Callable): + if name in self._mcp_factories: + logger.warning(f"正在覆盖已注册的 MCP 工厂: {name}") + self._mcp_factories[name] = factory_func + self._mcp_config_models[name] = config_model + logger.info(f"已注册 MCP 工具 '{name}' (配置模型: {config_model.__name__})") + return factory_func + + return decorator + + def get_mcp_config_model(self, name: str) -> type[BaseModel] | None: + """根据名称获取MCP工具的配置模型。""" + return self._mcp_config_models.get(name) + + def register_mcp_factory( + self, + name: str, + factory: Callable, + ): + """ + 在代码中注册一个 MCP 会话工厂,将其与配置中的工具名称关联。 + + 参数: + name: 工具的唯一名称,必须与配置文件中的名称匹配。 + factory: 一个返回异步生成器的可调用对象(会话工厂)。 + """ + if name in self._mcp_factories: + logger.warning(f"正在覆盖已注册的 MCP 工厂: {name}") + self._mcp_factories[name] = factory + logger.info(f"已注册 MCP 会话工厂: '{name}'") + + def get_tool(self, name: str) -> "LLMTool": + """ + 根据名称获取一个 LLMTool 定义。 + 对于MCP工具,返回的 LLMTool 实例包含一个可调用的会话工厂, + 而不是一个已激活的会话。 + """ + logger.debug(f"🔍 请求获取工具定义: {name}") + + if name in self._tool_cache: + logger.debug(f"✅ 从缓存中获取工具定义: {name}") + return self._tool_cache[name] + + if name in self._function_tools: + logger.debug(f"🛠️ 获取函数工具定义: {name}") + tool = self._function_tools[name] + self._tool_cache[name] = tool + return tool + + self._load_configs_if_needed() + if self._tool_configs is None or name not in self._tool_configs: + known_tools = list(self._function_tools.keys()) + ( + list(self._tool_configs.keys()) if self._tool_configs else [] + ) + logger.error(f"❌ 未找到名为 '{name}' 的工具定义") + logger.debug(f"📋 可用工具定义列表: {known_tools}") + raise ValueError(f"未找到名为 '{name}' 的工具定义。已知工具: {known_tools}") + + config = self._tool_configs[name] + tool: "LLMTool" + + if name not in self._mcp_factories: + logger.error(f"❌ MCP工具 '{name}' 缺少工厂函数") + available_factories = list(self._mcp_factories.keys()) + logger.debug(f"📋 已注册的MCP工厂: {available_factories}") + raise ValueError( + f"MCP 工具 '{name}' 已在配置中定义,但没有注册对应的工厂函数。" + "请使用 `@tool_registry.mcp_tool` 装饰器进行注册。" + ) + + logger.info(f"🔧 创建MCP工具定义: {name}") + factory = self._mcp_factories[name] + typed_mcp_config = config.mcp_config + logger.debug(f"📋 MCP工具配置: {typed_mcp_config}") + + configured_factory = partial(factory, config=typed_mcp_config) + tool = LLMTool.from_mcp_session(session=configured_factory) + + self._tool_cache[name] = tool + logger.debug(f"💾 MCP工具定义已缓存: {name}") + return tool + + def get_tools(self, names: list[str]) -> list["LLMTool"]: + """根据名称列表获取多个 LLMTool 实例。""" + return [self.get_tool(name) for name in names] + + +tool_registry = ToolRegistry() diff --git a/zhenxun/services/llm/types/__init__.py b/zhenxun/services/llm/types/__init__.py index ebae4185..f01bc291 100644 --- a/zhenxun/services/llm/types/__init__.py +++ b/zhenxun/services/llm/types/__init__.py @@ -4,6 +4,7 @@ LLM 类型定义模块 统一导出所有核心类型、协议和异常定义。 """ +from .capabilities import ModelCapabilities, ModelModality, get_model_capabilities from .content import ( LLMContentPart, LLMMessage, @@ -26,6 +27,7 @@ from .models import ( ToolMetadata, UsageInfo, ) +from .protocols import MCPCompatible __all__ = [ "EmbeddingTaskType", @@ -41,8 +43,11 @@ __all__ = [ "LLMTool", "LLMToolCall", "LLMToolFunction", + "MCPCompatible", + "ModelCapabilities", "ModelDetail", "ModelInfo", + "ModelModality", "ModelName", "ModelProvider", "ProviderConfig", @@ -50,5 +55,6 @@ __all__ = [ "ToolCategory", "ToolMetadata", "UsageInfo", + "get_model_capabilities", "get_user_friendly_error_message", ] diff --git a/zhenxun/services/llm/types/capabilities.py b/zhenxun/services/llm/types/capabilities.py new file mode 100644 index 00000000..fc25cf7e --- /dev/null +++ b/zhenxun/services/llm/types/capabilities.py @@ -0,0 +1,128 @@ +""" +LLM 模型能力定义模块 + +定义模型的输入输出模态、工具调用支持等核心能力。 +""" + +from enum import Enum +import fnmatch + +from pydantic import BaseModel, Field + + +class ModelModality(str, Enum): + TEXT = "text" + IMAGE = "image" + AUDIO = "audio" + VIDEO = "video" + EMBEDDING = "embedding" + + +class ModelCapabilities(BaseModel): + """定义一个模型的核心、稳定能力。""" + + input_modalities: set[ModelModality] = Field(default={ModelModality.TEXT}) + output_modalities: set[ModelModality] = Field(default={ModelModality.TEXT}) + supports_tool_calling: bool = False + is_embedding_model: bool = False + + +STANDARD_TEXT_TOOL_CAPABILITIES = ModelCapabilities( + input_modalities={ModelModality.TEXT}, + output_modalities={ModelModality.TEXT}, + supports_tool_calling=True, +) + +GEMINI_CAPABILITIES = ModelCapabilities( + input_modalities={ + ModelModality.TEXT, + ModelModality.IMAGE, + ModelModality.AUDIO, + ModelModality.VIDEO, + }, + output_modalities={ModelModality.TEXT}, + supports_tool_calling=True, +) + +DOUBAO_ADVANCED_MULTIMODAL_CAPABILITIES = ModelCapabilities( + input_modalities={ModelModality.TEXT, ModelModality.IMAGE, ModelModality.VIDEO}, + output_modalities={ModelModality.TEXT}, + supports_tool_calling=True, +) + + +MODEL_ALIAS_MAPPING: dict[str, str] = { + "deepseek-v3*": "deepseek-chat", + "deepseek-ai/DeepSeek-V3": "deepseek-chat", + "deepseek-r1*": "deepseek-reasoner", +} + + +MODEL_CAPABILITIES_REGISTRY: dict[str, ModelCapabilities] = { + "gemini-*-tts": ModelCapabilities( + input_modalities={ModelModality.TEXT}, + output_modalities={ModelModality.AUDIO}, + ), + "gemini-*-native-audio-*": ModelCapabilities( + input_modalities={ModelModality.TEXT, ModelModality.AUDIO, ModelModality.VIDEO}, + output_modalities={ModelModality.TEXT, ModelModality.AUDIO}, + supports_tool_calling=True, + ), + "gemini-2.0-flash-preview-image-generation": ModelCapabilities( + input_modalities={ + ModelModality.TEXT, + ModelModality.IMAGE, + ModelModality.AUDIO, + ModelModality.VIDEO, + }, + output_modalities={ModelModality.TEXT, ModelModality.IMAGE}, + supports_tool_calling=True, + ), + "gemini-embedding-exp": ModelCapabilities( + input_modalities={ModelModality.TEXT}, + output_modalities={ModelModality.EMBEDDING}, + is_embedding_model=True, + ), + "gemini-2.5-pro*": GEMINI_CAPABILITIES, + "gemini-1.5-pro*": GEMINI_CAPABILITIES, + "gemini-2.5-flash*": GEMINI_CAPABILITIES, + "gemini-2.0-flash*": GEMINI_CAPABILITIES, + "gemini-1.5-flash*": GEMINI_CAPABILITIES, + "GLM-4V-Flash": ModelCapabilities( + input_modalities={ModelModality.TEXT, ModelModality.IMAGE}, + output_modalities={ModelModality.TEXT}, + supports_tool_calling=True, + ), + "GLM-4V-Plus*": ModelCapabilities( + input_modalities={ModelModality.TEXT, ModelModality.IMAGE, ModelModality.VIDEO}, + output_modalities={ModelModality.TEXT}, + supports_tool_calling=True, + ), + "glm-4-*": STANDARD_TEXT_TOOL_CAPABILITIES, + "glm-z1-*": STANDARD_TEXT_TOOL_CAPABILITIES, + "doubao-seed-*": DOUBAO_ADVANCED_MULTIMODAL_CAPABILITIES, + "doubao-1-5-thinking-vision-pro": DOUBAO_ADVANCED_MULTIMODAL_CAPABILITIES, + "deepseek-chat": STANDARD_TEXT_TOOL_CAPABILITIES, + "deepseek-reasoner": STANDARD_TEXT_TOOL_CAPABILITIES, +} + + +def get_model_capabilities(model_name: str) -> ModelCapabilities: + """ + 从注册表获取模型能力,支持别名映射和通配符匹配。 + 查找顺序: 1. 标准化名称 -> 2. 精确匹配 -> 3. 通配符匹配 -> 4. 默认值 + """ + canonical_name = model_name + for alias_pattern, c_name in MODEL_ALIAS_MAPPING.items(): + if fnmatch.fnmatch(model_name, alias_pattern): + canonical_name = c_name + break + + if canonical_name in MODEL_CAPABILITIES_REGISTRY: + return MODEL_CAPABILITIES_REGISTRY[canonical_name] + + for pattern, capabilities in MODEL_CAPABILITIES_REGISTRY.items(): + if "*" in pattern and fnmatch.fnmatch(model_name, pattern): + return capabilities + + return ModelCapabilities() diff --git a/zhenxun/services/llm/types/content.py b/zhenxun/services/llm/types/content.py index 54887bc3..9dc10821 100644 --- a/zhenxun/services/llm/types/content.py +++ b/zhenxun/services/llm/types/content.py @@ -225,8 +225,10 @@ class LLMContentPart(BaseModel): logger.warning(f"无法解析Base64图像数据: {self.image_source[:50]}...") return None - def convert_for_api(self, api_type: str) -> dict[str, Any]: + async def convert_for_api_async(self, api_type: str) -> dict[str, Any]: """根据API类型转换多模态内容格式""" + from zhenxun.utils.http_utils import AsyncHttpx + if self.type == "text": if api_type == "openai": return {"type": "text", "text": self.text} @@ -248,20 +250,23 @@ class LLMContentPart(BaseModel): mime_type, data = base64_info return {"inlineData": {"mimeType": mime_type, "data": data}} else: - # 如果无法解析 Base64 数据,抛出异常 raise ValueError( f"无法解析Base64图像数据: {self.image_source[:50]}..." ) - else: - logger.warning( - f"Gemini API需要Base64格式,但提供的是URL: {self.image_source}" - ) - return { - "inlineData": { - "mimeType": "image/jpeg", - "data": self.image_source, + elif self.is_image_url(): + logger.debug(f"正在为Gemini下载并编码URL图片: {self.image_source}") + try: + image_bytes = await AsyncHttpx.get_content(self.image_source) + mime_type = self.mime_type or "image/jpeg" + base64_data = base64.b64encode(image_bytes).decode("utf-8") + return { + "inlineData": {"mimeType": mime_type, "data": base64_data} } - } + except Exception as e: + logger.error(f"下载或编码URL图片失败: {e}", e=e) + raise ValueError(f"无法处理图片URL: {e}") + else: + raise ValueError(f"不支持的图像源格式: {self.image_source[:50]}...") else: return {"type": "image_url", "image_url": {"url": self.image_source}} diff --git a/zhenxun/services/llm/types/models.py b/zhenxun/services/llm/types/models.py index c5f541bc..ce574d53 100644 --- a/zhenxun/services/llm/types/models.py +++ b/zhenxun/services/llm/types/models.py @@ -4,13 +4,25 @@ LLM 数据模型定义 包含模型信息、配置、工具定义和响应数据的模型类。 """ +from collections.abc import Callable +from contextlib import AbstractAsyncContextManager from dataclasses import dataclass, field -from typing import Any +from typing import TYPE_CHECKING, Any from pydantic import BaseModel, Field from .enums import ModelProvider, ToolCategory +if TYPE_CHECKING: + from .protocols import MCPCompatible + + MCPSessionType = ( + MCPCompatible | Callable[[], AbstractAsyncContextManager[MCPCompatible]] | None + ) +else: + MCPCompatible = object + MCPSessionType = Any + ModelName = str | None @@ -98,10 +110,21 @@ class LLMToolCall(BaseModel): class LLMTool(BaseModel): """LLM 工具定义(支持 MCP 风格)""" + model_config = {"arbitrary_types_allowed": True} + type: str = "function" - function: dict[str, Any] + function: dict[str, Any] | None = None + mcp_session: MCPSessionType = None annotations: dict[str, Any] | None = Field(default=None, description="工具注解") + def model_post_init(self, /, __context: Any) -> None: + """验证工具定义的有效性""" + _ = __context + if self.type == "function" and self.function is None: + raise ValueError("函数类型的工具必须包含 'function' 字段。") + if self.type == "mcp" and self.mcp_session is None: + raise ValueError("MCP 类型的工具必须包含 'mcp_session' 字段。") + @classmethod def create( cls, @@ -111,7 +134,7 @@ class LLMTool(BaseModel): required: list[str] | None = None, annotations: dict[str, Any] | None = None, ) -> "LLMTool": - """创建工具""" + """创建函数工具""" function_def = { "name": name, "description": description, @@ -123,6 +146,15 @@ class LLMTool(BaseModel): } return cls(type="function", function=function_def, annotations=annotations) + @classmethod + def from_mcp_session( + cls, + session: Any, + annotations: dict[str, Any] | None = None, + ) -> "LLMTool": + """从 MCP 会话创建工具""" + return cls(type="mcp", mcp_session=session, annotations=annotations) + class LLMCodeExecution(BaseModel): """代码执行结果""" diff --git a/zhenxun/services/llm/types/protocols.py b/zhenxun/services/llm/types/protocols.py new file mode 100644 index 00000000..1ab1ace2 --- /dev/null +++ b/zhenxun/services/llm/types/protocols.py @@ -0,0 +1,24 @@ +""" +LLM 模块的协议定义 +""" + +from typing import Any, Protocol + + +class MCPCompatible(Protocol): + """ + 一个协议,定义了与LLM模块兼容的MCP会话对象应具备的行为。 + 任何实现了 to_api_tool 方法的对象都可以被认为是 MCPCompatible。 + """ + + def to_api_tool(self, api_type: str) -> dict[str, Any]: + """ + 将此MCP会话转换为特定LLM提供商API所需的工具格式。 + + 参数: + api_type: 目标API的类型 (例如 'gemini', 'openai')。 + + 返回: + dict[str, Any]: 一个字典,代表可以在API请求中使用的工具定义。 + """ + ... diff --git a/zhenxun/services/llm/utils.py b/zhenxun/services/llm/utils.py index 3610df27..d5e9177d 100644 --- a/zhenxun/services/llm/utils.py +++ b/zhenxun/services/llm/utils.py @@ -3,8 +3,10 @@ LLM 模块的工具和转换函数 """ import base64 +import copy from pathlib import Path +from nonebot.adapters import Message as PlatformMessage from nonebot_plugin_alconna.uniseg import ( At, File, @@ -17,6 +19,7 @@ from nonebot_plugin_alconna.uniseg import ( ) from zhenxun.services.log import logger +from zhenxun.utils.http_utils import AsyncHttpx from .types import LLMContentPart @@ -25,6 +28,12 @@ async def unimsg_to_llm_parts(message: UniMessage) -> list[LLMContentPart]: """ 将 UniMessage 实例转换为一个 LLMContentPart 列表。 这是处理多模态输入的核心转换逻辑。 + + 参数: + message: 要转换的UniMessage实例。 + + 返回: + list[LLMContentPart]: 转换后的内容部分列表。 """ parts: list[LLMContentPart] = [] for seg in message: @@ -51,14 +60,25 @@ async def unimsg_to_llm_parts(message: UniMessage) -> list[LLMContentPart]: if seg.path: part = await LLMContentPart.from_path(seg.path) elif seg.url: - logger.warning( - f"直接使用 URL 的 {type(seg).__name__} 段," - f"API 可能不支持: {seg.url}" - ) - part = LLMContentPart.text_part( - f"[{type(seg).__name__.upper()} FILE: {seg.name or seg.url}]" - ) - elif hasattr(seg, "raw") and seg.raw: + try: + logger.debug(f"检测到媒体URL,开始下载: {seg.url}") + media_bytes = await AsyncHttpx.get_content(seg.url) + + new_seg = copy.copy(seg) + new_seg.raw = media_bytes + seg = new_seg + logger.debug(f"媒体文件下载成功,大小: {len(media_bytes)} bytes") + except Exception as e: + logger.error(f"从URL下载媒体失败: {seg.url}, 错误: {e}") + part = LLMContentPart.text_part( + f"[下载媒体失败: {seg.name or seg.url}]" + ) + + if part: + parts.append(part) + continue + + if hasattr(seg, "raw") and seg.raw: mime_type = getattr(seg, "mimetype", None) if isinstance(seg.raw, bytes): b64_data = base64.b64encode(seg.raw).decode("utf-8") @@ -127,50 +147,19 @@ def create_multimodal_message( audio_mimetypes: list[str] | str | None = None, ) -> UniMessage: """ - 创建多模态消息的便捷函数,方便第三方调用。 + 创建多模态消息的便捷函数 - Args: + 参数: text: 文本内容 images: 图片数据,支持路径、字节数据或URL - videos: 视频数据,支持路径、字节数据或URL - audios: 音频数据,支持路径、字节数据或URL - image_mimetypes: 图片MIME类型,当images为bytes时需要指定 - video_mimetypes: 视频MIME类型,当videos为bytes时需要指定 - audio_mimetypes: 音频MIME类型,当audios为bytes时需要指定 + videos: 视频数据 + audios: 音频数据 + image_mimetypes: 图片MIME类型,bytes数据时需要指定 + video_mimetypes: 视频MIME类型,bytes数据时需要指定 + audio_mimetypes: 音频MIME类型,bytes数据时需要指定 - Returns: + 返回: UniMessage: 构建好的多模态消息 - - Examples: - # 纯文本 - msg = create_multimodal_message("请分析这段文字") - - # 文本 + 单张图片(路径) - msg = create_multimodal_message("分析图片", images="/path/to/image.jpg") - - # 文本 + 多张图片 - msg = create_multimodal_message( - "比较图片", images=["/path/1.jpg", "/path/2.jpg"] - ) - - # 文本 + 图片字节数据 - msg = create_multimodal_message( - "分析", images=image_data, image_mimetypes="image/jpeg" - ) - - # 文本 + 视频 - msg = create_multimodal_message("分析视频", videos="/path/to/video.mp4") - - # 文本 + 音频 - msg = create_multimodal_message("转录音频", audios="/path/to/audio.wav") - - # 混合多模态 - msg = create_multimodal_message( - "分析这些媒体文件", - images="/path/to/image.jpg", - videos="/path/to/video.mp4", - audios="/path/to/audio.wav" - ) """ message = UniMessage() @@ -196,7 +185,7 @@ def _add_media_to_message( media_class: type, default_mimetype: str, ) -> None: - """添加媒体文件到 UniMessage 的辅助函数""" + """添加媒体文件到 UniMessage""" if not isinstance(media_items, list): media_items = [media_items] @@ -216,3 +205,80 @@ def _add_media_to_message( elif isinstance(item, bytes): mimetype = mime_list[i] if i < len(mime_list) else default_mimetype message.append(media_class(raw=item, mimetype=mimetype)) + + +def message_to_unimessage(message: PlatformMessage) -> UniMessage: + """ + 将平台特定的 Message 对象转换为通用的 UniMessage。 + 主要用于处理引用消息等未被自动转换的消息体。 + + 参数: + message: 平台特定的Message对象。 + + 返回: + UniMessage: 转换后的通用消息对象。 + """ + uni_segments = [] + for seg in message: + if seg.type == "text": + uni_segments.append(Text(seg.data.get("text", ""))) + elif seg.type == "image": + uni_segments.append(Image(url=seg.data.get("url"))) + elif seg.type == "record": + uni_segments.append(Voice(url=seg.data.get("url"))) + elif seg.type == "video": + uni_segments.append(Video(url=seg.data.get("url"))) + elif seg.type == "at": + uni_segments.append(At("user", str(seg.data.get("qq", "")))) + else: + logger.debug(f"跳过不支持的平台消息段类型: {seg.type}") + + return UniMessage(uni_segments) + + +def _sanitize_request_body_for_logging(body: dict) -> dict: + """ + 净化请求体用于日志记录,移除大数据字段并添加摘要信息 + + 参数: + body: 原始请求体字典。 + + 返回: + dict: 净化后的请求体字典。 + """ + try: + sanitized_body = copy.deepcopy(body) + + if "contents" in sanitized_body and isinstance( + sanitized_body["contents"], list + ): + for content_item in sanitized_body["contents"]: + if "parts" in content_item and isinstance(content_item["parts"], list): + media_summary = [] + new_parts = [] + for part in content_item["parts"]: + if "inlineData" in part and isinstance( + part["inlineData"], dict + ): + data = part["inlineData"].get("data") + if isinstance(data, str): + mime_type = part["inlineData"].get( + "mimeType", "unknown" + ) + media_summary.append(f"{mime_type} ({len(data)} chars)") + continue + new_parts.append(part) + + if media_summary: + summary_text = ( + f"[多模态内容: {len(media_summary)}个文件 - " + f"{', '.join(media_summary)}]" + ) + new_parts.insert(0, {"text": summary_text}) + + content_item["parts"] = new_parts + + return sanitized_body + except Exception as e: + logger.warning(f"日志净化失败: {e},将记录原始请求体。") + return body diff --git a/zhenxun/services/scheduler/__init__.py b/zhenxun/services/scheduler/__init__.py new file mode 100644 index 00000000..603339fd --- /dev/null +++ b/zhenxun/services/scheduler/__init__.py @@ -0,0 +1,12 @@ +""" +定时调度服务模块 + +提供一个统一的、持久化的定时任务管理器,供所有插件使用。 +""" + +from .lifecycle import _load_schedules_from_db +from .service import scheduler_manager + +_ = _load_schedules_from_db + +__all__ = ["scheduler_manager"] diff --git a/zhenxun/services/scheduler/adapter.py b/zhenxun/services/scheduler/adapter.py new file mode 100644 index 00000000..104a0457 --- /dev/null +++ b/zhenxun/services/scheduler/adapter.py @@ -0,0 +1,102 @@ +""" +引擎适配层 (Adapter) + +封装所有对具体调度器引擎 (APScheduler) 的操作, +使上层服务与调度器实现解耦。 +""" + +from nonebot_plugin_apscheduler import scheduler + +from zhenxun.models.schedule_info import ScheduleInfo +from zhenxun.services.log import logger + +from .job import _execute_job + +JOB_PREFIX = "zhenxun_schedule_" + + +class APSchedulerAdapter: + """封装对 APScheduler 的操作""" + + @staticmethod + def _get_job_id(schedule_id: int) -> str: + """生成 APScheduler 的 Job ID""" + return f"{JOB_PREFIX}{schedule_id}" + + @staticmethod + def add_or_reschedule_job(schedule: ScheduleInfo): + """根据 ScheduleInfo 添加或重新调度一个 APScheduler 任务""" + job_id = APSchedulerAdapter._get_job_id(schedule.id) + + if not isinstance(schedule.trigger_config, dict): + logger.error( + f"任务 {schedule.id} 的 trigger_config 不是字典类型: " + f"{type(schedule.trigger_config)}" + ) + return + + job = scheduler.get_job(job_id) + if job: + scheduler.reschedule_job( + job_id, trigger=schedule.trigger_type, **schedule.trigger_config + ) + logger.debug(f"已更新APScheduler任务: {job_id}") + else: + scheduler.add_job( + _execute_job, + trigger=schedule.trigger_type, + id=job_id, + misfire_grace_time=300, + args=[schedule.id], + **schedule.trigger_config, + ) + logger.debug(f"已添加新的APScheduler任务: {job_id}") + + @staticmethod + def remove_job(schedule_id: int): + """移除一个 APScheduler 任务""" + job_id = APSchedulerAdapter._get_job_id(schedule_id) + try: + scheduler.remove_job(job_id) + logger.debug(f"已从APScheduler中移除任务: {job_id}") + except Exception: + pass + + @staticmethod + def pause_job(schedule_id: int): + """暂停一个 APScheduler 任务""" + job_id = APSchedulerAdapter._get_job_id(schedule_id) + try: + scheduler.pause_job(job_id) + except Exception: + pass + + @staticmethod + def resume_job(schedule_id: int): + """恢复一个 APScheduler 任务""" + job_id = APSchedulerAdapter._get_job_id(schedule_id) + try: + scheduler.resume_job(job_id) + except Exception: + import asyncio + + from .repository import ScheduleRepository + + async def _re_add_job(): + schedule = await ScheduleRepository.get_by_id(schedule_id) + if schedule: + APSchedulerAdapter.add_or_reschedule_job(schedule) + + asyncio.create_task(_re_add_job()) # noqa: RUF006 + + @staticmethod + def get_job_status(schedule_id: int) -> dict: + """获取 APScheduler Job 的状态""" + job_id = APSchedulerAdapter._get_job_id(schedule_id) + job = scheduler.get_job(job_id) + return { + "next_run_time": job.next_run_time.strftime("%Y-%m-%d %H:%M:%S") + if job and job.next_run_time + else "N/A", + "is_paused_in_scheduler": not bool(job.next_run_time) if job else "N/A", + } diff --git a/zhenxun/services/scheduler/job.py b/zhenxun/services/scheduler/job.py new file mode 100644 index 00000000..dd7abdfc --- /dev/null +++ b/zhenxun/services/scheduler/job.py @@ -0,0 +1,192 @@ +""" +定时任务的执行逻辑 + +包含被 APScheduler 实际调度的函数,以及处理不同目标(单个、所有群组)的执行策略。 +""" + +import asyncio +import copy +import inspect +import random + +import nonebot + +from zhenxun.configs.config import Config +from zhenxun.models.schedule_info import ScheduleInfo +from zhenxun.services.log import logger +from zhenxun.utils.common_utils import CommonUtils +from zhenxun.utils.decorator.retry import Retry +from zhenxun.utils.platform import PlatformUtils + +SCHEDULE_CONCURRENCY_KEY = "all_groups_concurrency_limit" + + +async def _execute_job(schedule_id: int): + """ + APScheduler 调度的入口函数。 + 根据 schedule_id 处理特定任务、所有群组任务或全局任务。 + """ + from .repository import ScheduleRepository + from .service import scheduler_manager + + scheduler_manager._running_tasks.add(schedule_id) + try: + schedule = await ScheduleRepository.get_by_id(schedule_id) + if not schedule or not schedule.is_enabled: + logger.warning(f"定时任务 {schedule_id} 不存在或已禁用,跳过执行。") + return + + plugin_name = schedule.plugin_name + + task_meta = scheduler_manager._registered_tasks.get(plugin_name) + if not task_meta: + logger.error( + f"无法执行定时任务:插件 '{plugin_name}' 未注册或已卸载。将禁用该任务。" + ) + schedule.is_enabled = False + await ScheduleRepository.save(schedule, update_fields=["is_enabled"]) + from .adapter import APSchedulerAdapter + + APSchedulerAdapter.remove_job(schedule.id) + return + + try: + if schedule.bot_id: + bot = nonebot.get_bot(schedule.bot_id) + else: + bot = nonebot.get_bot() + logger.debug( + f"任务 {schedule_id} 未关联特定Bot,使用默认Bot {bot.self_id}" + ) + except KeyError: + logger.warning( + f"定时任务 {schedule_id} 需要的 Bot {schedule.bot_id} " + f"不在线,本次执行跳过。" + ) + return + except ValueError: + logger.warning(f"当前没有Bot在线,定时任务 {schedule_id} 跳过。") + return + + if schedule.group_id == scheduler_manager.ALL_GROUPS: + await _execute_for_all_groups(schedule, task_meta, bot) + else: + await _execute_for_single_target(schedule, task_meta, bot) + finally: + scheduler_manager._running_tasks.discard(schedule_id) + + +async def _execute_for_all_groups(schedule: ScheduleInfo, task_meta: dict, bot): + """为所有群组执行任务,并处理优先级覆盖。""" + plugin_name = schedule.plugin_name + + concurrency_limit = Config.get_config( + "SchedulerManager", SCHEDULE_CONCURRENCY_KEY, 5 + ) + if not isinstance(concurrency_limit, int) or concurrency_limit <= 0: + logger.warning( + f"无效的定时任务并发限制配置 '{concurrency_limit}',将使用默认值 5。" + ) + concurrency_limit = 5 + + logger.info( + f"开始执行针对 [所有群组] 的任务 " + f"(ID: {schedule.id}, 插件: {plugin_name}, Bot: {bot.self_id})," + f"并发限制: {concurrency_limit}" + ) + + all_gids = set() + try: + group_list, _ = await PlatformUtils.get_group_list(bot) + all_gids.update( + g.group_id for g in group_list if g.group_id and not g.channel_id + ) + except Exception as e: + logger.error(f"为 'all' 任务获取 Bot {bot.self_id} 的群列表失败", e=e) + return + + specific_tasks_gids = set( + await ScheduleInfo.filter( + plugin_name=plugin_name, group_id__in=list(all_gids) + ).values_list("group_id", flat=True) + ) + + semaphore = asyncio.Semaphore(concurrency_limit) + + async def worker(gid: str): + """使用 Semaphore 包装单个群组的任务执行""" + await asyncio.sleep(random.uniform(0, 59)) + async with semaphore: + temp_schedule = copy.deepcopy(schedule) + temp_schedule.group_id = gid + await _execute_for_single_target(temp_schedule, task_meta, bot) + await asyncio.sleep(random.uniform(0.1, 0.5)) + + tasks_to_run = [] + for gid in all_gids: + if gid in specific_tasks_gids: + logger.debug(f"群组 {gid} 已有特定任务,跳过 'all' 任务的执行。") + continue + tasks_to_run.append(worker(gid)) + + if tasks_to_run: + await asyncio.gather(*tasks_to_run) + + +async def _execute_for_single_target(schedule: ScheduleInfo, task_meta: dict, bot): + """为单个目标(具体群组或全局)执行任务。""" + + plugin_name = schedule.plugin_name + group_id = schedule.group_id + + try: + is_blocked = await CommonUtils.task_is_block(bot, plugin_name, group_id) + if is_blocked: + target_desc = f"群 {group_id}" if group_id else "全局" + logger.info( + f"插件 '{plugin_name}' 的定时任务在目标 [{target_desc}]" + "因功能被禁用而跳过执行。" + ) + return + + max_retries = Config.get_config("SchedulerManager", "JOB_MAX_RETRIES", 2) + retry_delay = Config.get_config("SchedulerManager", "JOB_RETRY_DELAY", 10) + + @Retry.simple( + stop_max_attempt=max_retries + 1, + wait_fixed_seconds=retry_delay, + log_name=f"定时任务执行:{schedule.plugin_name}", + ) + async def _execute_task_with_retry(): + task_func = task_meta["func"] + job_kwargs = schedule.job_kwargs + if not isinstance(job_kwargs, dict): + logger.error( + f"任务 {schedule.id} 的 job_kwargs 不是字典类型: {type(job_kwargs)}" + ) + return + + sig = inspect.signature(task_func) + if "bot" in sig.parameters: + job_kwargs["bot"] = bot + + await task_func(group_id, **job_kwargs) + + try: + logger.info( + f"插件 '{schedule.plugin_name}' 开始为目标 " + f"[{schedule.group_id or '全局'}] 执行定时任务 (ID: {schedule.id})。" + ) + await _execute_task_with_retry() + except Exception as e: + logger.error( + f"执行定时任务 (ID: {schedule.id}, 插件: {schedule.plugin_name}, " + f"目标: {schedule.group_id or '全局'}) 在所有重试后最终失败", + e=e, + ) + except Exception as e: + logger.error( + f"执行定时任务 (ID: {schedule.id}, 插件: {plugin_name}, " + f"目标: {group_id or '全局'}) 时发生异常", + e=e, + ) diff --git a/zhenxun/services/scheduler/lifecycle.py b/zhenxun/services/scheduler/lifecycle.py new file mode 100644 index 00000000..19f6c627 --- /dev/null +++ b/zhenxun/services/scheduler/lifecycle.py @@ -0,0 +1,62 @@ +""" +定时任务的生命周期管理 + +包含在机器人启动时加载和调度数据库中保存的任务的逻辑。 +""" + +from zhenxun.services.log import logger +from zhenxun.utils.manager.priority_manager import PriorityLifecycle + +from .adapter import APSchedulerAdapter +from .repository import ScheduleRepository +from .service import scheduler_manager + + +@PriorityLifecycle.on_startup(priority=90) +async def _load_schedules_from_db(): + """在服务启动时从数据库加载并调度所有任务。""" + logger.info("正在从数据库加载并调度所有定时任务...") + schedules = await ScheduleRepository.get_all_enabled() + count = 0 + for schedule in schedules: + if schedule.plugin_name in scheduler_manager._registered_tasks: + APSchedulerAdapter.add_or_reschedule_job(schedule) + count += 1 + else: + logger.warning(f"跳过加载定时任务:插件 '{schedule.plugin_name}' 未注册。") + logger.info(f"数据库定时任务加载完成,共成功加载 {count} 个任务。") + + logger.info("正在检查并注册声明式默认任务...") + declared_count = 0 + for task_info in scheduler_manager._declared_tasks: + plugin_name = task_info["plugin_name"] + group_id = task_info["group_id"] + bot_id = task_info["bot_id"] + + query_kwargs = { + "plugin_name": plugin_name, + "group_id": group_id, + "bot_id": bot_id, + } + exists = await ScheduleRepository.exists(**query_kwargs) + + if not exists: + logger.info(f"为插件 '{plugin_name}' 注册新的默认定时任务...") + schedule = await scheduler_manager.add_schedule( + plugin_name=plugin_name, + group_id=group_id, + trigger_type=task_info["trigger_type"], + trigger_config=task_info["trigger_config"], + job_kwargs=task_info["job_kwargs"], + bot_id=bot_id, + ) + if schedule: + declared_count += 1 + logger.debug(f"默认任务 '{plugin_name}' 注册成功 (ID: {schedule.id})") + else: + logger.error(f"默认任务 '{plugin_name}' 注册失败") + else: + logger.debug(f"插件 '{plugin_name}' 的默认任务已存在于数据库中,跳过注册。") + + if declared_count > 0: + logger.info(f"声明式任务检查完成,新注册了 {declared_count} 个默认任务。") diff --git a/zhenxun/services/scheduler/repository.py b/zhenxun/services/scheduler/repository.py new file mode 100644 index 00000000..7e168db9 --- /dev/null +++ b/zhenxun/services/scheduler/repository.py @@ -0,0 +1,79 @@ +""" +数据持久层 (Repository) + +封装所有对 ScheduleInfo 模型的数据库操作,将数据访问逻辑与业务逻辑分离。 +""" + +from typing import Any + +from tortoise.queryset import QuerySet + +from zhenxun.models.schedule_info import ScheduleInfo + + +class ScheduleRepository: + """封装 ScheduleInfo 模型的数据库操作""" + + @staticmethod + async def get_by_id(schedule_id: int) -> ScheduleInfo | None: + """通过ID获取任务""" + return await ScheduleInfo.get_or_none(id=schedule_id) + + @staticmethod + async def get_all_enabled() -> list[ScheduleInfo]: + """获取所有启用的任务""" + return await ScheduleInfo.filter(is_enabled=True).all() + + @staticmethod + async def get_all(plugin_name: str | None = None) -> list[ScheduleInfo]: + """获取所有任务,可按插件名过滤""" + if plugin_name: + return await ScheduleInfo.filter(plugin_name=plugin_name).all() + return await ScheduleInfo.all() + + @staticmethod + async def save(schedule: ScheduleInfo, update_fields: list[str] | None = None): + """保存任务""" + await schedule.save(update_fields=update_fields) + + @staticmethod + async def exists(**kwargs: Any) -> bool: + """检查任务是否存在""" + return await ScheduleInfo.exists(**kwargs) + + @staticmethod + async def get_by_plugin_and_group( + plugin_name: str, group_ids: list[str] + ) -> list[ScheduleInfo]: + """根据插件和群组ID列表获取任务""" + return await ScheduleInfo.filter( + plugin_name=plugin_name, group_id__in=group_ids + ).all() + + @staticmethod + async def update_or_create( + defaults: dict, **kwargs: Any + ) -> tuple[ScheduleInfo, bool]: + """更新或创建任务""" + return await ScheduleInfo.update_or_create(defaults=defaults, **kwargs) + + @staticmethod + async def query_schedules(**filters: Any) -> list[ScheduleInfo]: + """ + 根据任意条件查询任务列表 + + 参数: + **filters: 过滤条件,如 group_id="123", plugin_name="abc" + + 返回: + list[ScheduleInfo]: 任务列表 + """ + cleaned_filters = {k: v for k, v in filters.items() if v is not None} + if not cleaned_filters: + return await ScheduleInfo.all() + return await ScheduleInfo.filter(**cleaned_filters).all() + + @staticmethod + def filter(**kwargs: Any) -> QuerySet[ScheduleInfo]: + """提供一个通用的过滤查询接口,供Targeter使用""" + return ScheduleInfo.filter(**kwargs) diff --git a/zhenxun/services/scheduler/service.py b/zhenxun/services/scheduler/service.py new file mode 100644 index 00000000..4ed98c12 --- /dev/null +++ b/zhenxun/services/scheduler/service.py @@ -0,0 +1,448 @@ +""" +服务层 (Service) + +定义 SchedulerManager 类作为定时任务服务的公共 API 入口。 +它负责编排业务逻辑,并调用 Repository 和 Adapter 层来完成具体工作。 +""" + +from collections.abc import Callable, Coroutine +from datetime import datetime +from typing import Any, ClassVar + +import nonebot +from pydantic import BaseModel + +from zhenxun.configs.config import Config +from zhenxun.models.schedule_info import ScheduleInfo +from zhenxun.services.log import logger + +from .adapter import APSchedulerAdapter +from .job import _execute_job +from .repository import ScheduleRepository +from .targeter import ScheduleTargeter + + +class SchedulerManager: + ALL_GROUPS: ClassVar[str] = "__ALL_GROUPS__" + _registered_tasks: ClassVar[ + dict[str, dict[str, Callable | type[BaseModel] | None]] + ] = {} + _declared_tasks: ClassVar[list[dict[str, Any]]] = [] + _running_tasks: ClassVar[set] = set() + + def target(self, **filters: Any) -> ScheduleTargeter: + """ + 创建目标选择器以执行批量操作 + + 参数: + **filters: 过滤条件,支持plugin_name、group_id、bot_id等字段。 + + 返回: + ScheduleTargeter: 目标选择器对象,可用于批量操作。 + """ + return ScheduleTargeter(self, **filters) + + def task( + self, + trigger: str, + group_id: str | None = None, + bot_id: str | None = None, + **trigger_kwargs, + ): + """ + 声明式定时任务装饰器 + + 参数: + trigger: 触发器类型,如'cron'、'interval'等。 + group_id: 目标群组ID,None表示全局任务。 + bot_id: 目标Bot ID,None表示使用默认Bot。 + **trigger_kwargs: 触发器配置参数。 + + 返回: + Callable: 装饰器函数。 + """ + + def decorator(func: Callable[..., Coroutine]) -> Callable[..., Coroutine]: + try: + plugin = nonebot.get_plugin_by_module_name(func.__module__) + if not plugin: + raise ValueError(f"函数 {func.__name__} 不在任何已加载的插件中。") + plugin_name = plugin.name + + task_declaration = { + "plugin_name": plugin_name, + "func": func, + "group_id": group_id, + "bot_id": bot_id, + "trigger_type": trigger, + "trigger_config": trigger_kwargs, + "job_kwargs": {}, + } + self._declared_tasks.append(task_declaration) + logger.debug( + f"发现声明式定时任务 '{plugin_name}',将在启动时进行注册。" + ) + except Exception as e: + logger.error(f"注册声明式定时任务失败: {func.__name__}, 错误: {e}") + + return func + + return decorator + + def register( + self, plugin_name: str, params_model: type[BaseModel] | None = None + ) -> Callable: + """ + 注册可调度的任务函数 + + 参数: + plugin_name: 插件名称,用于标识任务。 + params_model: 参数验证模型,继承自BaseModel的类。 + + 返回: + Callable: 装饰器函数。 + """ + + def decorator(func: Callable[..., Coroutine]) -> Callable[..., Coroutine]: + if plugin_name in self._registered_tasks: + logger.warning(f"插件 '{plugin_name}' 的定时任务已被重复注册。") + self._registered_tasks[plugin_name] = { + "func": func, + "model": params_model, + } + model_name = params_model.__name__ if params_model else "无" + logger.debug( + f"插件 '{plugin_name}' 的定时任务已注册,参数模型: {model_name}" + ) + return func + + return decorator + + def get_registered_plugins(self) -> list[str]: + """ + 获取已注册插件列表 + + 返回: + list[str]: 已注册的插件名称列表。 + """ + return list(self._registered_tasks.keys()) + + async def add_daily_task( + self, + plugin_name: str, + group_id: str | None, + hour: int, + minute: int, + second: int = 0, + job_kwargs: dict | None = None, + bot_id: str | None = None, + ) -> "ScheduleInfo | None": + """ + 添加每日定时任务 + + 参数: + plugin_name: 插件名称。 + group_id: 目标群组ID,None表示全局任务。 + hour: 执行小时(0-23)。 + minute: 执行分钟(0-59)。 + second: 执行秒数(0-59),默认为0。 + job_kwargs: 任务参数字典。 + bot_id: 目标Bot ID,None表示使用默认Bot。 + + 返回: + ScheduleInfo | None: 创建的任务信息,失败时返回None。 + """ + trigger_config = { + "hour": hour, + "minute": minute, + "second": second, + "timezone": Config.get_config("SchedulerManager", "SCHEDULER_TIMEZONE"), + } + return await self.add_schedule( + plugin_name, + group_id, + "cron", + trigger_config, + job_kwargs=job_kwargs, + bot_id=bot_id, + ) + + async def add_interval_task( + self, + plugin_name: str, + group_id: str | None, + *, + weeks: int = 0, + days: int = 0, + hours: int = 0, + minutes: int = 0, + seconds: int = 0, + start_date: str | datetime | None = None, + job_kwargs: dict | None = None, + bot_id: str | None = None, + ) -> "ScheduleInfo | None": + """添加间隔性定时任务""" + trigger_config = { + "weeks": weeks, + "days": days, + "hours": hours, + "minutes": minutes, + "seconds": seconds, + "start_date": start_date, + } + trigger_config = {k: v for k, v in trigger_config.items() if v} + return await self.add_schedule( + plugin_name, + group_id, + "interval", + trigger_config, + job_kwargs=job_kwargs, + bot_id=bot_id, + ) + + def _validate_and_prepare_kwargs( + self, plugin_name: str, job_kwargs: dict | None + ) -> tuple[bool, str | dict]: + """验证并准备任务参数,应用默认值""" + from pydantic import ValidationError + + task_meta = self._registered_tasks.get(plugin_name) + if not task_meta: + return False, f"插件 '{plugin_name}' 未注册。" + + params_model = task_meta.get("model") + job_kwargs = job_kwargs if job_kwargs is not None else {} + + if not params_model: + if job_kwargs: + logger.warning( + f"插件 '{plugin_name}' 未定义参数模型,但收到了参数: {job_kwargs}" + ) + return True, job_kwargs + + if not (isinstance(params_model, type) and issubclass(params_model, BaseModel)): + logger.error(f"插件 '{plugin_name}' 的参数模型不是有效的 BaseModel 类") + return False, f"插件 '{plugin_name}' 的参数模型配置错误" + + try: + model_validate = getattr(params_model, "model_validate", None) + if not model_validate: + return False, f"插件 '{plugin_name}' 的参数模型不支持验证" + + validated_model = model_validate(job_kwargs) + + model_dump = getattr(validated_model, "model_dump", None) + if not model_dump: + return False, f"插件 '{plugin_name}' 的参数模型不支持导出" + + return True, model_dump() + except ValidationError as e: + errors = [f" - {err['loc'][0]}: {err['msg']}" for err in e.errors()] + error_str = "\n".join(errors) + msg = f"插件 '{plugin_name}' 的任务参数验证失败:\n{error_str}" + return False, msg + + async def add_schedule( + self, + plugin_name: str, + group_id: str | None, + trigger_type: str, + trigger_config: dict, + job_kwargs: dict | None = None, + bot_id: str | None = None, + ) -> "ScheduleInfo | None": + """ + 添加定时任务(通用方法) + + 参数: + plugin_name: 插件名称。 + group_id: 目标群组ID,None表示全局任务。 + trigger_type: 触发器类型,如'cron'、'interval'等。 + trigger_config: 触发器配置字典。 + job_kwargs: 任务参数字典。 + bot_id: 目标Bot ID,None表示使用默认Bot。 + + 返回: + ScheduleInfo | None: 创建的任务信息,失败时返回None。 + """ + if plugin_name not in self._registered_tasks: + logger.error(f"插件 '{plugin_name}' 没有注册可用的定时任务。") + return None + + is_valid, result = self._validate_and_prepare_kwargs(plugin_name, job_kwargs) + if not is_valid: + logger.error(f"任务参数校验失败: {result}") + return None + + search_kwargs = {"plugin_name": plugin_name, "group_id": group_id} + if bot_id and group_id == self.ALL_GROUPS: + search_kwargs["bot_id"] = bot_id + else: + search_kwargs["bot_id__isnull"] = True + + defaults = { + "trigger_type": trigger_type, + "trigger_config": trigger_config, + "job_kwargs": result, + "is_enabled": True, + } + + schedule, created = await ScheduleRepository.update_or_create( + defaults, **search_kwargs + ) + APSchedulerAdapter.add_or_reschedule_job(schedule) + + action = "设置" if created else "更新" + logger.info( + f"已成功{action}插件 '{plugin_name}' 的定时任务 (ID: {schedule.id})。" + ) + return schedule + + async def get_all_schedules(self) -> list[ScheduleInfo]: + """ + 获取所有定时任务信息 + """ + return await self.get_schedules() + + async def get_schedules( + self, + plugin_name: str | None = None, + group_id: str | None = None, + bot_id: str | None = None, + ) -> list[ScheduleInfo]: + """ + 根据条件获取定时任务列表 + + 参数: + plugin_name: 插件名称,None表示不限制。 + group_id: 群组ID,None表示不限制。 + bot_id: Bot ID,None表示不限制。 + + 返回: + list[ScheduleInfo]: 符合条件的任务信息列表。 + """ + return await ScheduleRepository.query_schedules( + plugin_name=plugin_name, group_id=group_id, bot_id=bot_id + ) + + async def update_schedule( + self, + schedule_id: int, + trigger_type: str | None = None, + trigger_config: dict | None = None, + job_kwargs: dict | None = None, + ) -> tuple[bool, str]: + """ + 更新定时任务配置 + + 参数: + schedule_id: 任务ID。 + trigger_type: 新的触发器类型,None表示不更新。 + trigger_config: 新的触发器配置,None表示不更新。 + job_kwargs: 新的任务参数,None表示不更新。 + + 返回: + tuple[bool, str]: (是否成功, 结果消息)。 + """ + schedule = await ScheduleRepository.get_by_id(schedule_id) + if not schedule: + return False, f"未找到 ID 为 {schedule_id} 的任务。" + + updated_fields = [] + if trigger_config is not None: + schedule.trigger_config = trigger_config + updated_fields.append("trigger_config") + if trigger_type is not None and schedule.trigger_type != trigger_type: + schedule.trigger_type = trigger_type + updated_fields.append("trigger_type") + + if job_kwargs is not None: + existing_kwargs = ( + schedule.job_kwargs.copy() + if isinstance(schedule.job_kwargs, dict) + else {} + ) + existing_kwargs.update(job_kwargs) + + is_valid, result = self._validate_and_prepare_kwargs( + schedule.plugin_name, existing_kwargs + ) + if not is_valid: + return False, str(result) + + assert isinstance(result, dict), "验证成功时 result 应该是字典类型" + schedule.job_kwargs = result + updated_fields.append("job_kwargs") + + if not updated_fields: + return True, "没有任何需要更新的配置。" + + await ScheduleRepository.save(schedule, update_fields=updated_fields) + APSchedulerAdapter.add_or_reschedule_job(schedule) + return True, f"成功更新了任务 ID: {schedule_id} 的配置。" + + async def get_schedule_status(self, schedule_id: int) -> dict | None: + """获取定时任务的详细状态信息""" + schedule = await ScheduleRepository.get_by_id(schedule_id) + if not schedule: + return None + + status_from_scheduler = APSchedulerAdapter.get_job_status(schedule.id) + + status_text = ( + "运行中" + if schedule_id in self._running_tasks + else ("启用" if schedule.is_enabled else "暂停") + ) + + return { + "id": schedule.id, + "bot_id": schedule.bot_id, + "plugin_name": schedule.plugin_name, + "group_id": schedule.group_id, + "is_enabled": status_text, + "trigger_type": schedule.trigger_type, + "trigger_config": schedule.trigger_config, + "job_kwargs": schedule.job_kwargs, + **status_from_scheduler, + } + + async def pause_schedule(self, schedule_id: int) -> tuple[bool, str]: + """暂停指定的定时任务""" + schedule = await ScheduleRepository.get_by_id(schedule_id) + if not schedule or not schedule.is_enabled: + return False, "任务不存在或已暂停。" + + schedule.is_enabled = False + await ScheduleRepository.save(schedule, update_fields=["is_enabled"]) + APSchedulerAdapter.pause_job(schedule_id) + return True, f"已暂停任务 (ID: {schedule.id})。" + + async def resume_schedule(self, schedule_id: int) -> tuple[bool, str]: + """恢复指定的定时任务""" + schedule = await ScheduleRepository.get_by_id(schedule_id) + if not schedule or schedule.is_enabled: + return False, "任务不存在或已启用。" + + schedule.is_enabled = True + await ScheduleRepository.save(schedule, update_fields=["is_enabled"]) + APSchedulerAdapter.resume_job(schedule_id) + return True, f"已恢复任务 (ID: {schedule.id})。" + + async def trigger_now(self, schedule_id: int) -> tuple[bool, str]: + """立即手动触发指定的定时任务""" + schedule = await ScheduleRepository.get_by_id(schedule_id) + if not schedule: + return False, f"未找到 ID 为 {schedule_id} 的定时任务。" + if schedule.plugin_name not in self._registered_tasks: + return False, f"插件 '{schedule.plugin_name}' 没有注册可用的定时任务。" + + try: + await _execute_job(schedule.id) + return True, f"已手动触发任务 (ID: {schedule.id})。" + except Exception as e: + logger.error(f"手动触发任务失败: {e}") + return False, f"手动触发任务失败: {e}" + + +scheduler_manager = SchedulerManager() diff --git a/zhenxun/services/scheduler/targeter.py b/zhenxun/services/scheduler/targeter.py new file mode 100644 index 00000000..a5b3277f --- /dev/null +++ b/zhenxun/services/scheduler/targeter.py @@ -0,0 +1,109 @@ +""" +目标选择器 (Targeter) + +提供链式API,用于构建和执行对多个定时任务的批量操作。 +""" + +from collections.abc import Callable, Coroutine +from typing import Any + +from .adapter import APSchedulerAdapter +from .repository import ScheduleRepository + + +class ScheduleTargeter: + """ + 一个用于构建和执行定时任务批量操作的目标选择器。 + """ + + def __init__(self, manager: Any, **filters: Any): + """初始化目标选择器""" + self._manager = manager + self._filters = {k: v for k, v in filters.items() if v is not None} + + async def _get_schedules(self): + """根据过滤器获取任务""" + query = ScheduleRepository.filter(**self._filters) + return await query.all() + + def _generate_target_description(self) -> str: + """根据过滤条件生成友好的目标描述""" + if "id" in self._filters: + return f"任务 ID {self._filters['id']} 的" + + parts = [] + if "group_id" in self._filters: + group_id = self._filters["group_id"] + if group_id == self._manager.ALL_GROUPS: + parts.append("所有群组中") + else: + parts.append(f"群 {group_id} 中") + + if "plugin_name" in self._filters: + parts.append(f"插件 '{self._filters['plugin_name']}' 的") + + if not parts: + return "所有" + + return "".join(parts) + + async def _apply_operation( + self, + operation_func: Callable[[int], Coroutine[Any, Any, tuple[bool, str]]], + operation_name: str, + ) -> tuple[int, str]: + """通用的操作应用模板""" + schedules = await self._get_schedules() + if not schedules: + target_desc = self._generate_target_description() + return 0, f"没有找到{target_desc}可供{operation_name}的任务。" + + success_count = 0 + for schedule in schedules: + success, _ = await operation_func(schedule.id) + if success: + success_count += 1 + + target_desc = self._generate_target_description() + return ( + success_count, + f"成功{operation_name}了{target_desc} {success_count} 个任务。", + ) + + async def pause(self) -> tuple[int, str]: + """ + 暂停匹配的定时任务 + + 返回: + tuple[int, str]: (成功暂停的任务数量, 操作结果消息)。 + """ + return await self._apply_operation(self._manager.pause_schedule, "暂停") + + async def resume(self) -> tuple[int, str]: + """ + 恢复匹配的定时任务 + + 返回: + tuple[int, str]: (成功恢复的任务数量, 操作结果消息)。 + """ + return await self._apply_operation(self._manager.resume_schedule, "恢复") + + async def remove(self) -> tuple[int, str]: + """ + 移除匹配的定时任务 + + 返回: + tuple[int, str]: (成功移除的任务数量, 操作结果消息)。 + """ + schedules = await self._get_schedules() + if not schedules: + target_desc = self._generate_target_description() + return 0, f"没有找到{target_desc}可供移除的任务。" + + for schedule in schedules: + APSchedulerAdapter.remove_job(schedule.id) + + query = ScheduleRepository.filter(**self._filters) + count = await query.delete() + target_desc = self._generate_target_description() + return count, f"成功移除了{target_desc} {count} 个任务。" diff --git a/zhenxun/utils/browser.py b/zhenxun/utils/browser.py index ca2e7755..310ed606 100644 --- a/zhenxun/utils/browser.py +++ b/zhenxun/utils/browser.py @@ -1,91 +1,94 @@ -import os -import sys +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from pathlib import Path +from typing import Any, Literal -from nonebot import get_driver -from playwright.__main__ import main -from playwright.async_api import Browser, Playwright, async_playwright +from nonebot_plugin_alconna import UniMessage +from nonebot_plugin_htmlrender import get_browser +from playwright.async_api import Page -from zhenxun.configs.config import BotConfig -from zhenxun.services.log import logger - -driver = get_driver() - -_playwright: Playwright | None = None -_browser: Browser | None = None +from zhenxun.utils.message import MessageUtils -# @driver.on_startup -# async def start_browser(): -# global _playwright -# global _browser -# install() -# await check_playwright_env() -# _playwright = await async_playwright().start() -# _browser = await _playwright.chromium.launch() +class BrowserIsNone(Exception): + pass -# @driver.on_shutdown -# async def shutdown_browser(): -# if _browser: -# await _browser.close() -# if _playwright: -# await _playwright.stop() # type: ignore +class AsyncPlaywright: + @classmethod + @asynccontextmanager + async def new_page( + cls, cookies: list[dict[str, Any]] | dict[str, Any] | None = None, **kwargs + ) -> AsyncGenerator[Page, None]: + """获取一个新页面 - -# def get_browser() -> Browser: -# if not _browser: -# raise RuntimeError("playwright is not initalized") -# return _browser - - -def install(): - """自动安装、更新 Chromium""" - - def set_env_variables(): - os.environ["PLAYWRIGHT_DOWNLOAD_HOST"] = ( - "https://npmmirror.com/mirrors/playwright/" - ) - if BotConfig.system_proxy: - os.environ["HTTPS_PROXY"] = BotConfig.system_proxy - - def restore_env_variables(): - os.environ.pop("PLAYWRIGHT_DOWNLOAD_HOST", None) - if BotConfig.system_proxy: - os.environ.pop("HTTPS_PROXY", None) - if original_proxy is not None: - os.environ["HTTPS_PROXY"] = original_proxy - - def try_install_chromium(): + 参数: + cookies: cookies + """ + browser = await get_browser() + ctx = await browser.new_context(**kwargs) + if cookies: + if isinstance(cookies, dict): + cookies = [cookies] + await ctx.add_cookies(cookies) # type: ignore + page = await ctx.new_page() try: - sys.argv = ["", "install", "chromium"] - main() - except SystemExit as e: - return e.code == 0 - return False + yield page + finally: + await page.close() + await ctx.close() - logger.info("检查 Chromium 更新") + @classmethod + async def screenshot( + cls, + url: str, + path: Path | str, + element: str | list[str], + *, + wait_time: int | None = None, + viewport_size: dict[str, int] | None = None, + wait_until: ( + Literal["domcontentloaded", "load", "networkidle"] | None + ) = "networkidle", + timeout: float | None = None, + type_: Literal["jpeg", "png"] | None = None, + user_agent: str | None = None, + cookies: list[dict[str, Any]] | dict[str, Any] | None = None, + **kwargs, + ) -> UniMessage | None: + """截图,该方法仅用于简单快捷截图,复杂截图请操作 page - original_proxy = os.environ.get("HTTPS_PROXY") - set_env_variables() - - success = try_install_chromium() - - if not success: - logger.info("Chromium 更新失败,尝试从原始仓库下载,速度较慢") - os.environ["PLAYWRIGHT_DOWNLOAD_HOST"] = "" - success = try_install_chromium() - - restore_env_variables() - - if not success: - raise RuntimeError("未知错误,Chromium 下载失败") - - -async def check_playwright_env(): - """检查 Playwright 依赖""" - logger.info("检查 Playwright 依赖") - try: - async with async_playwright() as p: - await p.chromium.launch() - except Exception as e: - raise ImportError("加载失败,Playwright 依赖不全,") from e + 参数: + url: 网址 + path: 存储路径 + element: 元素选择 + wait_time: 等待截取超时时间 + viewport_size: 窗口大小 + wait_until: 等待类型 + timeout: 超时限制 + type_: 保存类型 + user_agent: user_agent + cookies: cookies + """ + if viewport_size is None: + viewport_size = {"width": 2560, "height": 1080} + if isinstance(path, str): + path = Path(path) + wait_time = wait_time * 1000 if wait_time else None + element_list = [element] if isinstance(element, str) else element + async with cls.new_page( + cookies, + viewport=viewport_size, + user_agent=user_agent, + **kwargs, + ) as page: + await page.goto(url, timeout=timeout, wait_until=wait_until) + card = page + for e in element_list: + if not card: + return None + card = await card.wait_for_selector(e, timeout=wait_time) + if card: + await card.screenshot(path=path, timeout=timeout, type=type_) + return MessageUtils.build_message(path) + return None diff --git a/zhenxun/utils/decorator/retry.py b/zhenxun/utils/decorator/retry.py index ddc55584..e81aa334 100644 --- a/zhenxun/utils/decorator/retry.py +++ b/zhenxun/utils/decorator/retry.py @@ -1,24 +1,226 @@ +from collections.abc import Callable +from functools import partial, wraps +from typing import Any, Literal + from anyio import EndOfStream -from httpx import ConnectError, HTTPStatusError, TimeoutException -from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed +from httpx import ( + ConnectError, + HTTPStatusError, + RemoteProtocolError, + StreamError, + TimeoutException, +) +from nonebot.utils import is_coroutine_callable +from tenacity import ( + RetryCallState, + retry, + retry_if_exception_type, + retry_if_result, + stop_after_attempt, + wait_exponential, + wait_fixed, +) + +from zhenxun.services.log import logger + +LOG_COMMAND = "RetryDecorator" +_SENTINEL = object() + + +def _log_before_sleep(log_name: str | None, retry_state: RetryCallState): + """ + tenacity 重试前的日志记录回调函数。 + """ + func_name = retry_state.fn.__name__ if retry_state.fn else "unknown_function" + log_context = f"函数 '{func_name}'" + if log_name: + log_context = f"操作 '{log_name}' ({log_context})" + + reason = "" + if retry_state.outcome: + if exc := retry_state.outcome.exception(): + reason = f"触发异常: {exc.__class__.__name__}({exc})" + else: + reason = f"不满足结果条件: result={retry_state.outcome.result()}" + + wait_time = ( + getattr(retry_state.next_action, "sleep", 0) if retry_state.next_action else 0 + ) + logger.warning( + f"{log_context} 第 {retry_state.attempt_number} 次重试... " + f"等待 {wait_time:.2f} 秒. {reason}", + LOG_COMMAND, + ) class Retry: @staticmethod - def api( - retry_count: int = 3, wait: int = 1, exception: tuple[type[Exception], ...] = () + def simple( + stop_max_attempt: int = 3, + wait_fixed_seconds: int = 2, + exception: tuple[type[Exception], ...] = (), + *, + log_name: str | None = None, + on_failure: Callable[[Exception], Any] | None = None, + return_on_failure: Any = _SENTINEL, ): - """接口调用重试""" + """ + 一个简单的、用于通用网络请求的重试装饰器预设。 + + 参数: + stop_max_attempt: 最大重试次数。 + wait_fixed_seconds: 固定等待策略的等待秒数。 + exception: 额外需要重试的异常类型元组。 + log_name: 用于日志记录的操作名称。 + on_failure: (可选) 所有重试失败后的回调。 + return_on_failure: (可选) 所有重试失败后的返回值。 + """ + return Retry.api( + stop_max_attempt=stop_max_attempt, + wait_fixed_seconds=wait_fixed_seconds, + exception=exception, + strategy="fixed", + log_name=log_name, + on_failure=on_failure, + return_on_failure=return_on_failure, + ) + + @staticmethod + def download( + stop_max_attempt: int = 3, + exception: tuple[type[Exception], ...] = (), + *, + wait_exp_multiplier: int = 2, + wait_exp_max: int = 15, + log_name: str | None = None, + on_failure: Callable[[Exception], Any] | None = None, + return_on_failure: Any = _SENTINEL, + ): + """ + 一个适用于文件下载的重试装饰器预设,使用指数退避策略。 + + 参数: + stop_max_attempt: 最大重试次数。 + exception: 额外需要重试的异常类型元组。 + wait_exp_multiplier: 指数退避的乘数。 + wait_exp_max: 指数退避的最大等待时间。 + log_name: 用于日志记录的操作名称。 + on_failure: (可选) 所有重试失败后的回调。 + return_on_failure: (可选) 所有重试失败后的返回值。 + """ + return Retry.api( + stop_max_attempt=stop_max_attempt, + exception=exception, + strategy="exponential", + wait_exp_multiplier=wait_exp_multiplier, + wait_exp_max=wait_exp_max, + log_name=log_name, + on_failure=on_failure, + return_on_failure=return_on_failure, + ) + + @staticmethod + def api( + stop_max_attempt: int = 3, + wait_fixed_seconds: int = 1, + exception: tuple[type[Exception], ...] = (), + *, + strategy: Literal["fixed", "exponential"] = "fixed", + retry_on_result: Callable[[Any], bool] | None = None, + wait_exp_multiplier: int = 1, + wait_exp_max: int = 10, + log_name: str | None = None, + on_failure: Callable[[Exception], Any] | None = None, + return_on_failure: Any = _SENTINEL, + ): + """ + 通用、可配置的API调用重试装饰器。 + + 参数: + stop_max_attempt: 最大重试次数。 + wait_fixed_seconds: 固定等待策略的等待秒数。 + exception: 额外需要重试的异常类型元组。 + strategy: 重试等待策略, 'fixed' (固定) 或 'exponential' (指数退避)。 + retry_on_result: 一个回调函数,接收函数返回值。如果返回 True,则触发重试。 + 例如 `lambda r: r.status_code != 200` + wait_exp_multiplier: 指数退避的乘数。 + wait_exp_max: 指数退避的最大等待时间。 + log_name: 用于日志记录的操作名称,方便区分不同的重试场景。 + on_failure: (可选) 当所有重试都失败后,在抛出异常或返回默认值之前, + 会调用此函数,并将最终的异常实例作为参数传入。 + return_on_failure: (可选) 如果设置了此参数,当所有重试失败后, + 将不再抛出异常,而是返回此参数指定的值。 + """ base_exceptions = ( TimeoutException, ConnectError, HTTPStatusError, + StreamError, + RemoteProtocolError, EndOfStream, *exception, ) - return retry( - reraise=True, - stop=stop_after_attempt(retry_count), - wait=wait_fixed(wait), - retry=retry_if_exception_type(base_exceptions), - ) + + def decorator(func: Callable) -> Callable: + if strategy == "exponential": + wait_strategy = wait_exponential( + multiplier=wait_exp_multiplier, max=wait_exp_max + ) + else: + wait_strategy = wait_fixed(wait_fixed_seconds) + + retry_conditions = retry_if_exception_type(base_exceptions) + if retry_on_result: + retry_conditions |= retry_if_result(retry_on_result) + + log_callback = partial(_log_before_sleep, log_name) + + tenacity_retry_decorator = retry( + stop=stop_after_attempt(stop_max_attempt), + wait=wait_strategy, + retry=retry_conditions, + before_sleep=log_callback, + reraise=True, + ) + + decorated_func = tenacity_retry_decorator(func) + + if return_on_failure is _SENTINEL: + return decorated_func + + if is_coroutine_callable(func): + + @wraps(func) + async def async_wrapper(*args, **kwargs): + try: + return await decorated_func(*args, **kwargs) + except Exception as e: + if on_failure: + if is_coroutine_callable(on_failure): + await on_failure(e) + else: + on_failure(e) + return return_on_failure + + return async_wrapper + else: + + @wraps(func) + def sync_wrapper(*args, **kwargs): + try: + return decorated_func(*args, **kwargs) + except Exception as e: + if on_failure: + if is_coroutine_callable(on_failure): + logger.error( + f"不能在同步函数 '{func.__name__}' 中调用异步的 " + f"on_failure 回调。", + LOG_COMMAND, + ) + else: + on_failure(e) + return return_on_failure + + return sync_wrapper + + return decorator diff --git a/zhenxun/utils/exception.py b/zhenxun/utils/exception.py index 8ec925ec..9ab664f4 100644 --- a/zhenxun/utils/exception.py +++ b/zhenxun/utils/exception.py @@ -64,3 +64,23 @@ class GoodsNotFound(Exception): """ pass + + +class AllURIsFailedError(Exception): + """ + 当所有备用URL都尝试失败后抛出此异常 + """ + + def __init__(self, urls: list[str], exceptions: list[Exception]): + self.urls = urls + self.exceptions = exceptions + super().__init__( + f"All {len(urls)} URIs failed. Last exception: {exceptions[-1]}" + ) + + def __str__(self) -> str: + exc_info = "\n".join( + f" - {url}: {exc.__class__.__name__}({exc})" + for url, exc in zip(self.urls, self.exceptions) + ) + return f"All {len(self.urls)} URIs failed:\n{exc_info}" diff --git a/zhenxun/utils/http_utils.py b/zhenxun/utils/http_utils.py index 0ccf777f..9f00e9af 100644 --- a/zhenxun/utils/http_utils.py +++ b/zhenxun/utils/http_utils.py @@ -1,16 +1,15 @@ import asyncio -from collections.abc import AsyncGenerator, Sequence +from collections.abc import AsyncGenerator, Awaitable, Callable, Sequence from contextlib import asynccontextmanager +import os from pathlib import Path import time -from typing import Any, ClassVar, Literal, cast +from typing import Any, ClassVar, cast import aiofiles import httpx -from httpx import AsyncHTTPTransport, HTTPStatusError, Proxy, Response -from nonebot_plugin_alconna import UniMessage -from nonebot_plugin_htmlrender import get_browser -from playwright.async_api import Page +from httpx import AsyncClient, AsyncHTTPTransport, HTTPStatusError, Proxy, Response +import nonebot from rich.progress import ( BarColumn, DownloadColumn, @@ -18,13 +17,84 @@ from rich.progress import ( TextColumn, TransferSpeedColumn, ) +import ujson as json from zhenxun.configs.config import BotConfig from zhenxun.services.log import logger -from zhenxun.utils.message import MessageUtils +from zhenxun.utils.decorator.retry import Retry +from zhenxun.utils.exception import AllURIsFailedError +from zhenxun.utils.manager.priority_manager import PriorityLifecycle from zhenxun.utils.user_agent import get_user_agent -CLIENT_KEY = ["use_proxy", "proxies", "proxy", "verify", "headers"] +from .browser import AsyncPlaywright, BrowserIsNone # noqa: F401 + +_SENTINEL = object() + +driver = nonebot.get_driver() +_client: AsyncClient | None = None + + +@PriorityLifecycle.on_startup(priority=0) +async def _(): + """ + 在Bot启动时初始化全局httpx客户端。 + """ + global _client + client_kwargs = {} + if proxy_url := BotConfig.system_proxy or None: + try: + version_parts = httpx.__version__.split(".") + major = int("".join(c for c in version_parts[0] if c.isdigit())) + minor = ( + int("".join(c for c in version_parts[1] if c.isdigit())) + if len(version_parts) > 1 + else 0 + ) + if (major, minor) >= (0, 28): + client_kwargs["proxy"] = proxy_url + else: + client_kwargs["proxies"] = proxy_url + except (ValueError, IndexError): + client_kwargs["proxy"] = proxy_url + logger.warning( + f"无法解析 httpx 版本 '{httpx.__version__}'," + "将默认使用新版 'proxy' 参数语法。" + ) + + _client = httpx.AsyncClient( + headers=get_user_agent(), + follow_redirects=True, + **client_kwargs, + ) + + logger.info("全局 httpx.AsyncClient 已启动。", "HTTPClient") + + +@driver.on_shutdown +async def _(): + """ + 在Bot关闭时关闭全局httpx客户端。 + """ + if _client: + await _client.aclose() + logger.info("全局 httpx.AsyncClient 已关闭。", "HTTPClient") + + +def get_client() -> AsyncClient: + """ + 获取全局 httpx.AsyncClient 实例。 + """ + global _client + if not _client: + if not os.environ.get("PYTEST_CURRENT_TEST"): + raise RuntimeError("全局 httpx.AsyncClient 未初始化,请检查启动流程。") + # 在测试环境中创建临时客户端 + logger.warning("在测试环境中创建临时HTTP客户端", "HTTPClient") + _client = httpx.AsyncClient( + headers=get_user_agent(), + follow_redirects=True, + ) + return _client def get_async_client( @@ -33,6 +103,10 @@ def get_async_client( verify: bool = False, **kwargs, ) -> httpx.AsyncClient: + """ + [向后兼容] 创建 httpx.AsyncClient 实例的工厂函数。 + 此函数完全保留了旧版本的接口,确保现有代码无需修改即可使用。 + """ transport = kwargs.pop("transport", None) or AsyncHTTPTransport(verify=verify) if proxies: http_proxy = proxies.get("http://") @@ -62,6 +136,30 @@ def get_async_client( class AsyncHttpx: + """ + 一个高级的、健壮的异步HTTP客户端工具类。 + + 设计理念: + - **全局共享客户端**: 默认情况下,所有请求都通过一个在应用启动时初始化的全局 + `httpx.AsyncClient` 实例发出。这个实例共享连接池,提高了效率和性能。 + - **向后兼容与灵活性**: 完全兼容旧的API,同时提供了两种方式来处理需要 + 特殊网络配置(如不同代理、超时)的请求: + 1. **单次请求覆盖**: 在调用 `get`, `post` 等方法时,直接传入 `proxies`, + `timeout` 等参数,将为该次请求创建一个临时的、独立的客户端。 + 2. **临时客户端上下文**: 使用 `temporary_client()` 上下文管理器,可以 + 获取一个独立的、可配置的客户端,用于执行一系列需要相同特殊配置的请求。 + - **健壮性**: 内置了自动重试、多镜像URL回退(fallback)机制,并提供了便捷的 + JSON解析和文件下载方法。 + """ + + CLIENT_KEY: ClassVar[list[str]] = [ + "use_proxy", + "proxies", + "proxy", + "verify", + "headers", + ] + default_proxy: ClassVar[dict[str, str] | None] = ( { "http://": BotConfig.system_proxy, @@ -72,155 +170,346 @@ class AsyncHttpx: ) @classmethod - @asynccontextmanager - async def _create_client( - cls, - *, - use_proxy: bool = True, - proxies: dict[str, str] | None = None, - proxy: str | None = None, - headers: dict[str, str] | None = None, - verify: bool = False, - **kwargs, - ) -> AsyncGenerator[httpx.AsyncClient, None]: - """创建一个私有的、配置好的 httpx.AsyncClient 上下文管理器。 + def _prepare_temporary_client_config(cls, client_kwargs: dict) -> dict: + """ + [向后兼容] 处理旧式的客户端kwargs,将其转换为get_async_client可用的配置。 + 主要负责处理 use_proxy 标志,这是为了兼容旧版本代码中使用的 use_proxy 参数。 + """ + final_config = client_kwargs.copy() - 说明: - 此方法用于内部统一创建客户端,处理代理和请求头逻辑,减少代码重复。 + use_proxy = final_config.pop("use_proxy", True) + + if "proxies" not in final_config and "proxy" not in final_config: + final_config["proxies"] = cls.default_proxy if use_proxy else None + return final_config + + @classmethod + def _split_kwargs(cls, kwargs: dict) -> tuple[dict, dict]: + """[优化] 分离客户端配置和请求参数,使逻辑更清晰。""" + client_kwargs = {k: v for k, v in kwargs.items() if k in cls.CLIENT_KEY} + request_kwargs = {k: v for k, v in kwargs.items() if k not in cls.CLIENT_KEY} + return client_kwargs, request_kwargs + + @classmethod + @asynccontextmanager + async def _get_active_client_context( + cls, client: AsyncClient | None = None, **kwargs + ) -> AsyncGenerator[AsyncClient, None]: + """ + 内部辅助方法,根据 kwargs 决定并提供一个活动的 HTTP 客户端。 + - 如果 kwargs 中有客户端配置,则创建并返回一个临时客户端。 + - 否则,返回传入的 client 或全局客户端。 + - 自动处理临时客户端的关闭。 + """ + if kwargs: + logger.debug(f"为单次请求创建临时客户端,配置: {kwargs}") + temp_client_config = cls._prepare_temporary_client_config(kwargs) + async with get_async_client(**temp_client_config) as temp_client: + yield temp_client + else: + yield client or get_client() + + @Retry.simple(log_name="内部HTTP请求") + async def _execute_request_inner( + self, client: AsyncClient, method: str, url: str, **kwargs + ) -> Response: + """ + [内部] 执行单次HTTP请求的私有核心方法,被重试装饰器包裹。 + """ + return await client.request(method, url, **kwargs) + + @classmethod + async def _single_request( + cls, method: str, url: str, *, client: AsyncClient | None = None, **kwargs + ) -> Response: + """ + 执行单次HTTP请求的私有方法,内置了默认的重试逻辑。 + """ + client_kwargs, request_kwargs = cls._split_kwargs(kwargs) + + async with cls._get_active_client_context( + client=client, **client_kwargs + ) as active_client: + response = await cls()._execute_request_inner( + active_client, method, url, **request_kwargs + ) + response.raise_for_status() + return response + + @classmethod + async def _execute_with_fallbacks( + cls, + urls: str | list[str], + worker: Callable[..., Awaitable[Any]], + *, + client: AsyncClient | None = None, + **kwargs, + ) -> Any: + """ + 通用执行器,按顺序尝试多个URL,直到成功。 参数: - use_proxy: 是否使用在类中定义的默认代理。 - proxies: 手动指定的代理,会覆盖默认代理。 - proxy: 单个代理,用于兼容旧版本,不再使用 - headers: 需要合并到客户端的自定义请求头。 - verify: 是否验证 SSL 证书。 - **kwargs: 其他所有传递给 httpx.AsyncClient 的参数。 - - 返回: - AsyncGenerator[httpx.AsyncClient, None]: 生成器。 + urls: 单个URL或URL列表。 + worker: 一个接受单个URL和其他kwargs并执行请求的协程函数。 + client: 可选的HTTP客户端。 + **kwargs: 传递给worker的额外参数。 """ - proxies_to_use = proxies or (cls.default_proxy if use_proxy else None) + url_list = [urls] if isinstance(urls, str) else urls + exceptions = [] - final_headers = get_user_agent() - if headers: - final_headers.update(headers) + for i, url in enumerate(url_list): + try: + result = await worker(url, client=client, **kwargs) + if i > 0: + logger.info( + f"成功从镜像 '{url}' 获取资源 " + f"(在尝试了 {i} 个失败的镜像之后)。", + "AsyncHttpx:FallbackExecutor", + ) + return result + except Exception as e: + exceptions.append(e) + if url != url_list[-1]: + logger.warning( + f"Worker '{worker.__name__}' on {url} failed, trying next. " + f"Error: {e.__class__.__name__}", + "AsyncHttpx:FallbackExecutor", + ) - async with get_async_client( - proxies=proxies_to_use, - proxy=proxy, - verify=verify, - headers=final_headers, - **kwargs, - ) as client: - yield client + raise AllURIsFailedError(url_list, exceptions) @classmethod async def get( cls, url: str | list[str], *, + follow_redirects: bool = True, check_status_code: int | None = None, + client: AsyncClient | None = None, **kwargs, - ) -> Response: # sourcery skip: use-assigned-variable + ) -> Response: """发送 GET 请求,并返回第一个成功的响应。 说明: - 本方法是 httpx.get 的高级包装,增加了多链接尝试、自动重试和统一的代理管理。 - 如果提供 URL 列表,它将依次尝试直到成功为止。 + 本方法是 httpx.get 的高级包装,增加了多链接尝试、自动重试和统一的 + 客户端管理。如果提供 URL 列表,它将依次尝试直到成功为止。 + + 用法建议: + - **常规使用**: `await AsyncHttpx.get(url)` 将使用全局客户端。 + - **单次覆盖配置**: `await AsyncHttpx.get(url, timeout=5, proxies=None)` + 将为本次请求创建一个独立的临时客户端。 参数: url: 单个请求 URL 或一个 URL 列表。 + follow_redirects: 是否跟随重定向。 check_status_code: (可选) 若提供,将检查响应状态码是否匹配,否则抛出异常。 - **kwargs: 其他所有传递给 httpx.get 的参数 - (如 `params`, `headers`, `timeout`等)。 + client: (可选) 指定一个活动的HTTP客户端实例。若提供,则忽略 + `**kwargs`中的客户端配置。 + **kwargs: 其他所有传递给 httpx.get 的参数 (如 `params`, `headers`, + `timeout`)。如果包含 `proxies`, `verify` 等客户端配置参数, + 将创建一个临时客户端。 返回: - Response: Response + Response: httpx 的响应对象。 + + Raises: + AllURIsFailedError: 当所有提供的URL都请求失败时抛出。 """ - urls = [url] if isinstance(url, str) else url - last_exception = None - for current_url in urls: - try: - logger.info(f"开始获取 {current_url}..") - client_kwargs = {k: v for k, v in kwargs.items() if k in CLIENT_KEY} - for key in CLIENT_KEY: - kwargs.pop(key, None) - async with cls._create_client(**client_kwargs) as client: - response = await client.get(current_url, **kwargs) - if check_status_code and response.status_code != check_status_code: - raise HTTPStatusError( - f"状态码错误: {response.status_code}!={check_status_code}", - request=response.request, - response=response, - ) - return response - except Exception as e: - last_exception = e - if current_url != urls[-1]: - logger.warning(f"获取 {current_url} 失败, 尝试下一个", e=e) + async def worker(current_url: str, **worker_kwargs) -> Response: + logger.info(f"开始获取 {current_url}..", "AsyncHttpx:get") + response = await cls._single_request( + "GET", current_url, follow_redirects=follow_redirects, **worker_kwargs + ) + if check_status_code and response.status_code != check_status_code: + raise HTTPStatusError( + f"状态码错误: {response.status_code}!={check_status_code}", + request=response.request, + response=response, + ) + return response - raise last_exception or Exception("所有URL都获取失败") + return await cls._execute_with_fallbacks(url, worker, client=client, **kwargs) @classmethod - async def head(cls, url: str, **kwargs) -> Response: - """发送 HEAD 请求。 + async def head( + cls, url: str | list[str], *, client: AsyncClient | None = None, **kwargs + ) -> Response: + """发送 HEAD 请求,并返回第一个成功的响应。""" - 说明: - 本方法是对 httpx.head 的封装,通常用于检查资源的元信息(如大小、类型)。 + async def worker(current_url: str, **worker_kwargs) -> Response: + return await cls._single_request("HEAD", current_url, **worker_kwargs) - 参数: - url: 请求的 URL。 - **kwargs: 其他所有传递给 httpx.head 的参数 - (如 `headers`, `timeout`, `allow_redirects`)。 - - 返回: - Response: Response - """ - client_kwargs = {k: v for k, v in kwargs.items() if k in CLIENT_KEY} - for key in CLIENT_KEY: - kwargs.pop(key, None) - async with cls._create_client(**client_kwargs) as client: - return await client.head(url, **kwargs) + return await cls._execute_with_fallbacks(url, worker, client=client, **kwargs) @classmethod - async def post(cls, url: str, **kwargs) -> Response: - """发送 POST 请求。 + async def post( + cls, url: str | list[str], *, client: AsyncClient | None = None, **kwargs + ) -> Response: + """发送 POST 请求,并返回第一个成功的响应。""" - 说明: - 本方法是对 httpx.post 的封装,提供了统一的代理和客户端管理。 + async def worker(current_url: str, **worker_kwargs) -> Response: + return await cls._single_request("POST", current_url, **worker_kwargs) - 参数: - url: 请求的 URL。 - **kwargs: 其他所有传递给 httpx.post 的参数 - (如 `data`, `json`, `content` 等)。 - - 返回: - Response: Response。 - """ - client_kwargs = {k: v for k, v in kwargs.items() if k in CLIENT_KEY} - for key in CLIENT_KEY: - kwargs.pop(key, None) - async with cls._create_client(**client_kwargs) as client: - return await client.post(url, **kwargs) + return await cls._execute_with_fallbacks(url, worker, client=client, **kwargs) @classmethod - async def get_content(cls, url: str, **kwargs) -> bytes: - """获取指定 URL 的二进制内容。 - - 说明: - 这是一个便捷方法,等同于调用 get() 后再访问 .content 属性。 - - 参数: - url: 请求的 URL。 - **kwargs: 所有传递给 get() 方法的参数。 - - 返回: - bytes: 响应内容的二进制字节流 (bytes)。 - """ - res = await cls.get(url, **kwargs) + async def get_content( + cls, url: str | list[str], *, client: AsyncClient | None = None, **kwargs + ) -> bytes: + """获取指定 URL 的二进制内容。""" + res = await cls.get(url, client=client, **kwargs) return res.content + @classmethod + @Retry.api( + log_name="JSON请求", + exception=(json.JSONDecodeError,), + return_on_failure=_SENTINEL, + ) + async def _request_and_parse_json( + cls, method: str, url: str, *, client: AsyncClient | None = None, **kwargs + ) -> Any: + """ + [私有] 执行单个HTTP请求并解析JSON,用于内部统一处理。 + """ + async with cls._get_active_client_context( + client=client, **kwargs + ) as active_client: + _, request_kwargs = cls._split_kwargs(kwargs) + response = await active_client.request(method, url, **request_kwargs) + response.raise_for_status() + return response.json() + + @classmethod + async def get_json( + cls, + url: str | list[str], + *, + default: Any = None, + raise_on_failure: bool = False, + client: AsyncClient | None = None, + **kwargs, + ) -> Any: + """ + 发送GET请求并自动解析为JSON,支持重试和多链接尝试。 + + 说明: + 这是一个高度便捷的方法,封装了请求、重试、JSON解析和错误处理。 + 它会在网络错误或JSON解析错误时自动重试。 + 如果所有尝试都失败,它会安全地返回一个默认值。 + + 参数: + url: 单个请求 URL 或一个备用 URL 列表。 + default: (可选) 当所有尝试都失败时返回的默认值,默认为None。 + raise_on_failure: (可选) 如果为 True, 当所有尝试失败时将抛出 + `AllURIsFailedError` 异常, 默认为 False. + client: (可选) 指定的HTTP客户端。 + **kwargs: 其他所有传递给 httpx.get 的参数。 + 例如 `params`, `headers`, `timeout`等。 + + 返回: + Any: 解析后的JSON数据,或在失败时返回 `default` 值。 + + Raises: + AllURIsFailedError: 当 `raise_on_failure` 为 True 且所有URL都请求失败时抛出 + """ + + async def worker(current_url: str, **worker_kwargs): + logger.debug(f"开始GET JSON: {current_url}", "AsyncHttpx:get_json") + return await cls._request_and_parse_json( + "GET", current_url, **worker_kwargs + ) + + try: + result = await cls._execute_with_fallbacks( + url, worker, client=client, **kwargs + ) + return default if result is _SENTINEL else result + except AllURIsFailedError as e: + logger.error(f"所有URL的JSON GET均失败: {e}", "AsyncHttpx:get_json") + if raise_on_failure: + raise e + return default + + @classmethod + async def post_json( + cls, + url: str | list[str], + *, + json: Any = None, + data: Any = None, + default: Any = None, + raise_on_failure: bool = False, + client: AsyncClient | None = None, + **kwargs, + ) -> Any: + """ + 发送POST请求并自动解析为JSON,功能与 get_json 类似。 + + 参数: + url: 单个请求 URL 或一个备用 URL 列表。 + json: (可选) 作为请求体发送的JSON数据。 + data: (可选) 作为请求体发送的表单数据。 + default: (可选) 当所有尝试都失败时返回的默认值,默认为None。 + raise_on_failure: (可选) 如果为 True, 当所有尝试失败时将抛出 + AllURIsFailedError 异常, 默认为 False. + client: (可选) 指定的HTTP客户端。 + **kwargs: 其他所有传递给 httpx.post 的参数。 + + 返回: + Any: 解析后的JSON数据,或在失败时返回 `default` 值。 + """ + if json is not None: + kwargs["json"] = json + if data is not None: + kwargs["data"] = data + + async def worker(current_url: str, **worker_kwargs): + logger.debug(f"开始POST JSON: {current_url}", "AsyncHttpx:post_json") + return await cls._request_and_parse_json( + "POST", current_url, **worker_kwargs + ) + + try: + result = await cls._execute_with_fallbacks( + url, worker, client=client, **kwargs + ) + return default if result is _SENTINEL else result + except AllURIsFailedError as e: + logger.error(f"所有URL的JSON POST均失败: {e}", "AsyncHttpx:post_json") + if raise_on_failure: + raise e + return default + + @classmethod + @Retry.api(log_name="文件下载(流式)") + async def _stream_download( + cls, url: str, path: Path, *, client: AsyncClient | None = None, **kwargs + ) -> None: + """ + 执行单个流式下载的私有方法,被重试装饰器包裹。 + """ + async with cls._get_active_client_context( + client=client, **kwargs + ) as active_client: + async with active_client.stream("GET", url, **kwargs) as response: + response.raise_for_status() + total = int(response.headers.get("Content-Length", 0)) + + with Progress( + TextColumn(path.name), + "[progress.percentage]{task.percentage:>3.0f}%", + BarColumn(bar_width=None), + DownloadColumn(), + TransferSpeedColumn(), + ) as progress: + task_id = progress.add_task("Download", total=total) + async with aiofiles.open(path, "wb") as f: + async for chunk in response.aiter_bytes(): + await f.write(chunk) + progress.update(task_id, advance=len(chunk)) + @classmethod async def download_file( cls, @@ -228,6 +517,7 @@ class AsyncHttpx: path: str | Path, *, stream: bool = False, + client: AsyncClient | None = None, **kwargs, ) -> bool: """下载文件到指定路径。 @@ -239,6 +529,7 @@ class AsyncHttpx: url: 单个文件 URL 或一个备用 URL 列表。 path: 文件保存的本地路径。 stream: (可选) 是否使用流式下载,适用于大文件,默认为 False。 + client: (可选) 指定的HTTP客户端。 **kwargs: 其他所有传递给 get() 方法或 httpx.stream() 的参数。 返回: @@ -247,49 +538,29 @@ class AsyncHttpx: path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) - urls = [url] if isinstance(url, str) else url + async def worker(current_url: str, **worker_kwargs) -> bool: + if not stream: + content = await cls.get_content(current_url, **worker_kwargs) + async with aiofiles.open(path, "wb") as f: + await f.write(content) + else: + await cls._stream_download(current_url, path, **worker_kwargs) - for current_url in urls: - try: - if not stream: - response = await cls.get(current_url, **kwargs) - response.raise_for_status() - async with aiofiles.open(path, "wb") as f: - await f.write(response.content) - else: - async with cls._create_client(**kwargs) as client: - stream_kwargs = { - k: v - for k, v in kwargs.items() - if k not in ["use_proxy", "proxy", "verify"] - } - async with client.stream( - "GET", current_url, **stream_kwargs - ) as response: - response.raise_for_status() - total = int(response.headers.get("Content-Length", 0)) + logger.info( + f"下载 {current_url} 成功 -> {path.absolute()}", + "AsyncHttpx:download", + ) + return True - with Progress( - TextColumn(path.name), - "[progress.percentage]{task.percentage:>3.0f}%", - BarColumn(bar_width=None), - DownloadColumn(), - TransferSpeedColumn(), - ) as progress: - task_id = progress.add_task("Download", total=total) - async with aiofiles.open(path, "wb") as f: - async for chunk in response.aiter_bytes(): - await f.write(chunk) - progress.update(task_id, advance=len(chunk)) - - logger.info(f"下载 {current_url} 成功 -> {path.absolute()}") - return True - - except Exception as e: - logger.warning(f"下载 {current_url} 失败,尝试下一个。错误: {e}") - - logger.error(f"所有URL {urls} 下载均失败 -> {path.absolute()}") - return False + try: + return await cls._execute_with_fallbacks( + url, worker, client=client, **kwargs + ) + except AllURIsFailedError: + logger.error( + f"所有URL下载均失败 -> {path.absolute()}", "AsyncHttpx:download" + ) + return False @classmethod async def gather_download_file( @@ -346,7 +617,6 @@ class AsyncHttpx: logger.error(f"并发下载任务 ({url_info}) 时发生错误", e=result) final_results.append(False) else: - # download_file 返回的是 bool,可以直接附加 final_results.append(cast(bool, result)) return final_results @@ -395,86 +665,30 @@ class AsyncHttpx: _results = sorted(iter(_results), key=lambda r: r["elapsed_time"]) return [result["url"] for result in _results] - -class AsyncPlaywright: @classmethod @asynccontextmanager - async def new_page( - cls, cookies: list[dict[str, Any]] | dict[str, Any] | None = None, **kwargs - ) -> AsyncGenerator[Page, None]: - """获取一个新页面 + async def temporary_client(cls, **kwargs) -> AsyncGenerator[AsyncClient, None]: + """ + 创建一个临时的、可配置的HTTP客户端上下文,并直接返回该客户端实例。 + + 此方法返回一个标准的 `httpx.AsyncClient`,它不使用全局连接池, + 拥有独立的配置(如代理、headers、超时等),并在退出上下文后自动关闭。 + 适用于需要用一套特殊网络配置执行一系列请求的场景。 + + 用法: + async with AsyncHttpx.temporary_client(proxies=None, timeout=5) as client: + # client 是一个标准的 httpx.AsyncClient 实例 + response1 = await client.get("http://some.internal.api/1") + response2 = await client.get("http://some.internal.api/2") + data = response2.json() 参数: - cookies: cookies + **kwargs: 所有传递给 `httpx.AsyncClient` 构造函数的参数。 + 例如: `proxies`, `headers`, `verify`, `timeout`, + `follow_redirects`。 + + Yields: + httpx.AsyncClient: 一个配置好的、临时的客户端实例。 """ - browser = await get_browser() - ctx = await browser.new_context(**kwargs) - if cookies: - if isinstance(cookies, dict): - cookies = [cookies] - await ctx.add_cookies(cookies) # type: ignore - page = await ctx.new_page() - try: - yield page - finally: - await page.close() - await ctx.close() - - @classmethod - async def screenshot( - cls, - url: str, - path: Path | str, - element: str | list[str], - *, - wait_time: int | None = None, - viewport_size: dict[str, int] | None = None, - wait_until: ( - Literal["domcontentloaded", "load", "networkidle"] | None - ) = "networkidle", - timeout: float | None = None, - type_: Literal["jpeg", "png"] | None = None, - user_agent: str | None = None, - cookies: list[dict[str, Any]] | dict[str, Any] | None = None, - **kwargs, - ) -> UniMessage | None: - """截图,该方法仅用于简单快捷截图,复杂截图请操作 page - - 参数: - url: 网址 - path: 存储路径 - element: 元素选择 - wait_time: 等待截取超时时间 - viewport_size: 窗口大小 - wait_until: 等待类型 - timeout: 超时限制 - type_: 保存类型 - user_agent: user_agent - cookies: cookies - """ - if viewport_size is None: - viewport_size = {"width": 2560, "height": 1080} - if isinstance(path, str): - path = Path(path) - wait_time = wait_time * 1000 if wait_time else None - element_list = [element] if isinstance(element, str) else element - async with cls.new_page( - cookies, - viewport=viewport_size, - user_agent=user_agent, - **kwargs, - ) as page: - await page.goto(url, timeout=timeout, wait_until=wait_until) - card = page - for e in element_list: - if not card: - return None - card = await card.wait_for_selector(e, timeout=wait_time) - if card: - await card.screenshot(path=path, timeout=timeout, type=type_) - return MessageUtils.build_message(path) - return None - - -class BrowserIsNone(Exception): - pass + async with get_async_client(**kwargs) as client: + yield client diff --git a/zhenxun/utils/manager/schedule_manager.py b/zhenxun/utils/manager/schedule_manager.py deleted file mode 100644 index a3b21272..00000000 --- a/zhenxun/utils/manager/schedule_manager.py +++ /dev/null @@ -1,810 +0,0 @@ -import asyncio -from collections.abc import Callable, Coroutine -import copy -import inspect -import random -from typing import ClassVar - -import nonebot -from nonebot import get_bots -from nonebot_plugin_apscheduler import scheduler -from pydantic import BaseModel, ValidationError - -from zhenxun.configs.config import Config -from zhenxun.models.schedule_info import ScheduleInfo -from zhenxun.services.log import logger -from zhenxun.utils.common_utils import CommonUtils -from zhenxun.utils.manager.priority_manager import PriorityLifecycle -from zhenxun.utils.platform import PlatformUtils - -SCHEDULE_CONCURRENCY_KEY = "all_groups_concurrency_limit" - - -class SchedulerManager: - """ - 一个通用的、持久化的定时任务管理器,供所有插件使用。 - """ - - _registered_tasks: ClassVar[ - dict[str, dict[str, Callable | type[BaseModel] | None]] - ] = {} - _JOB_PREFIX = "zhenxun_schedule_" - _running_tasks: ClassVar[set] = set() - - def register( - self, plugin_name: str, params_model: type[BaseModel] | None = None - ) -> Callable: - """ - 注册一个可调度的任务函数。 - 被装饰的函数签名应为 `async def func(group_id: str | None, **kwargs)` - - Args: - plugin_name (str): 插件的唯一名称 (通常是模块名)。 - params_model (type[BaseModel], optional): 一个 Pydantic BaseModel 类, - 用于定义和验证任务函数接受的额外参数。 - """ - - def decorator(func: Callable[..., Coroutine]) -> Callable[..., Coroutine]: - if plugin_name in self._registered_tasks: - logger.warning(f"插件 '{plugin_name}' 的定时任务已被重复注册。") - self._registered_tasks[plugin_name] = { - "func": func, - "model": params_model, - } - model_name = params_model.__name__ if params_model else "无" - logger.debug( - f"插件 '{plugin_name}' 的定时任务已注册,参数模型: {model_name}" - ) - return func - - return decorator - - def get_registered_plugins(self) -> list[str]: - """获取所有已注册定时任务的插件列表。""" - return list(self._registered_tasks.keys()) - - def _get_job_id(self, schedule_id: int) -> str: - """根据数据库ID生成唯一的 APScheduler Job ID。""" - return f"{self._JOB_PREFIX}{schedule_id}" - - async def _execute_job(self, schedule_id: int): - """ - APScheduler 调度的入口函数。 - 根据 schedule_id 处理特定任务、所有群组任务或全局任务。 - """ - schedule = await ScheduleInfo.get_or_none(id=schedule_id) - if not schedule or not schedule.is_enabled: - logger.warning(f"定时任务 {schedule_id} 不存在或已禁用,跳过执行。") - return - - plugin_name = schedule.plugin_name - - task_meta = self._registered_tasks.get(plugin_name) - if not task_meta: - logger.error( - f"无法执行定时任务:插件 '{plugin_name}' 未注册或已卸载。将禁用该任务。" - ) - schedule.is_enabled = False - await schedule.save(update_fields=["is_enabled"]) - self._remove_aps_job(schedule.id) - return - - try: - if schedule.bot_id: - bot = nonebot.get_bot(schedule.bot_id) - else: - bot = nonebot.get_bot() - logger.debug( - f"任务 {schedule_id} 未关联特定Bot,使用默认Bot {bot.self_id}" - ) - except KeyError: - logger.warning( - f"定时任务 {schedule_id} 需要的 Bot {schedule.bot_id} " - f"不在线,本次执行跳过。" - ) - return - except ValueError: - logger.warning(f"当前没有Bot在线,定时任务 {schedule_id} 跳过。") - return - - if schedule.group_id == "__ALL_GROUPS__": - await self._execute_for_all_groups(schedule, task_meta, bot) - else: - await self._execute_for_single_target(schedule, task_meta, bot) - - async def _execute_for_all_groups( - self, schedule: ScheduleInfo, task_meta: dict, bot - ): - """为所有群组执行任务,并处理优先级覆盖。""" - plugin_name = schedule.plugin_name - - concurrency_limit = Config.get_config( - "SchedulerManager", SCHEDULE_CONCURRENCY_KEY, 5 - ) - if not isinstance(concurrency_limit, int) or concurrency_limit <= 0: - logger.warning( - f"无效的定时任务并发限制配置 '{concurrency_limit}',将使用默认值 5。" - ) - concurrency_limit = 5 - - logger.info( - f"开始执行针对 [所有群组] 的任务 " - f"(ID: {schedule.id}, 插件: {plugin_name}, Bot: {bot.self_id})," - f"并发限制: {concurrency_limit}" - ) - - all_gids = set() - try: - group_list, _ = await PlatformUtils.get_group_list(bot) - all_gids.update( - g.group_id for g in group_list if g.group_id and not g.channel_id - ) - except Exception as e: - logger.error(f"为 'all' 任务获取 Bot {bot.self_id} 的群列表失败", e=e) - return - - specific_tasks_gids = set( - await ScheduleInfo.filter( - plugin_name=plugin_name, group_id__in=list(all_gids) - ).values_list("group_id", flat=True) - ) - - semaphore = asyncio.Semaphore(concurrency_limit) - - async def worker(gid: str): - """使用 Semaphore 包装单个群组的任务执行""" - async with semaphore: - temp_schedule = copy.deepcopy(schedule) - temp_schedule.group_id = gid - await self._execute_for_single_target(temp_schedule, task_meta, bot) - await asyncio.sleep(random.uniform(0.1, 0.5)) - - tasks_to_run = [] - for gid in all_gids: - if gid in specific_tasks_gids: - logger.debug(f"群组 {gid} 已有特定任务,跳过 'all' 任务的执行。") - continue - tasks_to_run.append(worker(gid)) - - if tasks_to_run: - await asyncio.gather(*tasks_to_run) - - async def _execute_for_single_target( - self, schedule: ScheduleInfo, task_meta: dict, bot - ): - """为单个目标(具体群组或全局)执行任务。""" - plugin_name = schedule.plugin_name - group_id = schedule.group_id - - try: - is_blocked = await CommonUtils.task_is_block(bot, plugin_name, group_id) - if is_blocked: - target_desc = f"群 {group_id}" if group_id else "全局" - logger.info( - f"插件 '{plugin_name}' 的定时任务在目标 [{target_desc}]" - "因功能被禁用而跳过执行。" - ) - return - - task_func = task_meta["func"] - job_kwargs = schedule.job_kwargs - if not isinstance(job_kwargs, dict): - logger.error( - f"任务 {schedule.id} 的 job_kwargs 不是字典类型: {type(job_kwargs)}" - ) - return - - sig = inspect.signature(task_func) - if "bot" in sig.parameters: - job_kwargs["bot"] = bot - - logger.info( - f"插件 '{plugin_name}' 开始为目标 [{group_id or '全局'}] " - f"执行定时任务 (ID: {schedule.id})。" - ) - task = asyncio.create_task(task_func(group_id, **job_kwargs)) - self._running_tasks.add(task) - task.add_done_callback(self._running_tasks.discard) - await task - except Exception as e: - logger.error( - f"执行定时任务 (ID: {schedule.id}, 插件: {plugin_name}, " - f"目标: {group_id or '全局'}) 时发生异常", - e=e, - ) - - def _validate_and_prepare_kwargs( - self, plugin_name: str, job_kwargs: dict | None - ) -> tuple[bool, str | dict]: - """验证并准备任务参数,应用默认值""" - task_meta = self._registered_tasks.get(plugin_name) - if not task_meta: - return False, f"插件 '{plugin_name}' 未注册。" - - params_model = task_meta.get("model") - job_kwargs = job_kwargs if job_kwargs is not None else {} - - if not params_model: - if job_kwargs: - logger.warning( - f"插件 '{plugin_name}' 未定义参数模型,但收到了参数: {job_kwargs}" - ) - return True, job_kwargs - - if not (isinstance(params_model, type) and issubclass(params_model, BaseModel)): - logger.error(f"插件 '{plugin_name}' 的参数模型不是有效的 BaseModel 类") - return False, f"插件 '{plugin_name}' 的参数模型配置错误" - - try: - model_validate = getattr(params_model, "model_validate", None) - if not model_validate: - return False, f"插件 '{plugin_name}' 的参数模型不支持验证" - - validated_model = model_validate(job_kwargs) - - model_dump = getattr(validated_model, "model_dump", None) - if not model_dump: - return False, f"插件 '{plugin_name}' 的参数模型不支持导出" - - return True, model_dump() - except ValidationError as e: - errors = [f" - {err['loc'][0]}: {err['msg']}" for err in e.errors()] - error_str = "\n".join(errors) - msg = f"插件 '{plugin_name}' 的任务参数验证失败:\n{error_str}" - return False, msg - - def _add_aps_job(self, schedule: ScheduleInfo): - """根据 ScheduleInfo 对象添加或更新一个 APScheduler 任务。""" - job_id = self._get_job_id(schedule.id) - try: - scheduler.remove_job(job_id) - except Exception: - pass - - if not isinstance(schedule.trigger_config, dict): - logger.error( - f"任务 {schedule.id} 的 trigger_config 不是字典类型: " - f"{type(schedule.trigger_config)}" - ) - return - - scheduler.add_job( - self._execute_job, - trigger=schedule.trigger_type, - id=job_id, - misfire_grace_time=300, - args=[schedule.id], - **schedule.trigger_config, - ) - logger.debug( - f"已在 APScheduler 中添加/更新任务: {job_id} " - f"with trigger: {schedule.trigger_config}" - ) - - def _remove_aps_job(self, schedule_id: int): - """移除一个 APScheduler 任务。""" - job_id = self._get_job_id(schedule_id) - try: - scheduler.remove_job(job_id) - logger.debug(f"已从 APScheduler 中移除任务: {job_id}") - except Exception: - pass - - async def add_schedule( - self, - plugin_name: str, - group_id: str | None, - trigger_type: str, - trigger_config: dict, - job_kwargs: dict | None = None, - bot_id: str | None = None, - ) -> tuple[bool, str]: - """ - 添加或更新一个定时任务。 - """ - if plugin_name not in self._registered_tasks: - return False, f"插件 '{plugin_name}' 没有注册可用的定时任务。" - - is_valid, result = self._validate_and_prepare_kwargs(plugin_name, job_kwargs) - if not is_valid: - return False, str(result) - - validated_job_kwargs = result - - effective_bot_id = bot_id if group_id == "__ALL_GROUPS__" else None - - search_kwargs = { - "plugin_name": plugin_name, - "group_id": group_id, - } - if effective_bot_id: - search_kwargs["bot_id"] = effective_bot_id - else: - search_kwargs["bot_id__isnull"] = True - - defaults = { - "trigger_type": trigger_type, - "trigger_config": trigger_config, - "job_kwargs": validated_job_kwargs, - "is_enabled": True, - } - - schedule = await ScheduleInfo.filter(**search_kwargs).first() - created = False - - if schedule: - for key, value in defaults.items(): - setattr(schedule, key, value) - await schedule.save() - else: - creation_kwargs = { - "plugin_name": plugin_name, - "group_id": group_id, - "bot_id": effective_bot_id, - **defaults, - } - schedule = await ScheduleInfo.create(**creation_kwargs) - created = True - self._add_aps_job(schedule) - action = "设置" if created else "更新" - return True, f"已成功{action}插件 '{plugin_name}' 的定时任务。" - - async def add_schedule_for_all( - self, - plugin_name: str, - trigger_type: str, - trigger_config: dict, - job_kwargs: dict | None = None, - ) -> tuple[int, int]: - """为所有机器人所在的群组添加定时任务。""" - if plugin_name not in self._registered_tasks: - raise ValueError(f"插件 '{plugin_name}' 没有注册可用的定时任务。") - - groups = set() - for bot in get_bots().values(): - try: - group_list, _ = await PlatformUtils.get_group_list(bot) - groups.update( - g.group_id for g in group_list if g.group_id and not g.channel_id - ) - except Exception as e: - logger.error(f"获取 Bot {bot.self_id} 的群列表失败", e=e) - - success_count = 0 - fail_count = 0 - for gid in groups: - try: - success, _ = await self.add_schedule( - plugin_name, gid, trigger_type, trigger_config, job_kwargs - ) - if success: - success_count += 1 - else: - fail_count += 1 - except Exception as e: - logger.error(f"为群 {gid} 添加定时任务失败: {e}", e=e) - fail_count += 1 - await asyncio.sleep(0.05) - return success_count, fail_count - - async def update_schedule( - self, - schedule_id: int, - trigger_type: str | None = None, - trigger_config: dict | None = None, - job_kwargs: dict | None = None, - ) -> tuple[bool, str]: - """部分更新一个已存在的定时任务。""" - schedule = await self.get_schedule_by_id(schedule_id) - if not schedule: - return False, f"未找到 ID 为 {schedule_id} 的任务。" - - updated_fields = [] - if trigger_config is not None: - schedule.trigger_config = trigger_config - updated_fields.append("trigger_config") - - if trigger_type is not None and schedule.trigger_type != trigger_type: - schedule.trigger_type = trigger_type - updated_fields.append("trigger_type") - - if job_kwargs is not None: - if not isinstance(schedule.job_kwargs, dict): - return False, f"任务 {schedule_id} 的 job_kwargs 数据格式错误。" - - merged_kwargs = schedule.job_kwargs.copy() - merged_kwargs.update(job_kwargs) - - is_valid, result = self._validate_and_prepare_kwargs( - schedule.plugin_name, merged_kwargs - ) - if not is_valid: - return False, str(result) - - schedule.job_kwargs = result # type: ignore - updated_fields.append("job_kwargs") - - if not updated_fields: - return True, "没有任何需要更新的配置。" - - await schedule.save(update_fields=updated_fields) - self._add_aps_job(schedule) - return True, f"成功更新了任务 ID: {schedule_id} 的配置。" - - async def remove_schedule( - self, plugin_name: str, group_id: str | None, bot_id: str | None = None - ) -> tuple[bool, str]: - """移除指定插件和群组的定时任务。""" - query = {"plugin_name": plugin_name, "group_id": group_id} - if bot_id: - query["bot_id"] = bot_id - - schedules = await ScheduleInfo.filter(**query) - if not schedules: - msg = ( - f"未找到与 Bot {bot_id} 相关的群 {group_id} " - f"的插件 '{plugin_name}' 定时任务。" - ) - return (False, msg) - - for schedule in schedules: - self._remove_aps_job(schedule.id) - await schedule.delete() - - target_desc = f"群 {group_id}" if group_id else "全局" - msg = ( - f"已取消 Bot {bot_id} 在 [{target_desc}] " - f"的插件 '{plugin_name}' 所有定时任务。" - ) - return (True, msg) - - async def remove_schedule_for_all( - self, plugin_name: str, bot_id: str | None = None - ) -> int: - """移除指定插件在所有群组的定时任务。""" - query = {"plugin_name": plugin_name} - if bot_id: - query["bot_id"] = bot_id - - schedules_to_delete = await ScheduleInfo.filter(**query).all() - if not schedules_to_delete: - return 0 - - for schedule in schedules_to_delete: - self._remove_aps_job(schedule.id) - await schedule.delete() - await asyncio.sleep(0.01) - - return len(schedules_to_delete) - - async def remove_schedules_by_group(self, group_id: str) -> tuple[bool, str]: - """移除指定群组的所有定时任务。""" - schedules = await ScheduleInfo.filter(group_id=group_id) - if not schedules: - return False, f"群 {group_id} 没有任何定时任务。" - - count = 0 - for schedule in schedules: - self._remove_aps_job(schedule.id) - await schedule.delete() - count += 1 - await asyncio.sleep(0.01) - - return True, f"已成功移除群 {group_id} 的 {count} 个定时任务。" - - async def pause_schedules_by_group(self, group_id: str) -> tuple[int, str]: - """暂停指定群组的所有定时任务。""" - schedules = await ScheduleInfo.filter(group_id=group_id, is_enabled=True) - if not schedules: - return 0, f"群 {group_id} 没有正在运行的定时任务可暂停。" - - count = 0 - for schedule in schedules: - success, _ = await self.pause_schedule(schedule.id) - if success: - count += 1 - await asyncio.sleep(0.01) - - return count, f"已成功暂停群 {group_id} 的 {count} 个定时任务。" - - async def resume_schedules_by_group(self, group_id: str) -> tuple[int, str]: - """恢复指定群组的所有定时任务。""" - schedules = await ScheduleInfo.filter(group_id=group_id, is_enabled=False) - if not schedules: - return 0, f"群 {group_id} 没有已暂停的定时任务可恢复。" - - count = 0 - for schedule in schedules: - success, _ = await self.resume_schedule(schedule.id) - if success: - count += 1 - await asyncio.sleep(0.01) - - return count, f"已成功恢复群 {group_id} 的 {count} 个定时任务。" - - async def pause_schedules_by_plugin(self, plugin_name: str) -> tuple[int, str]: - """暂停指定插件在所有群组的定时任务。""" - schedules = await ScheduleInfo.filter(plugin_name=plugin_name, is_enabled=True) - if not schedules: - return 0, f"插件 '{plugin_name}' 没有正在运行的定时任务可暂停。" - - count = 0 - for schedule in schedules: - success, _ = await self.pause_schedule(schedule.id) - if success: - count += 1 - await asyncio.sleep(0.01) - - return ( - count, - f"已成功暂停插件 '{plugin_name}' 在所有群组的 {count} 个定时任务。", - ) - - async def resume_schedules_by_plugin(self, plugin_name: str) -> tuple[int, str]: - """恢复指定插件在所有群组的定时任务。""" - schedules = await ScheduleInfo.filter(plugin_name=plugin_name, is_enabled=False) - if not schedules: - return 0, f"插件 '{plugin_name}' 没有已暂停的定时任务可恢复。" - - count = 0 - for schedule in schedules: - success, _ = await self.resume_schedule(schedule.id) - if success: - count += 1 - await asyncio.sleep(0.01) - - return ( - count, - f"已成功恢复插件 '{plugin_name}' 在所有群组的 {count} 个定时任务。", - ) - - async def pause_schedule_by_plugin_group( - self, plugin_name: str, group_id: str | None, bot_id: str | None = None - ) -> tuple[bool, str]: - """暂停指定插件在指定群组的定时任务。""" - query = {"plugin_name": plugin_name, "group_id": group_id, "is_enabled": True} - if bot_id: - query["bot_id"] = bot_id - - schedules = await ScheduleInfo.filter(**query) - if not schedules: - return ( - False, - f"群 {group_id} 未设置插件 '{plugin_name}' 的定时任务或任务已暂停。", - ) - - count = 0 - for schedule in schedules: - success, _ = await self.pause_schedule(schedule.id) - if success: - count += 1 - - return ( - True, - f"已成功暂停群 {group_id} 的插件 '{plugin_name}' 共 {count} 个定时任务。", - ) - - async def resume_schedule_by_plugin_group( - self, plugin_name: str, group_id: str | None, bot_id: str | None = None - ) -> tuple[bool, str]: - """恢复指定插件在指定群组的定时任务。""" - query = {"plugin_name": plugin_name, "group_id": group_id, "is_enabled": False} - if bot_id: - query["bot_id"] = bot_id - - schedules = await ScheduleInfo.filter(**query) - if not schedules: - return ( - False, - f"群 {group_id} 未设置插件 '{plugin_name}' 的定时任务或任务已启用。", - ) - - count = 0 - for schedule in schedules: - success, _ = await self.resume_schedule(schedule.id) - if success: - count += 1 - - return ( - True, - f"已成功恢复群 {group_id} 的插件 '{plugin_name}' 共 {count} 个定时任务。", - ) - - async def remove_all_schedules(self) -> tuple[int, str]: - """移除所有群组的所有定时任务。""" - schedules = await ScheduleInfo.all() - if not schedules: - return 0, "当前没有任何定时任务。" - - count = 0 - for schedule in schedules: - self._remove_aps_job(schedule.id) - await schedule.delete() - count += 1 - await asyncio.sleep(0.01) - - return count, f"已成功移除所有群组的 {count} 个定时任务。" - - async def pause_all_schedules(self) -> tuple[int, str]: - """暂停所有群组的所有定时任务。""" - schedules = await ScheduleInfo.filter(is_enabled=True) - if not schedules: - return 0, "当前没有正在运行的定时任务可暂停。" - - count = 0 - for schedule in schedules: - success, _ = await self.pause_schedule(schedule.id) - if success: - count += 1 - await asyncio.sleep(0.01) - - return count, f"已成功暂停所有群组的 {count} 个定时任务。" - - async def resume_all_schedules(self) -> tuple[int, str]: - """恢复所有群组的所有定时任务。""" - schedules = await ScheduleInfo.filter(is_enabled=False) - if not schedules: - return 0, "当前没有已暂停的定时任务可恢复。" - - count = 0 - for schedule in schedules: - success, _ = await self.resume_schedule(schedule.id) - if success: - count += 1 - await asyncio.sleep(0.01) - - return count, f"已成功恢复所有群组的 {count} 个定时任务。" - - async def remove_schedule_by_id(self, schedule_id: int) -> tuple[bool, str]: - """通过ID移除指定的定时任务。""" - schedule = await self.get_schedule_by_id(schedule_id) - if not schedule: - return False, f"未找到 ID 为 {schedule_id} 的定时任务。" - - self._remove_aps_job(schedule.id) - await schedule.delete() - - return ( - True, - f"已删除插件 '{schedule.plugin_name}' 在群 {schedule.group_id} " - f"的定时任务 (ID: {schedule.id})。", - ) - - async def get_schedule_by_id(self, schedule_id: int) -> ScheduleInfo | None: - """通过ID获取定时任务信息。""" - return await ScheduleInfo.get_or_none(id=schedule_id) - - async def get_schedules( - self, plugin_name: str, group_id: str | None - ) -> list[ScheduleInfo]: - """获取特定群组特定插件的所有定时任务。""" - return await ScheduleInfo.filter(plugin_name=plugin_name, group_id=group_id) - - async def get_schedule( - self, plugin_name: str, group_id: str | None - ) -> ScheduleInfo | None: - """获取特定群组的定时任务信息。""" - return await ScheduleInfo.get_or_none( - plugin_name=plugin_name, group_id=group_id - ) - - async def get_all_schedules( - self, plugin_name: str | None = None - ) -> list[ScheduleInfo]: - """获取所有定时任务信息,可按插件名过滤。""" - if plugin_name: - return await ScheduleInfo.filter(plugin_name=plugin_name).all() - return await ScheduleInfo.all() - - async def get_schedule_status(self, schedule_id: int) -> dict | None: - """获取任务的详细状态。""" - schedule = await self.get_schedule_by_id(schedule_id) - if not schedule: - return None - - job_id = self._get_job_id(schedule.id) - job = scheduler.get_job(job_id) - - status = { - "id": schedule.id, - "bot_id": schedule.bot_id, - "plugin_name": schedule.plugin_name, - "group_id": schedule.group_id, - "is_enabled": schedule.is_enabled, - "trigger_type": schedule.trigger_type, - "trigger_config": schedule.trigger_config, - "job_kwargs": schedule.job_kwargs, - "next_run_time": job.next_run_time.strftime("%Y-%m-%d %H:%M:%S") - if job and job.next_run_time - else "N/A", - "is_paused_in_scheduler": not bool(job.next_run_time) if job else "N/A", - } - return status - - async def pause_schedule(self, schedule_id: int) -> tuple[bool, str]: - """暂停一个定时任务。""" - schedule = await self.get_schedule_by_id(schedule_id) - if not schedule or not schedule.is_enabled: - return False, "任务不存在或已暂停。" - - schedule.is_enabled = False - await schedule.save(update_fields=["is_enabled"]) - - job_id = self._get_job_id(schedule.id) - try: - scheduler.pause_job(job_id) - except Exception: - pass - - return ( - True, - f"已暂停插件 '{schedule.plugin_name}' 在群 {schedule.group_id} " - f"的定时任务 (ID: {schedule.id})。", - ) - - async def resume_schedule(self, schedule_id: int) -> tuple[bool, str]: - """恢复一个定时任务。""" - schedule = await self.get_schedule_by_id(schedule_id) - if not schedule or schedule.is_enabled: - return False, "任务不存在或已启用。" - - schedule.is_enabled = True - await schedule.save(update_fields=["is_enabled"]) - - job_id = self._get_job_id(schedule.id) - try: - scheduler.resume_job(job_id) - except Exception: - self._add_aps_job(schedule) - - return ( - True, - f"已恢复插件 '{schedule.plugin_name}' 在群 {schedule.group_id} " - f"的定时任务 (ID: {schedule.id})。", - ) - - async def trigger_now(self, schedule_id: int) -> tuple[bool, str]: - """手动触发一个定时任务。""" - schedule = await self.get_schedule_by_id(schedule_id) - if not schedule: - return False, f"未找到 ID 为 {schedule_id} 的定时任务。" - - if schedule.plugin_name not in self._registered_tasks: - return False, f"插件 '{schedule.plugin_name}' 没有注册可用的定时任务。" - - try: - await self._execute_job(schedule.id) - return ( - True, - f"已手动触发插件 '{schedule.plugin_name}' 在群 {schedule.group_id} " - f"的定时任务 (ID: {schedule.id})。", - ) - except Exception as e: - logger.error(f"手动触发任务失败: {e}") - return False, f"手动触发任务失败: {e}" - - -scheduler_manager = SchedulerManager() - - -@PriorityLifecycle.on_startup(priority=90) -async def _load_schedules_from_db(): - """在服务启动时从数据库加载并调度所有任务。""" - Config.add_plugin_config( - "SchedulerManager", - SCHEDULE_CONCURRENCY_KEY, - 5, - help="“所有群组”类型定时任务的并发执行数量限制", - type=int, - ) - - logger.info("正在从数据库加载并调度所有定时任务...") - schedules = await ScheduleInfo.filter(is_enabled=True).all() - count = 0 - for schedule in schedules: - if schedule.plugin_name in scheduler_manager._registered_tasks: - scheduler_manager._add_aps_job(schedule) - count += 1 - else: - logger.warning(f"跳过加载定时任务:插件 '{schedule.plugin_name}' 未注册。") - logger.info(f"定时任务加载完成,共成功加载 {count} 个任务。") diff --git a/zhenxun/utils/platform.py b/zhenxun/utils/platform.py index ffbd0114..13bf4144 100644 --- a/zhenxun/utils/platform.py +++ b/zhenxun/utils/platform.py @@ -80,14 +80,14 @@ class PlatformUtils: @classmethod async def send_superuser( cls, - bot: Bot, + bot: Bot | None, message: UniMessage | str, superuser_id: str | None = None, ) -> list[tuple[str, Receipt]]: """发送消息给超级用户 参数: - bot: Bot + bot: Bot,没有传入时使用get_bot随机获取 message: 消息 superuser_id: 指定超级用户id. @@ -97,6 +97,8 @@ class PlatformUtils: 返回: Receipt | None: Receipt """ + if not bot: + bot = nonebot.get_bot() superuser_ids = [] if superuser_id: superuser_ids.append(superuser_id) @@ -529,9 +531,16 @@ class BroadcastEngine: try: self.bot_list.append(nonebot.get_bot(i)) except KeyError: - logger.warning(f"Bot:{i} 对象未连接或不存在") + logger.warning(f"Bot:{i} 对象未连接或不存在", log_cmd) if not self.bot_list: - raise ValueError("当前没有可用的Bot对象...", log_cmd) + try: + bot = nonebot.get_bot() + self.bot_list.append(bot) + logger.warning( + f"广播任务未传入Bot对象,使用默认Bot {bot.self_id}", log_cmd + ) + except Exception as e: + raise ValueError("当前没有可用的Bot对象...", log_cmd) from e async def call_check(self, bot: Bot, group_id: str) -> bool: """运行发送检测函数 diff --git a/zhenxun/utils/utils.py b/zhenxun/utils/utils.py index c8046813..bdd28f83 100644 --- a/zhenxun/utils/utils.py +++ b/zhenxun/utils/utils.py @@ -1,5 +1,5 @@ from collections import defaultdict -from datetime import datetime +from datetime import date, datetime import os from pathlib import Path import time @@ -244,3 +244,20 @@ def is_number(text: str) -> bool: return True except ValueError: return False + + +class TimeUtils: + @classmethod + def get_day_start(cls, target_date: date | datetime | None = None) -> datetime: + """获取某天的0点时间 + + 返回: + datetime: 今天某天的0点时间 + """ + if not target_date: + target_date = datetime.now() + return ( + target_date.replace(hour=0, minute=0, second=0, microsecond=0) + if isinstance(target_date, datetime) + else datetime.combine(target_date, datetime.min.time()) + )