From bb45d9cb54ee47df142acff9a0c9c9cdc14b4e6d Mon Sep 17 00:00:00 2001
From: Soulter <905617992@qq.com>
Date: Sat, 13 Dec 2025 17:16:07 +0800
Subject: [PATCH 01/26] stage
---
PROVIDER_REFACTOR_INSTRUCTION.md | 56 +
astrbot/core/config/default.py | 128 +-
astrbot/core/provider/manager.py | 19 +
.../core/provider/sources/anthropic_source.py | 12 +-
astrbot/core/utils/migra_helper.py | 92 +
astrbot/dashboard/routes/config.py | 93 +-
.../src/components/shared/AstrBotConfig.vue | 1 +
.../i18n/locales/en-US/features/provider.json | 37 +
.../i18n/locales/zh-CN/features/provider.json | 37 +
dashboard/src/views/ProviderPage.vue | 1613 +++++++++--------
dashboard/src/views/ProviderPage.vue.backup | 892 +++++++++
11 files changed, 2156 insertions(+), 824 deletions(-)
create mode 100644 PROVIDER_REFACTOR_INSTRUCTION.md
create mode 100644 dashboard/src/views/ProviderPage.vue.backup
diff --git a/PROVIDER_REFACTOR_INSTRUCTION.md b/PROVIDER_REFACTOR_INSTRUCTION.md
new file mode 100644
index 000000000..73fbacdbb
--- /dev/null
+++ b/PROVIDER_REFACTOR_INSTRUCTION.md
@@ -0,0 +1,56 @@
+总览:对于 chat_completion 的提供商,目前需要将 provider 分成 provider_source 和 provider 两个部分,这样可以更好地支持同一 provider_source 下的多模型添加。
+
+目前已经在 astrbot/core/config/default.py 中对 provider 配置进行了拆分,接下来需要修改:
+
+## provider/manager 部分:
+
+需要传入 provider_sources 给 ProviderManager,然后在创建 provider 的时候:
+
+1. 如果 provider.provider_source_id 存在且不为空,则从 provider_sources 中找到对应的 provider_source,并将 provider_source 的配置合并到 provider_config 中。provider 的配置优先级更高。
+2. 如果 provider.provider_source_id 不存在或为空,则使用 provider 自身的配置。
+
+## WebUI 部分:
+
+我们主要需要大量修改 ProviderPage.vue 文件。
+
+将整体页面换成左右多栏布局。
+
+分为 三个 栏目:
+
+1. 左第一个栏目用于选择 provider type。
+2. 中间第二个栏目用于选择 provider source。
+3. 右第三个栏目用于配置 provider。
+
+中间的栏目最上面有一个添加按钮,点击后会出现一个 dropdown,列出所有的 provider source type,选择后会在中间栏目添加一个 provider source。
+
+选中某个 provider source 后,右侧栏目会显示该 provider source 的配置。指向这个 provider source 的 provider 本质上是 model 的配置。在 provider source 配置下面,会有一个“获取模型”的按钮(如果没有保存/更新 source 配置,则写“保存并获取模型”),点击后会在下方有一个 v-list 列出该 provider source 支持的所有模型,选择后会在下方增加一个新的模型配置(也就是provider的配置)。整个过程从设计上看不要有任何 dialog。
+
+有一个保存按钮,用于保存当前 provider source 和 provider 的配置。
+
+### 模型列表的获取
+
+需要增加一个 API,用于获取某个 provider source 支持的模型列表。前端在点击“添加模型”按钮时,调用该 API 获取模型列表并展示在 dropdown 中。
+
+GET /config/provider_sources/
{{ getEmptyText() }}
-{{ tm('providerSources.empty') }}
+{{ tm('models.empty') }}
+{{ tm('providerSources.selectHint') }}
++ {{ tm('subtitle') }} +
+{{ getEmptyText() }}
+{{ getEmptyText() }}
+{{ tm('models.empty') }}
+{{ tm('providerSources.empty') }}
+{{ tm('providerSources.empty') }}
-{{ tm('models.empty') }}
-MAnkuZ%EiJQ{&y)Z%o9-6=%^BNP_q_T z6PmE7Lbz1Kr5f$#kShQ2jRJ8Xc{6U{w}1RWpqj9a$X<#uW5ueHix E=GM|A^wS64lKbg?tBJQqn+4Q~AzyDN%Lmn3{JUI*eR}x|;8Tk|V zeqsE=mHP&cH>aJZGp2e!K!Rt|g00?14F^+9h^}vHN`Ef?2M!ILX-b{F;24~GqMv}L zCG_r_qTC(CPfHHfOlhZ_hX$TTL)y*p#5ggj=lIus*$#cD975d}_K#oQYs1jcdH!$} zK(kux##U7-rnx+k%xVVL)5LYJ;f;^$9iy+Et<#)0NhUBHo~lEFO|XMdG;C@ys>@pa zTe4K_D-#esf+r0;$i&OOU!NP9qH=fLRyTpsq5EanyaY5@Tn_W303S|aUidRDAKIE4 zH#hyP=K@y_ZG1vyEl+Iei%2_bxU_Xj=H@ %R)#yAq922#@`blxvsFgj>AFTJCS+i0NHFEhPPk-B894K>Rp+tUP#>HvlR z1zfp3)$uW02G6VG?$hExXo>X4IP{N;D=Ye*0vOdmqn%pq1IBRsg%<9Q`8?}(Ki)_5 z=>QlIFye^G^n04dZ|!UO 8=f>>={A;H7yh0rIF zJ8!;{VyD5t_QYaPb|VHr?1L5_B?kGBlmDSbvOy6|IA>y4Ckec zJzfw_rS3{^#wU-}w=EtPN$=AAa#aFt(F4tdd0I<$S5ZDcR+Kf(n~-@baUxdoSn>Rx z=_n&MNdh@1`Z@F9e4h}ox)sy%vncn0-&S=&ECJgUGGM7YA)NF1z%>)VLC3MuJD}{p zV5s0Qdg_aPBi4ft3mr8;#Y)yGH3m~!^Q{}YjskAjfBV5#TQKKbkO##9NYuk%Xboy} zIIY`NZ77Q}Dq2G34$k<5LPsvcwkAw?uLPt4ZON7O;f{)=)^Zz-SW W`&_XU+%( zyA=Lyluj6KdJ|=zNXEt8U?6JrNbiZ_*lj zZH;oxKt|` 9@`9XsV^aRfdt}KGT`^1b?8%K5L#;T0(i=QVz{a>7Exa zu_M0X8yprv7wUsxrX6J91*N_s<^HPg`;6PX6$~@jQ*ij}oj%aQDEKC>tvoW{_xl3< zn&|+sMlM=Tpj0{svtqyE_y<`2;kY SiQgi9v2hVif7}!@DHz5U_2U4%* zs$V~h7aBpmx30WdTy|cM9e$92VecLQU&-E$I5$cnYh^U(FMdfs^vVWEwogqLEKCtQ zR9KUjpd-zM^3Ra)8pL@R4N;@;*5!nV#o2^WuXw<4sT##U;@k~zRSVGZ3 @2tdj8 zE^dSLNV2YdKP`;wm%{Xn4>s5Z;rKz?kR2i7-9zHY@J=8w+`C;%%wKOq#+C@PgKWKQ zLjNN*z|IL<==;JouTpC_mtnacl`Xwj1tD1rv;0^1U?G$%t-%RTA`CqNr9bfHQJ}>* z5QHQWkk;-ZT (FPmO2)|NU{YlALh0wcR%oZIn==c02HwtrpTJq1cLVAhre zd?i>uxHT#Ox)Ko{C+7O@JH#2r$AaOiCZOZwq)M$WKxY+ztfzMJFdpAO+sslF*ZunV zBN-g6QbG5K`izL4+oX+>)u@wCyv5P@3_Kp=KBf{c(1ntNMY>?n^`aW82vW?^A!l7{ z1%V3h0j)S3VoQK07a)(9fv7>JVlJ@o%a%b8$b-Jd(dw%qn^w^^DP1de0x|OIC}6de zP<9*m2&yiivrB=_g=?4bBhI|rz;W>LF(NR_qM^oVVMh@^9vpioAI&MQ1?)jSjZ+4@ zm`Vs&32KfJ(X#~-;4Kk5Z;8z5vEGjx1G6%y^l=@;hLJ8hBVcFkccWwt;o~E;tT4}j zd{d}9h1SrVgjp +`R#v5zt6{SPd}&2KI=c$VeJuypJSD-O>X_33 z=W{^F`+nb-L^Mo^@O1QsfqkuH ^%G|GF0p z!ANHO5qfLbNb8$5h|tM%MW+MYCIwy&IP;oBw|#xhAT;~kBkc%zuwN1M)qoqH#)vzs z^gzsP%v~B93L#3)UFWi+WeB5H0l;J9=jEUr1kS!p+r718?;pqMjY)K%UO*_Nf1u^O z%lY&I;cX(g^p%4kV)3Iad6xa$Wh_9AhH$AKw&H2*3If_Wz17L`YejnYaq!D>sq*S3 zDCqEZQn?vOXhfr5$)VSe5YQe-8Hy3TYJ2@2MfZ!T ugRzgJWkc{`18$DuzH6i zHAL`^E?ztKPcF|n8zua<4O?@EOx5=EqF_3J;46WK(jDI1pU`JKK7ROE5OE0F{};8K zdC!i%CkN!b0wmhPpr1a?ghxeC_O%K9oW?B@*fG}fyxh(rK}PIyJuvw6GHLoW^!P8g z-gqB!D!_+Z-BN&PKZ*n!ZkR#(1WmZbcq`IoMrw#v6d1N6oJM<|@1>d6Y~y7}>>$KA z%pOCtUnbEQ2NVR#1}Fc4mPzuA(5(= p-6`sUhG2TJ223 z4u^csSxmH7o> xg^qUcyB_b=zc zVJcp-HN)WaYPY`G<*W;`)A?&olc{Ny&F^d~VSy;}h{tGW>t*~;vro-@cI} ?7sk#?&wtroc{WWo9gFWH zr4YpPGjukx*gbR4pYN0A`}ubz7&}%mru8PZ0e#)?1c8L8Hd2IdzWp;`sh)U-(BS%- zRD7fhi?7l~55me`!RYTWm8!f 8ZoUeC-c*H<|V@gkRx&5^ay{BV6w zWcDwgSL9x7QeMrkd?XcA;xElBl`WPh)TDtreRSDkBzF++{i-_eV~oD-2tB5|4i*pN zsXZ?VMD6;`VVzDRazeYxM6DpZ=_%N=3~9eSB7kvSrVPpW(*;?5t`SYr&d!6ouS zOQb+>;(2|^2KZ0JpBb%DQzFX@DUos#?A-!xfxpDkk-st|i)_$z5sdBEMi&$jljE8M zzCG>>a3jL25S2CP*IUo}5HN+sJ(ELg@T 4M#p$k>Xz@XT&E3EwN?(pIOi#gyWx> mnvU2_fVtVn58Fh6OD{)I19PxJzE38Me$H znF}Wl#oeYM17g|c! >hXehxfvXRf1IXH{U|*E*TNe{*&tO#9s64K(y>f8dhJT2rqv&KBGk!P(2n? z?|#~@B>a#}SAGOTcXvw%&2oLgaZ+0Ih_j#|4(9n@TuU8VSpHAv?N02aU8Iz 2 zdle{#(#nI$sZVMpFHuaMfJ+5^_+dDiP4*~C13SMqCrcsy-CkGl3aQ(_m3=|>J%ZJG zV?qWv>-5m@F*pR)kKq yHhF)>3d#fUk^T8~Ur=z_8??3_v$pc6~K z>H~8pP^9(VSVLTF>>K%?HMY1WxQ;SDzXtqD5*c%1Q@l$O4N-2|;}WDfMkT*@u}#`_ zI@DASipN*Aa=pco4N(O-Fh}3%;iP;l?Du^U-lD9-#0J-~&Ncpaqp?CRVk~ryXkkN$ zN%>Z&;(Q3s_W*7Q$fFv1&ZZ5;(BV(Sw(KFCk%GdN4;jS>==r%kr(fUVHUp7N-3I3j z_E8McZK237yUr6(m;x0WF=I_BY)2Nbd94})L)1tj%^7GCOM5Ii!IwaUXzB`;&enlS zKj^m=^oR1AQqnbf@hJ!{Zzm3ZL57=B&dh^eCJWWeOWP*}7mNQnli&*qQ3ooRLs!UQ zMlET6KPcmTabdt6WIaug*Ru{3?j3_X6{az9ZUWd=3Gy`^WC7!a#+g1RsJn|CbGMYC zUTfnCo~?u#sGQ-tx@CBM_Mq71r(milk-bL+TXCFv`&T&ed 05|>mCUG!6G>(WF0msvyb z>4NMW(KFDECyTBEX$IH{_oRQ)=NPW}sw33-ct88;qzQ#HrnF7YUe=C6;zVdr4V-rN zD-cWntmZ&|O-1E>#d^0`55$5A#2hCuCyU%&lc2 z!UC{UfpL23n>p|Jd?sw`4Q%Oq2=VJTKwE}qtI@wksQtd# H$wl z5+IeTLOBPhzF=4mT|qTO6|@bUI7-TN!BZj-yRP&$=)DX}${`srfKb3JHHMhU-~a=a zp*I2+x 2~%Lf=JgQq7RaFUoFN2C z34oF_=zz#M4tT)Y4`|020tRYqc+wyn_<{mM;O_@Ez#JKfC_FfA#blyly-|=(7B?+6 zK_M|`KoWKk1x$7T PDdX~ zh)sL~X8*Smgb>r=AX&{Rz+p1~$>#x%qWXrk*8K{~TI6S$PQp--qCyqlQc-il<*~nl zX)9$WU;+neI*G*){1y0$bz)!@X{{N!gxFzDZsMmsM8TD{pw=0RT?I6VLc50h@DNK3 zokZoWf`;IK4#@|`RQ3uI;{Zi$K~p!-qTbsihYYv&l0Eyri2Eu%C#Lg$2Wb3ChBzU= zG1;d;e(+jtbpx>!J*VFKz?m6h4$KrIF$hd_LRR1jzP=t>d|0g4dn!i{$oz}DL;0#y zfi+ZPcbw;|CJqX=$QWgyt7Xu;a_I3VQi*9De!#z4YRP|$8b>AQ@x}B^*w@8$^wW(n zOC~mzy5yVxjve>Tt8bH K)zK65hctPadn0P5YqvoX6@oi;Xij0TvnT>GiekOw`cDK-` zy1bU?WA&5_;L4NXQ8aAE{))uMPn5?GK+64;GcJP{zv>PmOb6+mXTjYt;#GUH8Enb} zzruRPB7bE-aOZgtk~+B#xo_tLrTjkrZS4MM&Sm3k4V06Sf@f|4$W+uuNccVhIeZ-4 zR6v_>n`dz(SlNdhtPk#lvOO<9l(F3aHOzC>LLdKbTF2@pk|BF smn*&rJ5Hp#H-_l#3 P!*6BD^#ctf>j efOXsGuT5#S&JIr3*5%}=#VOWjha$H z=J^sW)`E;-R~Ugh_lfj~cfZaEcIE(G(&GYxrxL?SU`$85RR8i1p-)a;?U%~{QEC38 zR9JzDrp=GJQL}V}J LYsNf6Ti{%*PkEqOgS}?nQ1RZ$~Kp$a_29_3ps6b?W=l zCj)@~Aon3qS;lLoAtgvBCBIlai*nS1^6x^c=`oI!8$p4;98BgfC $@E$yr)#L*@YYxC&x!l+3{HuK`X09tYwK(XVs-ze~X-LN#UojC`*ODGi8y zpt4{tUK%J*n+~Nh+c?sOYk*Xl%`NdLww^2c&dfSRvh9qJ@^A*jOHc;b4F+rKVB0$U zjt{u&3doBA4SKu<%&h?fn-nXcRdd@&jEhC{J_5(JX*?1K1&JZzb(=+t2Y9&Y9S-{& zh&PAL@k2|g;^Qj9#sDS~=`~X*2^_X>L~F(838t0O#K~4eV cIEoDE$vH@+GlS~K2nf+q%Dt}dDhw?sexhb=}kT-kF= z(ChCu-j=F-EWueM-9K5Jcr~c{g6IJQIdM;$XdgmUOpaE#k%;e`ux6Vsv^-aRI`-n$ zoAm?SUEsbR>n^Ni2=AyuC&eR5SohzV`Ca#B`YV ZjC4(=8ND zRPN3j#QO_PPvXBbHvYlCBfa& DJj)Hq`bA#VS(#-PyXQimKxEQN00JSs6{8z7(x#! zPmR#oM`fyUl0F*faGivIrtvnPH!^0lKMpR-BytMYz&SdlVUld_BHy-`q#%{VH2!?{ z&|vDjGwIXeDN-J7Ue j z)st!Gcz>PMf#z1WywIyAg}hr|+ldtdmX?lzpl0xr5SRpw#uMqE&&@mo5>D!9XVoUM z?^a=fEM^|9bk=0}DaZkLoYX-CpdfN^?0eFU8)pd?gX)Vy&<_H~|CO)AnnMsJ{|;mH zLzXssNlq-pBG~?dN(zakg@h~#1_Bz9a kc87Okf=#G z0P6RnKlRatg8-`fe`xLpP`W;L-wqS3mnGdN90gr%@P;xKG+EqBrx|{CAX~$0sk_8S zE7hHoWk(|5L&Y8NYh_pw-Z!J1C0*6y$PWT@foa66w?81BPD*T*wpUQG{bQ`7^`2S< z7!O6}>`)+{->=sIZZ^4)dIcb^B}^uuZ-bq+lOYe9ZIUb078?yjuBCZ2f&cmfsFGO; z%CrsIt_=W-^9$Z1zg63==!3)U3K=YB`mI$|p0#E%q%{ThE`!EnvA4l);`$AZioESo zomC{>M$$V+iFBF6mkDxE7!C9hY~lS+De(osEDnh!T_OHi%<38JyF0f?5xZi>V*vWI z=rkA9-j|Sy!JREcj>Dc;%c WjM?Bb?T$mJ5a)@Q{(^ z{4J2-MKC81a)He -fiFmwvTtO2UDy>gCEU$|?#2kTaD^=s6W2Dlt<>>Xmpoz=qWptwgPX@+x zgxNs=9*cdE*+N*2)iDUAiAM-Rj9B`VCOAvuDM&)MJOFv``z=13Kmj=o4*%&(w9$pC zSaJkkg%7(Wd`&^9Je#Y=;kc`@j}WGJL2?IBnQplA0GXw=FXmdu2~Xi $%M zrlg%Z8wz&D5QgNidH^JZX*71cJhDFhKUB8Tpy4=Fd@QOP%3=80P`kUyOf~W8h!6kv zzb%^b& u|=(}P1`@;v7*c=KW#$MzPt#@0NO7qx{4N_r^SN7x+2x+7T zFgitDZ-^XKdF*h*-IYWXx^g000dsi#EOzDqTE7Z`?EX^M@Qu>7kryCBY%2~mhDCim z1bZxE?0sICnLWt=jS^VNR{v=6^7U&BEv>M>Qiv@`r z`@c=~>JeXIL?2z+0+@r}+*S?(snQp7{DQq80=E4sDRABqka9iULg+4qZQAGUv~4 zt=<5n?NvklRqWjfy3@e%Dqbt&)Qitr;BS?-L6;NB!lM)`)Q=(F78^jD3-_O;32rO} zueSY=wZi(o;A42pR_RU4`k>r@<)}oYG{k8Y^GSd!g)Xp#*+1Z6Nr7QRR1DaWGsV3! zC5kTRcNxiJ+oFN{Hbn>_%uB`0&J}*DBN)nK`=Z73Bn^TpLYw@{but;CfD2%)J;Anc zSh)@C5$|?x1Q#=^R$nhjt+hY0p@L$Vbj2O<{M13+JX2`dF}-Ui(rIs`UCT#R7VZ z=i)wJe<|CKIzl6k1Y51)eB!{BU3w#L>kI4c+atn3!(v`pD|mQfNdQ3*D4hC=2byH> z6KE_)ETKy&WX{>`T7<2rcrSG9%~qZLUH`;m0!cQXVJb*{Dod0Vo}BgZeA29kYAi>^ zEmFxGCjD=S6lx(5d^MlK2h*OuRG!~w0Gv1l*kDV-GEk?IXe;u1I{B2?)<~A-d4qf? zb kO+;`oALV!F~LO{lf3DxZ@C_51Pa;fIbhloReBF(D2s6NwsF(Q2Rwd{I&-oslWrL zAPe}{Yp&**XGx3rANli(dwZ;fk>wTL+m9$DcP-@o)}mo+3@^c-gr-?m>2vw?Uly?9 zIXqpoi=1^S$wS ;8P1e*yW@y0{;r%GjL2{9;*`-VHPM60`e zHFyahlA5L%-pyH>^eqkVUbrwO^4i+<8F0Gcm+R0Sayc9Ft(K5OUO39lIIc&`yClID z=s7tg+5o}wtOVbf$fLPMzwpEi>sz5$#8X`{jzryEVbx}by+$54xx4Vej4lTn8ibZY zc*fZc7i@e~sc*)|%zGSn;RYFdRsV-y*}WF!P33+yss}nx=O_9_i=gR^StP2OFH5*l znO!IE+u{mY&U5WWm+N%|Rc`@Hkrj*iAlmmVlXl;G{Yc!`&MJ*xk`!s23Px=uby-^9 zn#)efTkOMK7Ji!-?F}1T=o^kAfZBO-EEbswoW=}P2ri-r%N80z>D#~N|D+YHLV}0B zzdzodKB=FUeamscv=>(u?EE1)v`d0vEs)B|yT@C03B5GA0dnEK!5=azD=wc<{- 1 zp0*qU$3>ezaJA1G`4iZRY)=rq2CbESsrZIoI*pGGMeh`!qJx`O3;K*sMg~isvo!`E z?}?6Z#2=^RgND_Dz05Ch%bFy|$4Ei*;Z2So;+*m~t${eD)hu#*`3+-*J@?_*;rdIC zJtrotK?V)oy}XE+Pbg_9k02{c2VyA`N I;afzRP$1SF##w3~&Oa zKjWYAO$m#7x1@#NIty%oR8l_?dC`P^(S=67@MQ|-?;ZV#1xcaJAOP;ZxuL$ZeDN8K z;N0gVRI}PrS2v$zxmzolj&vsmD=26?j~t)}C7DvKC-2 OwY z>{8M24^J~f4{S9MmvEXmyN*&5P7V4|At(%*3I0h!=x8^k&j08~AE4uXfF6_dTqZg6 z$Af!&)iZ)F>4KZQ-{EV;YB^rBIvuw!F<`Uib^Znlx=&Pih(9zyl$BJ)g@tEM&tW#u zhl@aKIi`51Nj5odysJS*<3s8WaOppMUd~qxPeoimG*e3W%LwO5VH;+=YTa;teQF&@ zURF-JbOy;AbQ ()NmlIs ZeF^ z&Xzp-Cfp`I>re_50kib5m1nK(KU&G&^clr^wgJ)I*#pAx1E~QE(tbrb65yZB)!hin zv(<7~>RVVT9Q%Vj)ss!xJ$rW~d`0S(y7j3&_Btu|a3gWW6w+CT#J@*3sdk=0Z>RN* zWAiHuYrEnVXAgWE9#AgI{~iAiuoiJu d~oq-|erm+xkwX_UQ075A$j0S&+?ZUmL9KPz0+kRRNxHx>c^LR5Hv!0q~#+ zl|7eA39fi8eALjM_k$Z|`PTn5wx`B41S84go8EZCz9*G%=qFwS8r;*S%!m(T`*Yp~ zU-kwY@mhAPIGF8oa#Rs=Y9V;j3rEbact%7#QOX?94Yp_^R`w7YX7G~ye@MQ(q>ntB zAcoF*g#XVD8Ab}L{h> (k6r+&M)tmuRTy z`cS_d*pW5scvp1mAC* Fdh)1^i$(H6MJsfRm~2)g7J2v} zsI<-E=D}F;U}xv1(g;|1$YOGPA5Lvj%0vEIHoQo4zC;sb%!{V(T;LOO`WTqq-G zYhJoPhiKt;oQ*eUgZGpaDPP9AgIc7MsWM0s`?wtbcM+ye67P{rU7@k#0%c{yp9xSc z^f0fqcU8q-Z#^vAkmlNRgsVOmFOv2>1&5Q`^}ozmuN^5sb<#%o`zRLfX%7gz{XO)+ zNXZ_Uy7k6%p%ye?C_IPgt?yfmc$0YE3yeQ~Ex?qhZcKRWbVUK1a-iH^-YZ(61rq)d zA}D0Op!(^J+ ^0rjh|g@^}8*0oCY% zbpxC($G@3K0(067Y&ZBladPCTD|TvR ClJg*1w@`$|WZ+mdDS$r~T zNQOzJ%d03>XMjh|xK*YJzK5(^fVzUA4A&lAFunGD`!U_MZyeo%MHlvhGFlMWP{>$+ z;V(FzeTHYe=%+Bg4r+)*_0Z4iSs-#`^G^8A%Y$=Eak_0DbRN6X=KhrOxbBJ{ZQnFk zTv4CB#B+%O`}@(2_ds33g(O^zR}Pn2 DQ*5QL z0RB9e!G|h)_@f|NzEt--?4op#)}~(w?`jb#vA2*|Ht8LN?Ju5vVas;*1h7ua?w#11 zO0g|KBOfa3?4H})mJ|*f1{@Fje}ePh&9eLo51 ej~)Uk_-)?I&|sTkDRQkPjJrR5^f2u zQEBxLj_7$HA?U}5BC>5*7d)55`S-#17i^i%M5)!|&G9jOdC(XO4HeDan#H%m`POJK zo3A$8OZ4s51)H0M_$=MG1r8u}pL#c<2y)FVrpNAmxHOO3LEdVtbb8xfDd@5;P|#En z9a$zuBLdIc3sS}i$zTdyA^zU{RWaZ6(r!g%7t+whRha*Z*DInGoUcbub3cFxd4bl_ z&)dgdR( -khk7n#L!n9-qN!C4#C> G#xw;pW=y+${UKlh$?Ice0(d-9Pct5u&@=8YM zZcP?00q@AR;_nG<*bK)`Y4(*e;5BS&9y5QT>@U9qH2g)h)BgS<+b7x}+djiCZwL9~ zwM<+RSCc}!$+o#_ZNJl&BlY=ZV24&o>*(ACa@249H5u7SP^X~oKR_288@W?z`u3B; z(u$G!TMwsw776p0^Damyzg1)}N_84-?QpPBMYj<(WvAO9+vz$cwloijU&rz2XC@YM zp5QrT-fxqsH2sIAFP&cABwqQ;NVd^*DfFMEEjfq#NMTm6%-T;2Kv!|WPf|K1k5v4; zBa(kYWCh++Yj&8mEk7mWj#TW6a3pUM%w>Gf& R<(uqIhjXGp(Kc{H zcA7u=E3P6=nG9kUugLv4@MQsSz5htnK>3KCt69?B1oFteY+b$F-M^l&d^Zrwznkdn z9R(@fpCM(g*3CC~s`0|xCwMBK@M|-xAgKMmpnlI4>5yOGrO9&eRcd&lNa%A8UkIGJ zJhu{RCs*=qFB2WUZ}VbL@o!@NRnkb!ZIbQE(f`xK)y6b+M&b9ir6?^@R7@%0t--nR zF }d3mLfp3m>EB`R0w+vKXb}7s?m;JqFWxZ3iqZrwLQ4uj=r?R0HwqB zKddvyoH!QkymkTz5PD(A{zlsmTI4Z?zuq!o%0rGG2@p-H6f5WUgcoNfG42uaASk6A z94KE)#PwfSp`q^e_(iaX5it-h)H4ft#OQWp$=@#&=Y#I9zDYKm+gGHk$|rKIx(JM? zj53$D+O}2vr8>yVrasp6!aGdQ^ixw;3X$WJx)n%U&FeowwAYsE-2lz}9bP9X(7iQa zo3YR~g%`$~IoTcqgkCw9qn!_eTFz&l>{Jt}!Q &I(iz<4NVHD!UEI_}F(E;G*qw1|YD3^Id^TPqzvOD=GL?&9Z77b4jVJy_0W&_(|h zbPGAHrbde#d_Y%DH*y%(-c_7m%Pce{Tj?R1)!;Y#76`y^fKPnNruisS0f&qNT2!aD z(B&KGV(dQXql4KSf3Qg40#uuivTuP{`%cXN4JGDJ1> 9-Ynx(>v z 0|)Y z9Q59(!B0C0OI1c Q!DRDz+7#a5H;L>9n(}%1@ z^oQ6ZHe*n>oKuEsK%L9R0Uf5J{IAfAIp$wvfwVf8I*p%(8a?HsdcV1!gF~bgvg4Qh zl=l%SWW;PG{0K6->WNpzj3f+{qYX5tg$i44T87{o6pkHgph=6j5+yXL-)B_JN2Vd@ zFe+bG<5DlIppM)D=L!<0gWvgO)J+sER5~SIA`17l2^QtwoNO7+TFg2;s@YJ`H%#r? zci<2ZM%q2=IZ4x(VEY$n1#BPoISbu^pEI`NmPq-LDywdX=z6`itIJFMa|H!JA#wtW zTm8xpJyq=(VRBvlTxB`Y&=Dybl}L*3aTlTkAQ>&DCm`+60est;yg 0SO41_^EH@f@=3U=NRML; zJSYlijepM1Csr63N%j_e9^LTW``Xol$^MtO2VrJ?y^pRZt5TM-pP#k~zF5Pml$li( z%;t8@mHFaOm0_TzbiNp2e;h9y9utn!;8QP`#ipshZ# ;_ux Date: Thu, 18 Dec 2025 17:11:09 +0800 Subject: [PATCH 22/26] feat: enhance tool call handling and agent stats tracking and UI integration for tool calls render (#4101) * feat: enhance tool call handling and UI integration for tool calls render - Added support for tool call messages in the agent runner and webchat event handling. - Implemented JSON message component for structured tool call data. - Updated chat route to save tool call information in message history. - Enhanced frontend to display tool call details in a collapsible format, including status and results. - Introduced elapsed time tracking for ongoing tool calls in the chat interface. * fix: improve message handling in agent run utility and tool loop runner - Refactored message sending logic in `astr_agent_run_util.py` to use `msg_chain` directly for better clarity. - Added a check in `tool_loop_agent_runner.py` to ensure `tool_call_result_blocks` is not empty before yielding the last tool call result, preventing potential errors. * refactor: enhance message structure and UI for chat components - Updated message handling in `MessageList.vue` to support structured message parts, including plain text, images, audio, and files. - Improved the `Chat.vue` component styles for better visual consistency. - Refactored message parsing logic in `useMessages.ts` to accommodate new message formats and ensure proper rendering of embedded content. - Removed deprecated tool call handling from the message structure, streamlining the message display process. * chore: ruff format * feat: implement agent statistics tracking and display in chat - Added `AgentStats` and `TokenUsage` data classes to track agent performance metrics. - Enhanced `ToolLoopAgentRunner` to collect and update agent statistics during execution. - Integrated agent statistics sending to webchat for real-time updates. - Updated chat route to save and display agent statistics in message history. - Improved frontend components to visualize agent statistics, including token usage and duration metrics. * fix: improve message handling in Telegram event and agent run utility - Updated message sending logic in `astr_agent_run_util.py` to send the correct message chain for tool calls. - Enhanced `tg_event.py` to edit messages during streaming breaks, improving message management and user experience. - Added error handling for message editing failures to ensure robustness. * chore: ruff format --- astrbot/core/agent/response.py | 23 +- .../agent/runners/tool_loop_agent_runner.py | 68 +- astrbot/core/astr_agent_run_util.py | 25 +- astrbot/core/message/components.py | 9 +- .../platform/sources/telegram/tg_event.py | 9 + .../platform/sources/webchat/webchat_event.py | 40 +- astrbot/core/provider/entities.py | 41 ++ .../core/provider/sources/anthropic_source.py | 43 +- .../core/provider/sources/gemini_source.py | 21 +- .../core/provider/sources/openai_source.py | 19 +- astrbot/core/star/context.py | 4 + astrbot/dashboard/routes/chat.py | 65 +- dashboard/src/components/chat/Chat.vue | 4 + dashboard/src/components/chat/MessageList.vue | 681 +++++++++++++++--- dashboard/src/composables/useMessages.ts | 422 ++++++----- .../src/i18n/locales/en-US/features/chat.json | 8 + .../src/i18n/locales/zh-CN/features/chat.json | 8 + 17 files changed, 1154 insertions(+), 336 deletions(-) diff --git a/astrbot/core/agent/response.py b/astrbot/core/agent/response.py index 3f3430c87..9e61fa8c7 100644 --- a/astrbot/core/agent/response.py +++ b/astrbot/core/agent/response.py @@ -1,7 +1,8 @@ import typing as T -from dataclasses import dataclass +from dataclasses import dataclass, field from astrbot.core.message.message_event_result import MessageChain +from astrbot.core.provider.entities import TokenUsage class AgentResponseData(T.TypedDict): @@ -12,3 +13,23 @@ class AgentResponseData(T.TypedDict): class AgentResponse: type: str data: AgentResponseData + + +@dataclass +class AgentStats: + token_usage: TokenUsage = field(default_factory=TokenUsage) + start_time: float = 0.0 + end_time: float = 0.0 + time_to_first_token: float = 0.0 + + @property + def duration(self) -> float: + return self.end_time - self.start_time + + def to_dict(self) -> dict: + return { + "token_usage": self.token_usage.__dict__, + "start_time": self.start_time, + "end_time": self.end_time, + "time_to_first_token": self.time_to_first_token, + } diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 450e4dbcb..069de144f 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -1,4 +1,5 @@ import sys +import time import traceback import typing as T @@ -12,6 +13,7 @@ from mcp.types import ( ) from astrbot import logger +from astrbot.core.message.components import Json from astrbot.core.message.message_event_result import ( MessageChain, ) @@ -24,7 +26,7 @@ from astrbot.core.provider.provider import Provider from ..hooks import BaseAgentRunHooks from ..message import AssistantMessageSegment, Message, ToolCallMessageSegment -from ..response import AgentResponseData +from ..response import AgentResponseData, AgentStats from ..run_context import ContextWrapper, TContext from ..tool_executor import BaseFunctionToolExecutor from .base import AgentResponse, AgentState, BaseAgentRunner @@ -69,6 +71,9 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): ) self.run_context.messages = messages + self.stats = AgentStats() + self.stats.start_time = time.time() + async def _iter_llm_responses(self) -> T.AsyncGenerator[LLMResponse, None]: """Yields chunks *and* a final LLMResponse.""" if self.streaming: @@ -98,6 +103,10 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): async for llm_response in self._iter_llm_responses(): if llm_response.is_chunk: + # update ttft + if self.stats.time_to_first_token == 0: + self.stats.time_to_first_token = time.time() - self.stats.start_time + if llm_response.result_chain: yield AgentResponse( type="streaming_delta", @@ -121,6 +130,10 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): ) continue llm_resp_result = llm_response + + if not llm_response.is_chunk and llm_response.usage: + # only count the token usage of the final response for computation purpose + self.stats.token_usage += llm_response.usage break # got final response if not llm_resp_result: @@ -132,6 +145,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): if llm_resp.role == "err": # 如果 LLM 响应错误,转换到错误状态 self.final_llm_resp = llm_resp + self.stats.end_time = time.time() self._transition_state(AgentState.ERROR) yield AgentResponse( type="err", @@ -146,6 +160,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): # 如果没有工具调用,转换到完成状态 self.final_llm_resp = llm_resp self._transition_state(AgentState.DONE) + self.stats.end_time = time.time() # record the final assistant message self.run_context.messages.append( Message( @@ -175,23 +190,19 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): # 如果有工具调用,还需处理工具调用 if llm_resp.tools_call_name: tool_call_result_blocks = [] - for tool_call_name in llm_resp.tools_call_name: - yield AgentResponse( - type="tool_call", - data=AgentResponseData( - chain=MessageChain(type="tool_call").message( - f"🔨 调用工具: {tool_call_name}" - ), - ), - ) async for result in self._handle_function_tools(self.req, llm_resp): if isinstance(result, list): tool_call_result_blocks = result elif isinstance(result, MessageChain): if result.type is None: - result.type = "tool_call_result" + # should not happen + continue + if result.type == "tool_direct_result": + ar_type = "tool_call_result" + else: + ar_type = result.type yield AgentResponse( - type="tool_call_result", + type=ar_type, data=AgentResponseData(chain=result), ) # 将结果添加到上下文中 @@ -234,6 +245,19 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): llm_response.tools_call_args, llm_response.tools_call_ids, ): + yield MessageChain( + type="tool_call", + chain=[ + Json( + data={ + "id": func_tool_id, + "name": func_tool_name, + "args": func_tool_args, + "ts": time.time(), + } + ) + ], + ) try: if not req.func_tool: return @@ -307,7 +331,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): content=res.content[0].text, ), ) - yield MessageChain().message(res.content[0].text) elif isinstance(res.content[0], ImageContent): tool_call_result_blocks.append( ToolCallMessageSegment( @@ -329,7 +352,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): content=resource.text, ), ) - yield MessageChain().message(resource.text) elif ( isinstance(resource, BlobResourceContents) and resource.mimeType @@ -353,7 +375,22 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): content="返回的数据类型不受支持", ), ) - yield MessageChain().message("返回的数据类型不受支持。") + + # yield the last tool call result + if tool_call_result_blocks: + last_tcr_content = str(tool_call_result_blocks[-1].content) + yield MessageChain( + type="tool_call_result", + chain=[ + Json( + data={ + "id": func_tool_id, + "ts": time.time(), + "result": last_tcr_content, + } + ) + ], + ) elif resp is None: # Tool 直接请求发送消息给用户 @@ -363,6 +400,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): f"{func_tool_name} 没有没有返回值或者将结果直接发送给用户,此工具调用不会被记录到历史中。" ) self._transition_state(AgentState.DONE) + self.stats.end_time = time.time() else: # 不应该出现其他类型 logger.warning( diff --git a/astrbot/core/astr_agent_run_util.py b/astrbot/core/astr_agent_run_util.py index d94d96a82..5421a14c0 100644 --- a/astrbot/core/astr_agent_run_util.py +++ b/astrbot/core/astr_agent_run_util.py @@ -4,6 +4,7 @@ from collections.abc import AsyncGenerator from astrbot.core import logger from astrbot.core.agent.runners.tool_loop_agent_runner import ToolLoopAgentRunner from astrbot.core.astr_agent_context import AstrAgentContext +from astrbot.core.message.components import Json from astrbot.core.message.message_event_result import ( MessageChain, MessageEventResult, @@ -33,16 +34,27 @@ async def run_agent( msg_chain = resp.data["chain"] if msg_chain.type == "tool_direct_result": # tool_direct_result 用于标记 llm tool 需要直接发送给用户的内容 - await astr_event.send(resp.data["chain"]) + await astr_event.send(msg_chain) continue + if astr_event.get_platform_id() == "webchat": + await astr_event.send(msg_chain) # 对于其他情况,暂时先不处理 continue elif resp.type == "tool_call": if agent_runner.streaming: # 用来标记流式响应需要分节 yield MessageChain(chain=[], type="break") - if show_tool_use: + + if astr_event.get_platform_name() == "webchat": await astr_event.send(resp.data["chain"]) + elif show_tool_use: + json_comp = resp.data["chain"].chain[0] + if isinstance(json_comp, Json): + m = f"🔨 调用工具: {json_comp.data.get('name')}" + else: + m = "🔨 调用工具..." + chain = MessageChain(type="tool_call").message(m) + await astr_event.send(chain) continue if stream_to_general and resp.type == "streaming_delta": @@ -69,6 +81,15 @@ async def run_agent( continue yield resp.data["chain"] # MessageChain if agent_runner.done(): + # send agent stats to webchat + if astr_event.get_platform_name() == "webchat": + await astr_event.send( + MessageChain( + type="agent_stats", + chain=[Json(data=agent_runner.stats.to_dict())], + ) + ) + break except Exception as e: diff --git a/astrbot/core/message/components.py b/astrbot/core/message/components.py index 0e7b3bab6..050e36521 100644 --- a/astrbot/core/message/components.py +++ b/astrbot/core/message/components.py @@ -629,12 +629,11 @@ class Nodes(BaseMessageComponent): class Json(BaseMessageComponent): type = ComponentType.Json - data: str | dict - resid: int | None = 0 + data: dict - def __init__(self, data, **_): - if isinstance(data, dict): - data = json.dumps(data) + def __init__(self, data: str | dict, **_): + if isinstance(data, str): + data = json.loads(data) super().__init__(data=data, **_) diff --git a/astrbot/core/platform/sources/telegram/tg_event.py b/astrbot/core/platform/sources/telegram/tg_event.py index 37f60e65a..5faba6803 100644 --- a/astrbot/core/platform/sources/telegram/tg_event.py +++ b/astrbot/core/platform/sources/telegram/tg_event.py @@ -200,6 +200,15 @@ class TelegramPlatformEvent(AstrMessageEvent): if isinstance(chain, MessageChain): if chain.type == "break": # 分割符 + if message_id: + try: + await self.client.edit_message_text( + text=delta, + chat_id=payload["chat_id"], + message_id=message_id, + ) + except Exception as e: + logger.warning(f"编辑消息失败(streaming-break): {e!s}") message_id = None # 重置消息 ID delta = "" # 重置 delta continue diff --git a/astrbot/core/platform/sources/webchat/webchat_event.py b/astrbot/core/platform/sources/webchat/webchat_event.py index 9f1a6d059..2e529bb1d 100644 --- a/astrbot/core/platform/sources/webchat/webchat_event.py +++ b/astrbot/core/platform/sources/webchat/webchat_event.py @@ -1,11 +1,12 @@ import base64 +import json import os import shutil import uuid from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, MessageChain -from astrbot.api.message_components import File, Image, Plain, Record +from astrbot.api.message_components import File, Image, Json, Plain, Record from astrbot.core.utils.astrbot_path import get_astrbot_data_path from .webchat_queue_mgr import webchat_queue_mgr @@ -41,12 +42,20 @@ class WebChatMessageEvent(AstrMessageEvent): await web_chat_back_queue.put( { "type": "plain", - "cid": cid, "data": data, "streaming": streaming, "chain_type": message.type, }, ) + elif isinstance(comp, Json): + await web_chat_back_queue.put( + { + "type": "plain", + "data": json.dumps(comp.data, ensure_ascii=False), + "streaming": streaming, + "chain_type": message.type, + }, + ) elif isinstance(comp, Image): # save image to local filename = f"{str(uuid.uuid4())}.jpg" @@ -58,7 +67,6 @@ class WebChatMessageEvent(AstrMessageEvent): await web_chat_back_queue.put( { "type": "image", - "cid": cid, "data": data, "streaming": streaming, }, @@ -74,7 +82,6 @@ class WebChatMessageEvent(AstrMessageEvent): await web_chat_back_queue.put( { "type": "record", - "cid": cid, "data": data, "streaming": streaming, }, @@ -91,7 +98,6 @@ class WebChatMessageEvent(AstrMessageEvent): await web_chat_back_queue.put( { "type": "file", - "cid": cid, "data": data, "streaming": streaming, }, @@ -111,18 +117,17 @@ class WebChatMessageEvent(AstrMessageEvent): cid = self.session_id.split("!")[-1] web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid) async for chain in generator: - if chain.type == "break" and final_data: - # 分割符 - await web_chat_back_queue.put( - { - "type": "break", # break means a segment end - "data": final_data, - "streaming": True, - "cid": cid, - }, - ) - final_data = "" - continue + # if chain.type == "break" and final_data: + # # 分割符 + # await web_chat_back_queue.put( + # { + # "type": "break", # break means a segment end + # "data": final_data, + # "streaming": True, + # }, + # ) + # final_data = "" + # continue r = await WebChatMessageEvent._send( chain, @@ -142,7 +147,6 @@ class WebChatMessageEvent(AstrMessageEvent): "data": final_data, "reasoning": reasoning_content, "streaming": True, - "cid": cid, }, ) await super().send_streaming(generator, use_fallback) diff --git a/astrbot/core/provider/entities.py b/astrbot/core/provider/entities.py index dc188f141..d13e9b56a 100644 --- a/astrbot/core/provider/entities.py +++ b/astrbot/core/provider/entities.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import base64 import enum import json @@ -199,6 +201,38 @@ class ProviderRequest: return "" +@dataclass +class TokenUsage: + input_other: int = 0 + """The number of input tokens, excluding cached tokens.""" + input_cached: int = 0 + """The number of input cached tokens.""" + output: int = 0 + """The number of output tokens.""" + + @property + def total(self) -> int: + return self.input_other + self.input_cached + self.output + + @property + def input(self) -> int: + return self.input_other + self.input_cached + + def __add__(self, other: TokenUsage) -> TokenUsage: + return TokenUsage( + input_other=self.input_other + other.input_other, + input_cached=self.input_cached + other.input_cached, + output=self.output + other.output, + ) + + def __sub__(self, other: TokenUsage) -> TokenUsage: + return TokenUsage( + input_other=self.input_other - other.input_other, + input_cached=self.input_cached - other.input_cached, + output=self.output - other.output, + ) + + @dataclass class LLMResponse: role: str @@ -227,6 +261,11 @@ class LLMResponse: is_chunk: bool = False """Indicates if the response is a chunked response.""" + id: str | None = None + """The ID of the response. For chunked responses, it's the ID of the chunk; for non-chunked responses, it's the ID of the response.""" + usage: TokenUsage | None = None + """The usage of the response. For chunked responses, it's the usage of the chunk; for non-chunked responses, it's the usage of the response.""" + def __init__( self, role: str, @@ -241,6 +280,8 @@ class LLMResponse: | AnthropicMessage | None = None, is_chunk: bool = False, + id: str | None = None, + usage: TokenUsage | None = None, ): """初始化 LLMResponse diff --git a/astrbot/core/provider/sources/anthropic_source.py b/astrbot/core/provider/sources/anthropic_source.py index 21acd87e8..0ff61e393 100644 --- a/astrbot/core/provider/sources/anthropic_source.py +++ b/astrbot/core/provider/sources/anthropic_source.py @@ -6,10 +6,12 @@ from mimetypes import guess_type import anthropic from anthropic import AsyncAnthropic from anthropic.types import Message +from anthropic.types.message_delta_usage import MessageDeltaUsage +from anthropic.types.usage import Usage from astrbot import logger from astrbot.api.provider import Provider -from astrbot.core.provider.entities import LLMResponse +from astrbot.core.provider.entities import LLMResponse, TokenUsage from astrbot.core.provider.func_tool_manager import ToolSet from astrbot.core.utils.io import download_image_by_url @@ -107,6 +109,22 @@ class ProviderAnthropic(Provider): return system_prompt, new_messages + def _extract_usage(self, usage: Usage) -> TokenUsage: + # https://docs.claude.com/en/docs/build-with-claude/prompt-caching#tracking-cache-performance + return TokenUsage( + input_other=usage.input_tokens or 0, + input_cached=usage.cache_read_input_tokens or 0, + output=usage.output_tokens, + ) + + def _update_usage(self, token_usage: TokenUsage, usage: MessageDeltaUsage) -> None: + if usage.input_tokens is not None: + token_usage.input_other = usage.input_tokens + if usage.cache_read_input_tokens is not None: + token_usage.input_cached = usage.cache_read_input_tokens + if usage.output_tokens is not None: + token_usage.output = usage.output_tokens + async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse: if tools: if tool_list := tools.get_func_desc_anthropic_style(): @@ -135,6 +153,10 @@ class ProviderAnthropic(Provider): llm_response.tools_call_args.append(content_block.input) llm_response.tools_call_name.append(content_block.name) llm_response.tools_call_ids.append(content_block.id) + + llm_response.id = completion.id + llm_response.usage = self._extract_usage(completion.usage) + # TODO(Soulter): 处理 end_turn 情况 if not llm_response.completion_text and not llm_response.tools_call_args: raise Exception(f"Anthropic API 返回的 completion 无法解析:{completion}。") @@ -155,7 +177,8 @@ class ProviderAnthropic(Provider): # 用于累积最终结果 final_text = "" final_tool_calls = [] - + id = None + usage = TokenUsage() extra_body = self.provider_config.get("custom_extra_body", {}) async with self.client.messages.stream( @@ -163,6 +186,10 @@ class ProviderAnthropic(Provider): ) as stream: assert isinstance(stream, anthropic.AsyncMessageStream) async for event in stream: + if event.type == "message_start": + # the usage contains input token usage + id = event.message.id + usage = self._extract_usage(event.message.usage) if event.type == "content_block_start": if event.content_block.type == "text": # 文本块开始 @@ -170,6 +197,8 @@ class ProviderAnthropic(Provider): role="assistant", completion_text="", is_chunk=True, + usage=usage, + id=id, ) elif event.content_block.type == "tool_use": # 工具使用块开始,初始化缓冲区 @@ -187,6 +216,8 @@ class ProviderAnthropic(Provider): role="assistant", completion_text=event.delta.text, is_chunk=True, + usage=usage, + id=id, ) elif event.delta.type == "input_json_delta": # 工具调用参数增量 @@ -223,6 +254,8 @@ class ProviderAnthropic(Provider): tools_call_name=[tool_info["name"]], tools_call_ids=[tool_info["id"]], is_chunk=True, + usage=usage, + id=id, ) except json.JSONDecodeError: # JSON 解析失败,跳过这个工具调用 @@ -231,11 +264,17 @@ class ProviderAnthropic(Provider): # 清理缓冲区 del tool_use_buffer[event.index] + elif event.type == "message_delta": + if event.usage: + self._update_usage(usage, event.usage) + # 返回最终的完整结果 final_response = LLMResponse( role="assistant", completion_text=final_text, is_chunk=False, + usage=usage, + id=id, ) if final_tool_calls: diff --git a/astrbot/core/provider/sources/gemini_source.py b/astrbot/core/provider/sources/gemini_source.py index ebb000c03..87d03c051 100644 --- a/astrbot/core/provider/sources/gemini_source.py +++ b/astrbot/core/provider/sources/gemini_source.py @@ -14,7 +14,7 @@ import astrbot.core.message.components as Comp from astrbot import logger from astrbot.api.provider import Provider from astrbot.core.message.message_event_result import MessageChain -from astrbot.core.provider.entities import LLMResponse +from astrbot.core.provider.entities import LLMResponse, TokenUsage from astrbot.core.provider.func_tool_manager import ToolSet from astrbot.core.utils.io import download_image_by_url @@ -347,6 +347,16 @@ class ProviderGoogleGenAI(Provider): ] return "".join(thought_buf).strip() + def _extract_usage( + self, usage_metadata: types.GenerateContentResponseUsageMetadata + ) -> TokenUsage: + """Extract usage from candidate""" + return TokenUsage( + input_other=usage_metadata.prompt_token_count or 0, + input_cached=usage_metadata.cached_content_token_count or 0, + output=usage_metadata.candidates_token_count or 0, + ) + def _process_content_parts( self, candidate: types.Candidate, @@ -501,6 +511,9 @@ class ProviderGoogleGenAI(Provider): result.candidates[0], llm_response, ) + llm_response.id = result.response_id + if result.usage_metadata: + llm_response.usage = self._extract_usage(result.usage_metadata) return llm_response async def _query_stream( @@ -569,6 +582,9 @@ class ProviderGoogleGenAI(Provider): chunk.candidates[0], llm_response, ) + llm_response.id = chunk.response_id + if chunk.usage_metadata: + llm_response.usage = self._extract_usage(chunk.usage_metadata) yield llm_response return @@ -596,6 +612,9 @@ class ProviderGoogleGenAI(Provider): chunk.candidates[0], final_response, ) + final_response.id = chunk.response_id + if chunk.usage_metadata: + final_response.usage = self._extract_usage(chunk.usage_metadata) break # Yield final complete response with accumulated text diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 6e3bd0bfd..a716d0a5a 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -12,6 +12,7 @@ from openai._exceptions import NotFoundError from openai.lib.streaming.chat._completions import ChatCompletionStreamState from openai.types.chat.chat_completion import ChatCompletion from openai.types.chat.chat_completion_chunk import ChatCompletionChunk +from openai.types.completion_usage import CompletionUsage import astrbot.core.message.components as Comp from astrbot import logger @@ -19,7 +20,7 @@ from astrbot.api.provider import Provider from astrbot.core.agent.message import Message from astrbot.core.agent.tool import ToolSet from astrbot.core.message.message_event_result import MessageChain -from astrbot.core.provider.entities import LLMResponse, ToolCallsResult +from astrbot.core.provider.entities import LLMResponse, TokenUsage, ToolCallsResult from astrbot.core.utils.io import download_image_by_url from ..register import register_provider_adapter @@ -207,6 +208,7 @@ class ProviderOpenAIOfficial(Provider): # handle the content delta reasoning = self._extract_reasoning_content(chunk) _y = False + llm_response.id = chunk.id if reasoning: llm_response.reasoning_content = reasoning _y = True @@ -216,6 +218,8 @@ class ProviderOpenAIOfficial(Provider): chain=[Comp.Plain(completion_text)], ) _y = True + if chunk.usage: + llm_response.usage = self._extract_usage(chunk.usage) if _y: yield llm_response @@ -244,6 +248,15 @@ class ProviderOpenAIOfficial(Provider): reasoning_text = str(reasoning_attr) return reasoning_text + def _extract_usage(self, usage: CompletionUsage) -> TokenUsage: + ptd = usage.prompt_tokens_details + cached = ptd.cached_tokens if ptd and ptd.cached_tokens else 0 + return TokenUsage( + input_other=usage.prompt_tokens - cached, + input_cached=ptd.cached_tokens if ptd and ptd.cached_tokens else 0, + output=usage.completion_tokens, + ) + async def _parse_openai_completion( self, completion: ChatCompletion, tools: ToolSet | None ) -> LLMResponse: @@ -320,6 +333,10 @@ class ProviderOpenAIOfficial(Provider): raise Exception(f"API 返回的 completion 无法解析:{completion}。") llm_response.raw_completion = completion + llm_response.id = completion.id + + if completion.usage: + llm_response.usage = self._extract_usage(completion.usage) return llm_response diff --git a/astrbot/core/star/context.py b/astrbot/core/star/context.py index 9a52ec8bc..2561762f1 100644 --- a/astrbot/core/star/context.py +++ b/astrbot/core/star/context.py @@ -296,6 +296,10 @@ class Context: provider_type=ProviderType.CHAT_COMPLETION, umo=umo, ) + if prov is None: + raise ProviderNotFoundError( + "provider not found, please choose provider first" + ) if not isinstance(prov, Provider): raise ValueError("返回的 Provider 不是 Provider 类型") return prov diff --git a/astrbot/dashboard/routes/chat.py b/astrbot/dashboard/routes/chat.py index f2439c058..c2b991ef7 100644 --- a/astrbot/dashboard/routes/chat.py +++ b/astrbot/dashboard/routes/chat.py @@ -227,16 +227,19 @@ class ChatRoute(Route): text: str, media_parts: list, reasoning: str, + agent_stats: dict, ): """保存 bot 消息到历史记录,返回保存的记录""" bot_message_parts = [] + bot_message_parts.extend(media_parts) if text: bot_message_parts.append({"type": "plain", "text": text}) - bot_message_parts.extend(media_parts) new_his = {"type": "bot", "message": bot_message_parts} if reasoning: new_his["reasoning"] = reasoning + if agent_stats: + new_his["agent_stats"] = agent_stats record = await self.platform_history_mgr.insert( platform_id="webchat", @@ -294,7 +297,8 @@ class ChatRoute(Route): accumulated_parts = [] accumulated_text = "" accumulated_reasoning = "" - + tool_calls = {} + agent_stats = {} try: async with track_conversation(self.running_convs, webchat_conv_id): while True: @@ -314,6 +318,16 @@ class ChatRoute(Route): result_text = result["data"] msg_type = result.get("type") streaming = result.get("streaming", False) + chain_type = result.get("chain_type") + + if chain_type == "agent_stats": + stats_info = { + "type": "agent_stats", + "data": json.loads(result_text), + } + yield f"data: {json.dumps(stats_info, ensure_ascii=False)}\n\n" + agent_stats = stats_info["data"] + continue # 发送 SSE 数据 try: @@ -335,8 +349,30 @@ class ChatRoute(Route): # 累积消息部分 if msg_type == "plain": - chain_type = result.get("chain_type", "normal") - if chain_type == "reasoning": + chain_type = result.get("chain_type") + if chain_type == "tool_call": + tool_call = json.loads(result_text) + tool_calls[tool_call.get("id")] = tool_call + if accumulated_text: + # 如果累积了文本,则先保存文本 + accumulated_parts.append( + {"type": "plain", "text": accumulated_text} + ) + accumulated_text = "" + elif chain_type == "tool_call_result": + tcr = json.loads(result_text) + tc_id = tcr.get("id") + if tc_id in tool_calls: + tool_calls[tc_id]["result"] = tcr.get("result") + tool_calls[tc_id]["finished_ts"] = tcr.get("ts") + accumulated_parts.append( + { + "type": "tool_call", + "tool_calls": [tool_calls[tc_id]], + } + ) + tool_calls.pop(tc_id, None) + elif chain_type == "reasoning": accumulated_reasoning += result_text elif streaming: accumulated_text += result_text @@ -369,15 +405,20 @@ class ChatRoute(Route): if msg_type == "end": break elif ( - (streaming and msg_type == "complete") - or not streaming - or msg_type == "break" + (streaming and msg_type == "complete") or not streaming + # or msg_type == "break" ): + if ( + chain_type == "tool_call" + or chain_type == "tool_call_result" + ): + continue saved_record = await self._save_bot_message( webchat_conv_id, accumulated_text, accumulated_parts, accumulated_reasoning, + agent_stats, ) # 发送保存的消息信息给前端 if saved_record and not client_disconnected: @@ -392,11 +433,11 @@ class ChatRoute(Route): yield f"data: {json.dumps(saved_info, ensure_ascii=False)}\n\n" except Exception: pass - # 重置累积变量 (对于 break 后的下一段消息) - if msg_type == "break": - accumulated_parts = [] - accumulated_text = "" - accumulated_reasoning = "" + accumulated_parts = [] + accumulated_text = "" + accumulated_reasoning = "" + tool_calls = {} + agent_stats = {} except BaseException as e: logger.exception(f"WebChat stream unexpected error: {e}", exc_info=True) diff --git a/dashboard/src/components/chat/Chat.vue b/dashboard/src/components/chat/Chat.vue index 509971ca8..5524e787d 100644 --- a/dashboard/src/components/chat/Chat.vue +++ b/dashboard/src/components/chat/Chat.vue @@ -575,5 +575,9 @@ onBeforeUnmount(() => { .chat-page-container { padding: 0 !important; } + + .conversation-header { + padding: 2px; + } } diff --git a/dashboard/src/components/chat/MessageList.vue b/dashboard/src/components/chat/MessageList.vue index 8361b5176..cd14c6574 100644 --- a/dashboard/src/components/chat/MessageList.vue +++ b/dashboard/src/components/chat/MessageList.vue @@ -5,56 +5,66 @@