From acf150d47dd4954783c4e6776e7b027d472fefee Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Thu, 28 May 2026 12:53:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent):=20=E6=94=AF=E6=8C=81=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=81=A2=E5=A4=8D=E4=B8=8E=E4=B8=AD=E6=96=AD=E7=BB=AD?= =?UTF-8?q?=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 完善 AgentRun 的父子运行关系、恢复请求和前端流式状态处理,补充相关单元测试以覆盖中断后恢复场景。 --- PixPin_2026-05-26_21-54-34.png | Bin 192189 -> 0 bytes .../yuxi/repositories/agent_run_repository.py | 30 +- .../repositories/conversation_repository.py | 12 + .../yuxi/services/agent_run_service.py | 135 ++++++-- backend/package/yuxi/services/chat_service.py | 35 +- backend/package/yuxi/services/oidc_service.py | 2 +- .../yuxi/services/run_queue_service.py | 28 +- backend/package/yuxi/services/run_worker.py | 148 +++++--- .../package/yuxi/storage/postgres/manager.py | 17 - .../yuxi/storage/postgres/models_business.py | 24 ++ backend/server/routers/agent_router.py | 22 +- .../unit/services/test_agent_run_service.py | 315 +++++++++--------- .../services/test_chat_stream_interrupt.py | 23 ++ .../unit/services/test_run_queue_service.py | 4 +- backend/test/unit/services/test_run_worker.py | 19 +- docs/develop-guides/roadmap.md | 1 + notion_preresearch.md | 159 --------- web/src/apis/agent_api.js | 86 +---- web/src/components/AgentChatComponent.vue | 249 ++++---------- web/src/components/AgentPanel.vue | 6 +- .../components/ToolCallsGroupComponent.vue | 20 +- web/src/composables/useAgentRunStream.js | 94 +++--- web/src/composables/useAgentStreamHandler.js | 98 +----- web/src/composables/useAgentThreadState.js | 16 - web/src/composables/useApproval.js | 100 +----- .../utils/__tests__/messageProcessor.spec.js | 17 + web/src/utils/messageProcessor.js | 6 +- 27 files changed, 693 insertions(+), 973 deletions(-) delete mode 100644 PixPin_2026-05-26_21-54-34.png delete mode 100644 notion_preresearch.md diff --git a/PixPin_2026-05-26_21-54-34.png b/PixPin_2026-05-26_21-54-34.png deleted file mode 100644 index ac6aa1de6baf1d5f7e33080116e8161ee56a4660..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 192189 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%0A|N!PTh;=sAb4d$=Wd$Q|Y7<>-K;k~pEw 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

rr$M)bs%c_5UnGFWqvXt6@6T5VwH)YRtB%sKl?r zW}^>yEexDr49EYV-c-I{w$N`t)n)NNz1cotNwI!5iw2W#J5*n|*gU~<68#i~JW|jI z$hI;vxbk46E*0H4w&HNafG_huY4MxA*0nTe@<(9oc8y)TGfX9OV$3u8$>?Gh1boPk z-=KdU|3`9ijq|w&!NCLm{RpZ1t0?!k1>Nkmck_(>$DQh&hj?WElFf+JWvZk(i>@Yf zqR!$;xN$cB*!;Vai|MzQ`f>A2%Uur2cq|(9J=H&HvkiYPR$};|l~2isSOt_;tpA%A z(DNvOX+8f8TK7x*-D|QTqp6Ghbf<{fSr|&V~2hR=%gldC_X& zDT!YA!qxS62QKr6(XZ#DYY*jfv;cSqR8Bku11R9pe|-ov=YuV~(O#P~g1k$jv{~aX zlJG|&P!{>&S8&dkJ=s2}`oV+=6TlAvPprT!37UM&eT2I!xV#^~mF3SrU1-38ZG1L9DbX#aIO@ z)_XT>8}MhWTwRqwvq8`_QY}Q!@2$UqOV-_o&5ccUjn@ML&=e4>gMNB(d5DI!I)Y5z zycwlkZYO13^muT!#SY+R$!ANKqW7}HrK`CHLsYf+DtA2yQSr0*8Jj*&>8_Lklj|`F zJA}NA#3HC-6a#iL#%HWgK&MJqH-5nw_MGAo%UpO57$HFNIux$gesYli=hf0BUcJz7 z_o0>2YxlEvn^;M!kL=uAc#y%SXP9_0OC7J{^}cuPc+}pm+4qh+UirIa<1&vD&pMLQ zvXG?0hCK4}8dhK^b`|+Wl*LIz#bf+?R9NrNw<#fWccY?bolS`lGu_`7PQAZS8-O%& z8+EF8=U77F-}nRQ?=z2gcOeo|BH+$LeCbM5k>s2Q$c@(0T5@-j1eTYosukde<%m%- zwXgTXT!z(9>C$%BR0C;KGyHQYw3?$&bRKF=={7%-+&En$sqZMH(`j4`Tl|Rg27qth zSNj{A$XEq-aRT;dU-Kk_+MF@VM@#khW5##|eF%hvO1>#9luGyA7jZW}-rp*h3}=1R zE=BqZDKBuI1ff<@;tdd=BGHn<-Mkd$ zkJyKp#r?5#>b2lZT+$@1>-XW^&F}0)z~F!z zrHT)p7p=Qtp3Z!ZZ-4=^r&^Si^KKZ1P9FMle+>Cx^^#F@5Z|j}v=)z?`1N;~oFZ&% z_w-|qj@8ljW3i9ax_S-$6${-D%=OX!*Yeuc$vKBjL=2ve>Gk;XhTj)t5lN*k&I)iUroYO zeM&~78jmHX##3dtrKLw}>k&c{GVgike-zK$&-wc$wx{eLNgf$F{f<$J+BTMUmL6MX zW`P__0-tojiPmi^cL>ynTObx$O3oq~b102reW3#89-B!cRi~^}Mk=X~6>#50CigZ3 z4^K}8{U0G+55gnEND{AFxG5SU`}}z}i9iiQ7QU%*KEb@cy1vkD5s0aAwp9h!B6fb= z8OS5PGw#Uu^*$+!}6N2%s*G0Jp^U*R#l*LPjiLcw>Vk?*eIMW1~bE8j^+6$vcX-St9*Tu-ZGa#iW9p}Iv04ikB7pK0p}QwQ9UbtLgzUu~DSbgQ zKJMV#KtJjF0&8M`C_j;_PV#}@PlD}hz>C9QE^guIiCy@9?KQh*t(+@5Tn{NK0O8j; zDh?w8ZLVeCo9?aOFk$+>m>nc0Nrm0KBL?r_+{XDJ^OK>G1T+%&zxUucqxGmYgMjMO zs_6sa4=WF{*GDF?7r+bmN<(+Q{CL@6WXCWUl2touTlkiTTM?9kK%*>z`7#$?Ft+=6 zFzktA<0ib7l#5FN1PQaudBaJhJ@1r4G94U?Z}`odHyF!3ioN9ReQKr=-uuOVth*)= z*!m^XQ#)EKN2ViNhakT7oKDT$eBuPOo~I^TBCw2c75rn#D{Rh=XO1(B_GWi9f*y<@ zi=}e9XVk0D} zunP}x<~8-53Eh=0iGA(4Lc)6^8EGZ{H*6EnLL`awm1N(h$0^rL5K3++=vNh`Z$+#r zA&wetMGz4QvyZ)g{W?XmL3N>-EGH0K?p~JBsP|O{()YBH#hzO3|R}-ETKGyKYn(hw>8N} zeP>)05U8yQ{l_&DKgnyP^&P#CH3fdlHFtkRpecEGxS@2Q<9KzGO!(LE#93L8M78Z; z6Vjj1+w#IA|9uzN2vfc!kG3p3urnVaJ8$bfUBF-}8C3gV}r@_Wkeo zc2_v6xK5tf#C~9g z|6Z#<5ZLLjOepKS5rqwHv;_IV^))%$sGZrA z5-E9{8R^tT5Z{1vRwdcHbK$Q*E*Dohnnzr8{Yvj;XC&Y&vD>Yvm2T^~e!wv6RAsqh z*SS}#rj25Rdjb7EBgp_ z?e%wd>4B>|@4|}iG{SvoKEQl z&zcrKGiT?Zb4TY6hAZ85ySA};B2Z-^%fySBzd8-e%gdW`WYoUPmq8ia2h(ImFrozM za35w;jpw*$Bu2p zEBv~{7;u6XrIuu>+u!^QU^wI?#+()E!)ywsAQO$5bqZ3YEzV!W%CzqqM@Jbo1=o=)?+hok?&INNGOhyXv^vz@ejbuT35KbX+cM+a>aY&tJVN0BWyb z$1ejjSL~-+Fqu;AVq?&V=AJ%tE|;#Dm1EFS0&a>_B|s9`8srF}WMzRz_gnt3e*!la zhx|L_>nGq?luSnZ2hs=pN}$?}-t60f`>u6pwmkgTUvEEspkid;V+GSM<+1L)mG3WK zx$-0hO%sN#ee@+ov1i(p=iYz=8^Ztj5e2~XK067e)B1|Tz`_X}#M-L9dEoXiK1kp`|TGVWSEZ`pDySU{fp0}G4XjVXz zUtB*>yTO`gG}vVZyC0@)*i0| zSSNb<(^*;INeOU6 z%I-7ax3b>qYNT9MED(oNh>@wR%H^h;$UhskZ2Iq5)8YwhKc)_jl-fD2g$0qs5Agp&Dun*8_Ka7Z#lyqUva)5NmI5mpyH;J|~H zL(@KOOto+xHVZt^0F`5N9XRIiZPFKOC|AqaF1FwTEflsNv?)fp4>l<4- z6s9ho+x;&>W5i-PdW*-jTNG$EdKVLtklDqVV$f5*<}>i=kb7l)f63kA4Gco^9vk2F>i353z_=cb^~Z{Ir+~>c&BIS}&WzKJ#eOqHVoB zK|6MxVInAF6lIxr;Of<}n9%?>)cE+8@5l~m)tMmyT>T8+Q2pwD*+dV>(JhP%k`y>gk(hLbuM zRYm>X3{Xn4aLr+@J3uiBx)B1cJXr1~-psd0Uxon@o?$-|l|CUTY|n->eMdgSc!dc_ zYBC0PhaPP^I6Qb@3X6H74-s~{nwn2k+p2DNz0+VGhPRl5-O0+D7PMkSo7gPM?!#>8 zz?Y=Y0!26v^}>#GAg^>r&kMAZw*27c8{EC zgY1cHRmnybJ%zG+KvJa?W053C6yR~fPS(vE10PntWlNqS`lFx$_4(G3VJ&9QLsml@ zxAO4_z+(Y6WR-DVn`sRGigXcGOqw;T3+@(iK4r6~;?0T_fHnIj^%pvW`{?1}A?P59 zP@!ab%HiQ{#!+PvhJSt@W(LL}nJFe|`L~eoz(f@D@nom~gPFTrLA?s;!vkm)&Pa=@oxGA?RQ^>Ex;)2Ht9?L^vR7#6PKTibY&^4L5!n&7F z61S+D-y{@^mTRbp$D%)$cEtlXLzIEO!X0##EK6!EaQL>ZMxUem{f&xz;n#~7?Fb$c zy-QkIwM|J$!J8CZvJ+58ImHXp2eoY0(1$V~;~X4@)w;l2@C7Snv|s&p_GMMV4%>N_ ziGXTW(LB@4lgiqfHgsIVHq;(XdJ^FvdDxpIetb(_HgrisKMW*T zH2_F!K25`WtTM2ig7NkFZQKj%arF@Bx)|$8hHk}yrV>q~J)D66?&G3^%&=L`Lb zFy~<^0e%ihv+Ju%a;~=MeF8|^e;m;MJ*0RO@U=K(`)Nwv8}&t}U$qnwPaTpVC6k?j zC-zE+?>1W>jY{BJJPUg`jN0Y4(p-J5JRxl4?2r+FXB`WQ>IoYk*(m~a+mCrYo%NfV zx*EKXdOu@b4e9nnPDh|mvL2$XjJHWAfyPR(G z0#l!-!HWUQr!v@U>VBzrGv(rwuYxdKXtoE&RR|?voX*2P0Rl0jPzKzGXA)trA6a4Z_e-Su5MAS zC^ z1^>`UL;x66H9wk;0kF|N_;_?ymf%niYwpe=`3T;|Cw7HLrd+1d21Z5^Eay?{`W4cY zewHaZ*L&d(7~J6$(JZ2TjMw8;SK66i_>5}1wLWb#4GB0GYO3$7>)zXwq*1ddmgM(^@Pti>B_-S^RXDwp< zFq2Ew%WKOqZ+$W`nRfvTf9S&|;&R2L<2vwJz#P0kYI_VEI~cz1*sk4fX=y~-QkqSd zBwJy?)D=s$R)Wu2VGKZZ1IuhY-m6ddg~cBP8dne`*rnaD4Np^eC)h zF;1Rvw6y@P+QJb{e1KVt@&Mr-jy_1;_5O^=GSmTGwcYe#Af!$~9TuXnu8_D#b#0@< z6M!)K%iTwhknex6k7yeU9!>;i%7Njo3GD@qz;I|4VatU{KXO9Dpyfkue6`Uf5TRrR`RQ%hWKgltR;;!%)YBKz;*WASl6ajL{T{nS+dR0sb`^~QbQ-wS{( z!=^s5)l$K42l`p6_=T-4o+C~BIr?0CWFHL1&@&$>gqeKgi+#b*e!Vx%3c!28W4EV$ z(NdpITR#(=ui%uv+Nuu(9YK{PmTck9-? zFg=WLz6Zb2(l)yE-jlnV%B7R?b+_zjC0rYDe72U-?I(A?8u-s(j(>o#`3+`=Ge79Z z*3B?0Zf|rP#y7*J86Hu^7_=t3gHba$J${krMAt)U*Z4}I#$x}pfP)B;H2Mj=Jf4vZDiSN^x zvGP&mLFZQy+w?rSH*AH)g!K=FPMpwrPm}Kb*>hBDcl1y=luEH(=Ebb)S)36}4kTJ2 zQ*)>Oi^7vQo`4TL(lfqDsEIblB|wID9?hxUM7nU{zU)#RlB)s>b8m6Yn1uiVZ_$sO zqpkQ6)tPtd621D8u*79yz@i_WK5(CG;B=wJ*cmmNT8|z|^31+{***xr40L__7Gh>N zqj_iy`|q1HR9AW{Wh6-PjU%{UYK2iXcT!a_M(N~^5ZD*zBX-t<$B&a=47YjcGaAdD zNfA>VW_2k}54L`DXt;DQD;mFcLxv1dUvqmSW*{ODw=I1x`A6>pUTKhZoQ>x*U4`HKT~ za>x_Tod6m}c7tvwu{F}s(TRoZ(=T0dj??yk-QM5;sp+PX4f)8D9o^lwZE2$_x(VUD z2oHU?mp#ge-j7kg((1=6FW#XXfSWJ(qxj4ygxC@4#DhCD>(rFbjwGH3jOE*Jzr{Y0 z1tBY4x(B1dhs+klqjG8uBFsLZJoz^q6MKOS%&ZU6r;ON+B^J1YP??`Su%R+{7afR^ z79Sm;M^V7FM~LJn26(cBh`>e_pMa`X35JS^kP?G}52|hD)586ve0#KD_qdu!=-r_& zjdgJcTDrdt#G`?^6l4_S%f=W(4S2q4mV097z7h9Z1wu(fOK zfiiH|+N4je=`4wh-wkZgos5j}8s3XIjB6N#wMLv;2X~ygF7BE$2kz5TY-&@3Y2h^v z3u}yf00Hv!@>*GR8f*Lmvn?#r0s{^=>r1OBX~WIWpRB1&IhqubppJyEB-CRB5b@&l z87Nk`R!h!1B(&#nu5lk0)xv80mK}CjGi=ztSx;WDpojP-rzzD3^otljB)j+n242w6PMI= zkkP*SQa1tO2WaTAU7TQpLj817c!qiM5yz)nxMT-p=_Jz1M>dLQmf~SHA#iqI|71+0)!pEG4?f_O zNhj|iol-MPqbG(B0x=_DX$HO%xAoir9i|bTa_-NXwx|kkwO_7Y^VmwM(0K1vbp0xE zraCeUf{Nf)bEK+CAeM?Hp0b(q%kU|m0z-BbkQq?{w;^E2{75zT;b%<42owMj)Ey%& zxBe}gfkWCh_CnACr<%RvF^i~v;;cPGZKkPd0x>i-##fNvL(+9IvjZRN=o|!J79HvH zUq#ffLfwhJ>DFQWE-=TM7NCz6RE;C!sB~ex8Xi(&VI|^aBM>NFP zZ(AnTB{D3A@zPYd_H|FN^2mW@bYGt|OkOX&sw)mj#|mIEer|QUnjDOmQLP-u3_fMS zh+*6gMq^&F>dMOzhkSjxPZB2k4CdTQTJ`*AGtA9Byu47kBf(-1L`;xO_iXst`B8^_ zS6$vgi}Pd0KKr;N@z!lI4hqB~c5{B0kXH{mUjR5Hef%NxD?2-ojP=0ts0q8gef#!_ zW43EUUT=jw4Ks7V{s0{-N8N$t8}=*M@kwOd;vrLG6e~;#O|O7Ls6Eb!l8$rw;ED)! zEX?wg_T4%#RB3iZ&ek4c{xKY~(`vo5b5o*5j>y%+ytNm$iV=Z3FwWEH-U;OtoZu#+ zSQDU(g;D8&`wwqJW&=YX&U@!?Sqyne_POG?=zHR9xE8kM>H=lzV2hAVG^>s3x#srR zCA!_YEbWAyN;Kz4(*ZDfm5b$)s6~o74o2aGbwH+*0z?dD#08GL%+Rsu{zI zP5?i8i_GVpgXSW&vs#E_WqP-)Ob5z|*DGDy&yDEp9tF`Nah`wTPRP*K02)vMFwCXa zzG^9!){v&fk-7-`Et9snD;!uctxXci6XS`2@}x$O{{z%Q6Q( zy_Xtx4pQUsN@tyoK1Xu1uu?p&nm{?}*Pig!iMKMgh1kqmRY9@qY&Zf98V=>tPh|I| zqGB@&;s_(EL|3D=3U$JyRzgQBJvzp=eO*m(4xn!1d#?S&`mkMdfmkUP)OgGo#nb7& z@UTOBv^&E6&~$qso$L}`UL2-7Dhb#5C)bp>QT_k^EMpIvy- zGXT=fvQUKn3^aZ~Kw+Axv>G<0l$_Po`*IwjaM7Ai-8b*H5|+QIe#03LcJd)h5 z%MVIQ^n1s_35ANw@*8$jBFqY~u3UswF%*T)^EV%0mN~r`o6BXOJ#!4nR+=kcw@us6 zv-#aMA}D6gvmq!| z+Q9MmVrui)dceFx5ckjY9xZVszTX9>0&)W%=Uii+n#p#l=m_BX4 zMsANoF=Nanv`}#C;Md54==ynJg(>LB^X^X8UW&aYN`7$_w|vOL%Rh1Ldt>r`dA&hU zP!Rk9Lr_9sEII|Te8-^R%R47)M|l8=07VVX3BqwO$6HwRrZ6m97;V?0$wlAU7FIpB z{bz02zbvMy^`cP4IAv<-tEVD7&ri=&G68}jd@9b!O+AE_=!~Vk($rGdXH`-I4&D6# zPwwxl4abie#^bNEn<>{-ztUVW_< zD|GKpRIhT;9Xs&G%`zQoSm5=s8L@mtkJY^?n4O72LvCSPw|>j*{_^mP7g00Jvk}|q zAt@)W419J01tZHTrW-;tfaY0*5c*_w-fqYUa4&BqZ5r&(GC``Y z^sAO~`#I_e8r8cQ83|B6!@yVOH{RM>(;Ce+ne5;>+YHGtckGEg#U^b{VrNLRs)wz5 zI*N;j)zCPb`Grpr{Ag995do?k9owKwQo=p%5)1-jwEAm^v!r{&3GB$qU%btre^z%*}Q1NDDOo`2hvifw5B_6 zb9Ix7*lRpq6?23=RO4d?hq9Y3FJ`SDL`Hf9h6ZYC>KK>{<2JM1#NGjMD*~`D$pk6d z(N9{dz(P4`V+(a5Hkx=Fr8!tc5KP6L|E4~2WHA-_^)Aznp3_PLW+N6qFnTH=QGwCH z+f$M|_o^&N3z$2YIO~r>3sosLqTS?P8C9m&_#l-&*e|t@CA4>VhWX?mw}wi8tg<`h zIdR9chgk3GY&S#if%|mlp!kjP54d)EX5gF%RAdK#Wh<9EAa^T(yc_FFHP6l&8h`uI z-D{{Z^mA{1?$lW6pO$Q1rOSD8!7ab37^3zWy7a|tbd)hM{zSAAhL19(aToia2?V1WzEiSwVCR2Z-l*JvW-Rkr~Hj!98p(Q)n!C^^7&AJ>%4a0?46u_`9!`xd3Y^M#Sx9v zouAl==sF_TsQE7b6c&tn;=^+SM73-SF1Ri%;Y6 zGh%d=vxVR+)gfPvLx_gnCm&Dh=xVcNsfTWGw2C!d{^jCxR;yQc1p1-wbaG*{A z%5Y)rAiQ6s|9^C8c%PdWs_1~7)FCVvD?)vuJj5nr2ev|cPu%Ks( z-hrij1wK1D9Y@poUuCm@2}hLfAT1NTIax5m5P~Z$SG;maXHk*90RNG5z{-=p^fdlG zuN_@jz6q)jEP=2r(fuCV8xc3K?$P(?;Kp{mop}daz{;YcqR);@!w__UrxgZ9ZB(_C zp?Kc5?ew5IMK50TWgFyQ1v`s_7K(zA46S{x-ReF}_$A+|*V8d$e*Ka?1iJjZwZYCk zF(?;GQYxj~V!pakZQjeJZ0xrQ@r0?NaK**~6VL1Vuz84y<8EgH+MRRJb<4?%a&=YW zF`)b!0veFF6b}DF)BhF_(bEuAiZg<&uwzEPCV~?~%3kZAe^&MS4GO3DYAIU4gJYaW zR2HP3M6+qtR;UM$>4aliFY2=S*)@Oj=8eGOf$bwyeDLM^m`lOB!TcI@vJm)THX$Av zG)^!}(7m0V#)NR-SBS?G>Yb~%3{vrj9bZMLyET*pb%E_V>dI+)jWWOb$ih46kCHGj z2aHTfo;V-PLD;es4UfJTY#+xXD>+G{t~h?)_x0=71QQBnYt*a|sTQI7nBs46#KoXP ztin3FbXL2s5u4Cr`?-~=R)`t6y}0$_ z?Tv}!{WYRHx)m}!BKBerAflFk_Z`II+snBexQnkyyL)nSIi;zlo<4I1$|z||I3AYn z<>pAgP+j!asM?>nWbXOZpAqRH^}}gER-1^SzvW$Y8i@c|Lrc7%U>VyRkPKAt*n8bP z4ya0v{0DtR1`Hb7yU)iedsv#@4RW=Y2ZCocP(KDZo+vubk~r&Nq1`KRKLY0RNvse0HOfSp}+ z5R6K)FmCw-ZZ^i&He>2sYp$V_SDG!1!ZZIn9qG4E@D$xXS;pEXlvIH{K=S z{h15l0Q+(5r2oFz|9YbTJ;uM1H2yn{?`^gJJ0SlZkiVni-igWo9gzPH$bSdqZ*YP? zE0uTdiuj-ZrI`Ox%m<~wcx?VVApaeZ{|?CeVe`K(=Dl0-KOWG35#+sR_5VweT9sY! z2==!`@Vmm~Y~|w%A+C=>06gag&_zR|V@w_hz}Triv9C-qb50o!L4YnF31R!QAW7Q{ zh)4`zel?jez!^4U;6A_?;qT~FeI+|(==k0#6ju_Kcir_(d#PNQCe2ZfhEpRZ&93-@ ze0mU5=mvW)1B2Aj*>AM6D)wJoedin1f7B)X%lPHJJ6v*5M^M;ofFMA{)6xw-*1Jwa zGza)J`kPC;+yD@X6s)&{K+u~RIF4kSa16D#^<1YWI6ie93FK>8X@zfg!&b5@$r5mk zsb9DpLvBggn%y2A=RteHE>0;{5eoG#{tBxBC`E(=HA;s?V2A&r?)2vq`oPtyw^#;r zSHo+VThwRQ_e$$V>4NW9+E`(;C+!Y!6p$=#jraV8=dX%#0Eq_sAPuB8$pN+Ag(Ah? z0+^EVdYE7b0y&~3G}df0T#db(ZV`8X0%Xq`>P%xG@Sm|uE0^CPT!h+R<^wL{F3dvl z!c^$T{UXIHX03&7G{%aNRl|dGoM6CRDocRB>Z!)6jF<{>brw~z53BBv+A94~z&4t~ zlX5r)vE)cd$*>SQV>SqLkKLL;O1Q|CdduMspO*-m2?GJNT<{k}tgy)X^R-YMvV~Z9 z$;+!asC~+xmbz>ce?3_}LFflg%awJnPRzl7LmcKjR2DSab?5+u-t|G|jrKs$Le@pz z6GjQ8E*V!Qh<+lesnafBxgwE`YJ~2Xed(BLsP5FpwlIY07D#x{R@%A_GJYVs6F0`l z>PKgd1(*pC*gW8qhWy*NOOU6TOJaW_C!~J8-##BN^owrXZMzzBP8H)sppDI(-~wzh z^AhI*#{*uIe`C#f0RVtV)tCcVXL>a}OE6q!8v_p+$%H5N$wS0nTabH=GZJU)RyS*T zczC#E^`oMI%6tBeKTRZHL))o*7nz&!YSH+Ez-L5m!%2QR7;v1I0L_-&&~}B>Hqhb2 zp&w%!-LO(=v^cHLY^ZO4X(v?Fq|UF7}j7P zZ%kq^DL{fZ{CTQHEsl%Sci6+tsfdMqbI<=R{34CHDU(2??kXSZ|LKR;dw%o>d@_rh z-lC^ZSuhre1~nTybOaue!*`$;V&stbvATSJ>NMK7!ZM~xbtxj z8VU`qS6d3ONME1VgLq0o6iHG{QvU|r#D4CZPXp`hJ#Mojyy}I1SZ{x~eEIP?Tzwc5 z6y_zZXel&tye?4cC?Cg|=$ae5x`l960|cPjoTU` zOA29mH*N-~pa6};o5oahWt_36jtT@L%wJzqtq3%B-ARj_s{H3xo)SxPhat}M@6zJi zVCV@u4}29}tkqai2m|LQt{DrlCge8M0uKWJxpAXpL+vgLVKw%6x-09}h+83SxHg?` zw&(fdux%kBL-7%4##5c;*c-5RiTpvZQ#<|w3@u=rag_41>R+Rv8E zB_28L6lgJ($5o^ESU@_FOfa3c$o~uR)aU}F8dbJMM5~n~XGB*G&#HXo=s<)hX_hq) z)ws#;4N*^E_uUUh;k)~FAueahR30JG4-pm2# zm*||%6eat&vC>L_x0r|o?c!gfY#dKnE9hebo%uuWhCb=@p{Ei!yS2M(ZB}*kc;0W? zK?#+GX)P=PIV8jMJhtKH6+imGh$0#UZ^1cvNf=gxK)LntPUJ4GNk+k-c8BpEU{sXl zMVCR^luQr__wZ(v_X&1-bEc@Ybo@9yxTn?L^@+JPGUm;Y)KT>-utEbHG9#)gTogW- zPa%}rOk=m!RCJS7F37q@ACs&%uN;RF|EtwlXjqHe-ZIwv&9M#}_TXiBCoX~El40W7 z42!grJ`3X$3>xhKXaK{(<@M*iKLcTUIev(??$^guKlwP^#vfO)kt)Drx{gKDBJ z!5QFY^(rPbx$Z5vutFjSwRa$x+z`3ZSeX@huu@T>DiBgEa7BOi&BtDVP`qOvY zcU9zGK(E__Gj$rLS86V zM4%Q#S4fJ@W!0W@w4-0~`H?*m5M_+M3hECOU}G23m|z!80)-#72ebvQs27&5hmCKl zbNMa;K7_e%9$HZ=NYqt=uVzG=t0(Iv4X7QlZV>j+MHnHc3(j8@K@Q-Bf>bN)05L$#{W`Jmu~|S#P9l{TnI7hk^Fr5rK^Qt2Rgo`O z+KK{f>}@oECM*i93eXSV>(t&aQ2yF7yww)NG>Podim1}^B}5G)HuD2@0y>t4ez1-e z4FkOjJ%*(LrD>V-Rijh+4eD+P@|XwU6bRsz0P0034pbWoQsl*^CZr~GDI z2PMQG!r}$zN~ssrYLv?Gn?HlMvA0jlnsVa}UvUrr0-!k7pES9%KD=R6c5SL7(VEz@ z9|NaP&$Bxif&cP&Q&iq>=Pa-{tKH;E((d%V5{w@_f2hV_C(3(JS^?4?IN|_nhzteX zCh z$W|E8%-66!GYz;u@RW`E`Xv9mPB2tKEq>!0q(m;ruAAfM0gVcYdjRmO=V@Nc*tY@x zLXZr?w$AeN= z-9eMfLvZPkk-b{!;?oVNlwLXN0|z!%*_|!xlQjU=m#kbOQ**D|3RLh19jX5Lj4Sx= ztMYB3InrD4FkymdNZY6UA}9r3ZY5UI?r!zP783EdbMt!FtL+dSRnehUt;>3gS{ZEo z$}oS$VbTcloZbNZ2(2B#Lh&dAh?hlb6<3ki2(L$#8;x{WdD=8;=NRWITxXFEzBK6* zdUh>DtuM6o@({{a931XEf{G#!&dh;Y;~CSSW_?0dK+dEyz~xGPLeNj6fPmzhMtp*V z+)-40K#&uO3i~^^WKb)<7djJTEjah+4rWbI_`kjFEXp4HQ1_Hp(btI=MX-J1(4o*D zQeE*(y+*mHd|L3=U}}-R2w-v4PP%`R{@vyKRjUeEl8KZXQ?m{Ff^%4UMG-2-wKf@{ z$WLr8*^$FBE7$X}ZQ(7z<)9lV98KS%Y9_?eW=^I-`pPeJ1_H;3$D$K|p0OJVpuN_W z*E5CXPf=k3jjd3&1VRlJjAYMTh!+Su(%fD-%!bdp#yp$a`giiZBX0s6)6Myu!NrI^nyOHvcwmFfCRsy?SknIuP0=FOLErX$hd4_$yoy z0TBFMggQnInG4pfCRi)2Ahn0?2~Tf)u3kYn@RpR^a(Qjl+bVBsJ7G%exINWyLM9;w zR4105aGkY;+w5Vt##*o@bVrR!Ds`(KU2yA2vh9ZY1)V|Hz;}Rfbbsjd0s1e0<@_q= zI8N_%P_rTJQIL8NZ%dqmWWpca?J!%~1cR!f8yds>L(l|3@im@*W7eBg$HhT6;8~BL z(oCml52Tt(98x93UPrzRvduq)5gEvOlmo7gDs^R_EvP%)pCK*OPlL4;FsM1l+i3SI z-w*#wYAoZ_Q!dP3@uX0gaM-Efpm3j>;WIvw=-5J_@BP;3f6v}y;-UFK?JElV)|!&T z!verrn>=(y5>+6LatU9JX?PHP;5E8c4+`j|_>Pc}3w z!kS}L{nLgD)0o9~i_NP?Ay6UAZ})O}_BjcSE_Ji~7vt6e4ac@v(1BHRkL(&ypTEVq zJVR`AA(UfV$b*oMcKHm33EJTZ2Ys-3sq#r?2~772$&hM{bYlG~vBVGw6jLe(7&IK~ zK)zWREE^~86x+C1-7B$o%?|L|?y?K4#5gfyS%_e`E^C`3+*?OdDN1Sb!+Z ztzG6dU(Go;_bP`|B||!^@L_Ebswi%YS}tC2qUHqbRS> zev2;s?aPKi*$wd{;kSvpN8S^Tgo-4Jr(xRJTBQ-@g{vP022{Y3c`J$&G!enBPm8Jc zDTGtIhy4On|8|_V&+lqlb`APmK2fhvK3N^t4vJ~UH&9uiJrEjRRmT)=0hp0}KV%L& zErL3h5Lwe-I|l`#mcV=R($=|$Ft%gIEuRV2{toSFwhdhjFcE4CZ>SDKb1C5R#!-Y8 zgh$WPdYAIa7DSmxzFw-WB*(~8BfByEBKQ}`ZQl~(PVNV~NHR116}hS;eprG9AkW;A$yQ~aQEw`(RC)^EH{gV-K3;rr|6oK+Aysg;}z`s(gvv1 zhpK6n5UJn=IlMmUixm&gOWcj&1**RO`7yx-+}y&h!7lv;HcNO)8z4g1zpuzwdE%7N zQ#9BJCP#~u`hJ*#C4e+B-T@MYt4m$0%hPsg7udQfMms)n()2N3*IgV1%cQ^w8*ixa zZc?R>YezRnvLz{};F3Sc;uz`8QBR?7v*O{Uy~)}!>abJ>D&NdCWw^BNp_SSC%%x^) zADGY}LnIGB%=1*Tw1w0>#|iGF@1bh@mm}W{0%ZMbOROUxepBOE1MIG{(Q(MyrudEo z1<44cs8Kr%-$ECm5q2C19Y7|hRq6qmd`W(-O>K_XhU+AN1L%5;G#pKvEO6lMi~~4JxM?HiOKEyZ$ryogqFQ2lvRfbBMR$1 zI`L)j`Q64J+sq+lc*ARJLn;lb$OA_~3glA7=`%I20}N9NW6D%8D2HoX0+&x05UdKZ*cyo<_Hl7`S#tyQ!MMtWAFDn0d(>R|AJqBX8tuRB z_Iopv|6jJ-f1TxgFtEHEWBx<=|7C&x|H;vpE4tVWfrblY*TSIxb74j1wNtx$*!>kK z`{!wB^rbKo^nqxJaJ@zkQibo{_dmbTtV(f2QlmHu-a5W=e~NEWaj_NF36oB4f_B9R z{!BrrLSuv{TalfL{R*@+JRxET0sDBg zvInpTd*_J$FEb(-lr0XQzqHV^C*^lB zdSo?q5Hf1m8=>WsrhRxCP;ZM?9wjB{X{V+fNy49_^|$4kC7ba$LAw0#ch$sqoe^QS z4BCAdQed@#>DJHC#fH}Q2dl@bn11^v-MMq;Dz+$?5aQ24+IAvXupdg2BK2xGg!QwY zUI|Hu&Q}_Yp(y(E!5&VLi~%(`M>Kx283L-~tpD{xw4iu|8-1g|r-f6~2S12bz2XG4 z_^?_%8~z#+Cm($EaDBWg8 z?A46FQQHc2GKia{LE-BmyiTZt5xVfoI{p?mH}J_TEA6=TGiUIs_<1c^;%b%~a-r_Dr1TZCkfXV%PS>*SF{8k&17K4BXl3<-Hj*FMTTM$c% zsHiA7C4um^1KMLg5hZT()Dy7&z*iQ%e7O`(g=oWMBmVJgZ!J}5*{RRc@}cY&tljW{ zDXx|Yr#7(iUsqLOnglgE4Xo98i{H9o2alHK&Yc+$GlF*rr5XOX-kFL`D@IHvRSxI% zLno=k?I4PC%OD7xv=!}9{5+s@`ReJFFS_j#pmm|;^ZA|IxAADIVg~ypg90~EfJPKm zB%rk@hXz+`N~gl92`&fE?DabXRn%lio0xBpfpcf-Xu;SH-4W)4kSl?<%_%6qp@TuN zpl$&lXQLq$#^3wuKaYu`4K)yKoo}i@4EwEIu`tG}y zLRuyMZj5kTvNd^{-)dL7`mY%^m0cUXhS0f-l2SeKPj;$JAI!zTtF*pf(=)yTjYP<6 z;3al}Wu|4Q1dDDeKPSdaBW|?4Q2FA2&Hvu?{SDGp!azA=5ut zk1J>vwrM8T1e6iC00kaLs@a}t0g(TyiKf$Um4c!l5*-`}zKKA*iTA&xAoMLr=H3GI znkX??HXHgJJg-f%X&6&79AzpTi`E=cj)mF*+PgmwdsVg;Sm@ISeo%)aQ$eGiqw@m= zIJaS|HscfMS1IEeX^2sykNBP8c_d2h57-IqvS#A%@x@+0@C(=Hz|$6pN)s+$2FsNqeF^Px0@yP6br5mcRPbub3mJfY9qLIe^{&)6x3KNV- zY~XX?Yc>W8A($6#?lP^<3}}q5&QzEZ80SRD6d?j%HRv!N|&Mz{OC`yll!!fF9g1_G-#%Yki^gzdlqX{Q+d*a~xxWinwD z@S93mahN1vs5ucT)fb=Gx1p7QJUbAgLcnXWobCeO3|Ll*AG|L%pO1+$9Eh=mEr zBT%zzuuO$-3D=iq$^dZ*eyvQfw{YidlV&Bx5%_X~57T0(Mkp!6Kd>)Q__^f+XF$i< zm9Sm4D1&CkFktj}B9->k#E*==8Ul6xS=8F;<*rx@Q)Osiu&OzTc_hlT5UfUd9t{_v zvNl_nxm4O6RhRpqbC)(@TIu1o6zeQZ(*X#f3ab=KML_dtFM_nLaQ|{;>gdb}2TeqI z4zfSuZby^^(&g|J{HHF}7Wj7Rl101^{CS~n zN!+T`Z=3i;sO*BLtkJR=Eq;m5RN6yt ziUf6sw#MHQF`?xwH3V3w23ww6Cm;%;RK__U;b|qw6qyp(4I++=rXHu@x3lNybrkp< zs-q*s-orm*u|d0zOM;bR)+yr+DmwXj(1ldfuy&$B#a^$nU^Dx~{s1pPAxWIqQ~L*Mk&&;~ z23sK-G)dOAMy#d^EzN=&2HDEudVECcJeaHj&-O9hMW|b5vUz+ow4b%nexq^aVAqMi z=N(RovR+<8x5Ac{+9gW#p&X2XwJV(t;e^b*U>aS~)ek@%!?v++0LyFo<=g7)4$#z= zN^b!Kbdy*QHt38c5^;A>Q==zPUDL4cj+aaqDl@GxwN8Wh?bIO3x|)}5QeBO;O_(;F z7UXVM8J}_cA}~;|Ni^QDiK4{Cp@43t!>)(uWojGBGrz;WM;0$E6-{aq!tCx33&CAxMYPV9#_D z^i(O3j&qo1_;w9_%7Ju*irv`7qW)4>CQ|YiWKwEJ!j1z5rtnD<=7uh5C8WWtlDGV7 z9$_|kKDbjG%6{m*`bA)XEcV|SQ?$p)qqv$3*)Nf_6rnSBj{12X!(`ZMeuI505{nk@G2Yr;czrT- zD*S2iN!LOgIrwn5=z|&+7%iMrF(8Pi0=pC+Ti49i(~evz!Xc zgGJFqD6cmCN*$`b%)mS$O%DJULdB9h8T|NtDTc_mOW8LKNc2cVQMFmq^tVKbgd7=F zyv)mYniBR)r6@6vmX#t32s?o8>5U&5W>`z$+a)xNZa`8ki0j>`gCPKLTLx7jUgeLo z8~2|Hjy^<$bR_D?fRK>A(A*^;q73J&3$VsuR9r{oGk`^pDJ%?j^gL@(RmM6-SN0VARzfGZ5J_%Wz5VlJ+w7r?I}}J!RuezsB-TLt@Fu*O zB8pD=&V}DI@B+S>KB6N5WvH15sHudSP78ue7=N@dtQmVv=b3(*Q+uxE6-gpCdYtmd zWO0}b5S0)1%5D}3)+8`N<$Tmz0Gt6?xOp#J#EsJYv>?H-ap(?0yoG)uhYC{CA_qm3 zotJik+>h~=p<8+EC>e-NVvX@5)D%bpq92apM-jP+fGd0m7w+TozLbL=HKv4vjGQF z!wyoT%}CzbfReC5r$0p9>8&-TK|GyM3{Wc zJ>GE9(NR~7LXACvo?(^JmX^V_I9>7VJGfQFSWh1^$xbOY=5c&?08j&0jgqjJdlc04 zJaLPPe;Ej%TmvF$0sd5H)(^v$-LiafKTv%G@H+UYg*bXGD4rxoMbMwRnSx>!qO=&$ zhXaFw$H5$alx9`q0M+Bg;ue*cTMY9=ZyWC`baH{;d;yCdA-gX9BCcTzAd}@^_pa+W zwqsc^+IV*uSk8V4DxiBnE=unY4?+KAxG93L0rlGnQ@)|Db!+@E9anL;;k9Hnut%n- z&xW)bk^BUz$tr!b+ZqLBqw!&p3z|AvIwiNS%e_5;-pTKmXGrs^wKRh#+y&T`{M3-R*5PH^&=%l?(B+=9WO%5RmZMPz>-aN1#2$pJ{%I zQ;`qr&SvK?VzoeqpKu-*57%AECt>#2`LHnL32g9p;)!M@btZO4R9Tv3UZk4K@W8I7++FpFm?1G2s%N z8rb}Fh;3m|CLt)l#uO|gdJ8d3r11QMX(aMHh`xdJDhj9%b^rkwl1yOxlf&v<#Plm#!CD&E-mfjD0>`%X0;FIT zLj+33Y}6Y37taUnBcR#{pJ&Ex$#e+jqHL*eGSv+Oz?10R@cj_dZV7WV6m2G^3sqvF z8mSbUwV@`icg5C`ksArf!9WWiG7F8J_*)BXqhQBLu(+sUS-!m`Yw8;K`(3p$ul!U5 zbs_*uVqs875?RaGE+}3I+fcJY|A%f$&E2gu(D^7g(rD!Tx_khZcMRjVirt!baLMfLb;0G`DgHr)m_0i$pqJGu#!$u{6w zR`NNB0Iz;T6;2REpc5RxjVMDL-!#s~W{}_wweJKJORZyxKPgB((nfT0D->xF{BhVX z%QJNWD0YY((e&SohdAWJCTatxLDFaDC^ zs2|a(WV(Hh@sDC1M1ZBekUf#jp?!K@8~Fy1&rxV1WPK@l4?I531&Xm=iX^5C!EY*J zR^?ybX?+C-U`H^t(nW?6MU4?N@jiZ@J9L5Sw9*B5=sex^kb~vF za8jEA-1Z~ol9@v}PR@fBK;u{}3IQ{wI(<{SCK|dT^;?pBSdz@f2r}D=QPo+=G?bhG z7U?JMu&ME4)0ua0s8w?|yj0#4ykA8W_M(%M9>7T*JAuVhTUjWQWX?qte2U1*f6+L+ zmnaV{m{*1R*dfuE%f$chw*w_`En!~xIr;PULeHWpSme~{KcFfyuWDho4FK71i9JzI0m; z+sM6M8(#lRjZMxTLA)TS2&w8w$x4oML?F=Xd&Y%5!Mq|a)_4hF8qk2^D&dl zIqYOI{Kh!zHP+-g=`xSR&f1r_z`6h^?l9FUGjzewyYXH!M9q70^((;pC#XY)4&9bBW&>j&@ zW}FU%RY1U9N7D(n!L1sf*&mmWtw>{Ibqq9z8Hj(2z~JQ~Wh;JO9^TrBPFR5laAyQ> zQRxj9H`$VaS_>Y8QY`6}g!0%lQeh$-n*N$t{#X&10ike+!Ib z?47HsFar?5tKxH5jF`F`<9?iSs!ZFgDTf&SC%UT#wh+0mc2_&S>u6-l$t*+J>rOn7Z>B zHG#TmOeG?Z;j2a$Qs0pfAVk<1$Ty@>U>0raJ2Kr{)c$$hO~2vBVL_l~CHA zISA0!^V-_7t8GA|n(8~kj_2^aHm5%V!xv`E$UNx7rQ_K8t%`ZO4whXasU5u{X{$zG zjjMTK8Z!w?)V5z6lFvhVWaB=L<^epP3Tl9K9*ZtZkPF>z;GWHDPw32HQVpJ3+6g2x6gM72vGQBaMbi+> z#^`H)?r&-9`v2t^Kjtx>6=t1QfM$G|g{XersES1GzTMIzrwGtv$Ryy}(9-OjD6k9_ z{l)YhL2#8W1p5Rf!FxAKlN~o>EzdLdW1@rv;+a!0 zu&CcX#=I1mUTc>x5gB{@hYNO!xrvwRUYx^IP5kL6MrrfxvJ!h z4_{IZq`F~p8;hE+$YRGV5(6FO*n4&y)3c1_c`~oL2Gxj;u)E5miJVS601)9IsG}p| zh*A)rg!2Np`cdvbUX6o0{BJm%i+n6m`XYSxJ`9{bb;l?b43tqXe)+iu?_x3&4jOsU z69W%_G9=fVRU>^2@OoXUk_zck`p^%~&;i8hRO*;&GYnp_H57KRNg;cD|Nd^G zvJ5-#0MRvA(f^%lq~Fj#OV=eShlaD??c-?$Md_<`Z;GAp3X|B4bLWjs-!jy6s2(_9 zhu#jz->YF>rUdesDy*b2)Dy{(k?5bA%Xe@b3H^FuHABSo;umMICqyNn36bpS5c?@- zCL9zT`6H%aFCl8*)BQSz`1GD+7tU!Ct(64PQj+xI05VSZv_wB_En1IoN!u$86`CE} zS?;fsaOPUX(x#06uW=ug%az=%U%Yeq`u%z7UcO zn0=Yl)J3O(co3QJ`$jOF!^^A3kf>e&qQQIm2mW2Ho0XMX3 zYh1`TGugbNv)@i}?Y06w-4MdqKc6@A{?6kqA><*X-G3)jPW&TL`~l+mqk(@vjhDF5 zSj_En6P4Kc@J${Qp9d6}`G(N=!24sB!H#ppLfcpTHl0d~L*G8x(M3NlBPq_i3yYyhB14y?i64VH70_lp*Q zV6t?Ac#?B3LA>(bSSLgvV@t%5lS~S}N@V^J+Z--{a83)rx@*o)3(Ez`QS7ltqL1e4 zR+zFJ0~bpyUWoVbI}3fMW_vh;fsraw6o*M6+FU~+AZr2>#5J0@rso(Br3VK}gbhSK z|3-T;RHITIWN-!;xA?|RkWyCM1sR!fUp)T+REQfKBRn}SDH%p-C7=&En@eTa+BiYM zVKNF=j6^~yt)f9J4V1O88nS?NKR4GKPeaK+`X_psHG0!{0SXD*k8c+V#ZwL)IKat) zQd7sJNTS;moI>G=C2l4riV284B>n2hzovJ^Q)$+X3F9m0jWJ?8Nbgbu+3?()&Tt|J`X6Q@Gewl*K%Y ztW3=#%f}zzxML?~x=PLkusqm_p?MifqC@OH)vRR23$kJHnuAqQPxkw0dtnj@;WNS; z*tx+9L=SKNc}d9vp$*x1ASZ-yfTM zN5_!?SQB%07*TM@M#kDc76*&_6v=`6FdD~tSKNSU+Q9in=tuvD8pjteU*_+&b@`m} zS6q`fz78PLF0x>FFb@Ul?D{4?El+I?!b@9-jl5HRiwTU09hM&)+=vQdSi~<|vBCpB zLBceH+9fPO2~9P+R_!VRJ^;6A?v8h7jt>WR94-?|DS@W)z+Iv(1b!?rLiBjt?ZPyQTvs?>H&y5w3&G72#HLKz9T1CsgG?(f zLn?vQj#!w}8-jxgky$K`dl*|Hzuqg!6v3x*Z){5E)znUX`=G zv%JfZ#eo9{Soq^l<1k~?FFF6+9Rl%Sj!E*RXusC#w}Xc?_4~ow`k6rOCF}QlU`?J-^!MCD?(4*`@_Tu z?-$4DaDA~^v{TP>>eU4KP&~tX{0y!s%O4CBjW3(t0tyMTKaJBlbBPsaM<|e)jQff% zn!@tko}Qr)hU|=SP8WU5(D0rQP#9y69?VTApu=s_8+nOE;Rr6pI0QljCYqPx1p?PT zobP9@!%tMs^xDC#CJs3N5WF)MFge(CdjlE7?O{kdR`V`f0D~7nArN5NhpWYTIHflG z^hNA{C#B@DP!;|@ZB?wp^>o4Tz7VstQy@&h@ESitdawo5PegnIKWN8J+?+bVRdz1~ zR3I0CUR^b&<{KzX1Vwzatkfu14Qv|lum8OX~t%ul3LdEi5nKWk(G$0-NwviVFCkO83%(|JjukD9y)~Q8K4{uV97E1Q+9gU+ z)$2jpiVQ>cx5D?BxjeY2k%Am356?Z%8m%wiXN)jiRewvlgcLVTNuHLm17f2TKO6qs zv=}WrzCRbRG+ZZ~U(nAH*l6uL2U>kT9ThNl9JDIs^Q*xR-Nrnh9L8HtK_}nd z`U$gz392H6`8goDtdNnj|CF$6=5KI9VC-ZSXmTt>%3z`QTG{8=y5Z$4-J9+@lVy;Y z$}sA2@x8kWMzFBO`+`~MS~N*Q)v!%m{>$=2Gl&|Svjo@()TOfesC8>XNW$Na&ng`v z|K3o#3#3O;n^r(2x8TMN=c?h8|t-Ozc*M=Ib{VT-&iw6d?Os|N;KbW zFPbszCdrxr>-cEZ1lkiOP=|d?r7*{%*w>uprpalBIk`YNTN-saURi-Y44T07KI>VF*bC`1DlL zwx(g}P+_ciS`C(*b(QREkZ$Gqs3olS9>{71Thp&F<0#L=Cg;{@K+RgQuE?^Dot~ec z+Trnhad~8xWq8M7)nflr8wYw-tu0u{qYl)=)+nwY@=aZ)Wzj=C!TT7kD}Hauk^4f$BK{?1=Au zLq*4*hmmVt|J3LynL__g1G&A93O_CI8HwoQq<1Dy)~G#u1V*@4P(uto=ixW&Ty?0p z{e{ofc(4q!xc2njgzxbVJh5$Rs|SMQNUJmrAr~;Udjydhjp9va+0?yIQIxN`p@R7t z-*NuJ-$sYNPyG2y3fPhw_+j*CYF`P5hz8L$L7r5_BK`T*+zTqwfjPP&DlJz$R%S=^ zD5d7~Yl)O#PJbTuTLL}8c{HuSf@@#a++IG2fk!AM#fPW-bM{HAZywoKDv&gjP#9rG zLBwbvH+Xqz`vnpnvcT>cytzs!PI`xVDyshVQ0e|-47!Cn!Kf};a}x$83VswPlxV!% zA!!-3Q$LZIoWxa5G-JlF*?12UiKFNr&+pdkjPOfzY$zH2=+wA<`lnzs!OZXDu5!bN zaV*e&ov5x`^Sw@`v;i$zSC$dL1VZ4LmGX;Ow4tv}t~Jz2+E*sN^nRArEzt}XzF;g4 zzNBEY@XL)sh3#FwkfmO-nP?)5IUGvLz(kaq^1vY`D2=eb?y>X-vkgWs#kWz9(xgfV zO}L&%l&B%Sso5>e##I)9tWo(OMh^?s7@`H8oIIp1&gI25f~IFr58t!r-|yAapGAD< z)N%OmCzZxVNS5z{AS3im%w2Y>RqG7rSnX(v-7*~_RpM)V_%Mvc<)m_{i@s4k9wzu9lWB1O-GtXHI7kq#Z>eByG3Dyu zSY*$TqKW}dw}Tp^tsrD32!v#-YS{&dbPC=NexgPeH zY5H%}NE-U(X5c@VO@aJr&F#N*hI6n2g)X%u;P}xi*I_)4w^tQ3@yLKt2u{s>m>MEP zNGd}ZrPsGm-MYdi%1Y6o6CzEhy(azE;O`29!=N)Y>Ed}>m7mLK9?ND&u#eGhK)mqg zIvqTZ+P^L9VT1^KW|@Jq=DkcT9<^$>#t)s$(lTIF+rO1DN}Jim^z^bOE!AYTdwU~r z6pA(@n9+D(59HUH(4%`9(y?;857fX{U2IBd2ri}N+G`NIQX<(tUC`LhXbsCE{;Xi9 z3gK#&XjEeGY0-L2-ZUD_q-C1`I>Fn{)`+say_92V4iOe??yg%c8W6gAsjDf@B{P#~ zj;FI4mSJe`(&()w9oA)1(vHw_mE2ItlHIEzs^?ir#@M4&|0zz}K9TC%OhB*}D4?!n z5>jikwy_S&rY_oY+!LV1kvfO$>a8%nLbsI$Dy8ZyG+fl zsX3+u0i=i%Wj9)}qf`qs%_EE0Bh@aGgw##aAV>+W3|7wqm?r9tP2RRhT9lEX@g0Ij zEk*&)v;xg=j6^9+b67z|nW-^PJi_;5%9YO+d4U0uBBTa7n+vZct~-KUrqP3Pt6?;q z13Rzi<*@8k`jsrID)iO`s}g~Vvg(HgO&m7mo)|;-PBUUSRK~P@!&5Xr!=6gHslI0E z4h-1TE3kaGDE;O33U_FPJQL*oZO~Y9c2*!@+i>)J(WkKkF=DS^SK=apEU0J@R2pZ* zvhSsE3W|L!;2d!BfvK zu*_G8xTM7g=z&xC4{GTDkP~E9?_5=_HzCP4Wvov$ph9)zb#z4>G;iD$Ok+4?UIBk7 zAn7Mu;`DAS_Mw3%ay0J~Ey&WGz#7fKKX4cupM5{iOwR)&5UUh8G=$E~XKFE96j4=6 zJJ1>t#@Q0ueK}2|&a)oLrrWgyNd!%2)v|MORNwdOB`og|$hGWDGNNszEV}k$z3q~v5bp3{kMhIFZ z4hFFOFj@naMp3-NF)K>-s>Z&mr3W+A&=H#|FftWh$|iBz1czTgG7&_l+DodA*2EQ0 z6Vfh6hk-Clrb~#geJY2O+G%A{)!z_v&)es>Z|A$d1gr1+c5MFZmYg%xfYj0xAe1a? zrv_qXw8IHfs`;Dee^{X5s3<%V_D~;0;VoSVtoV^3eGG+DOKz}#_HNCuOL_AymW#S1 z+221oS4&--3DYl<03pt|ySKb^Sq*Tfx^*w0S5+o;kuMFtO#s`ba$F6dizs}pn$(u& z5-7J*HmJ>Jv}Ct$*Wrlvz+<=|pC;=sX*qz0jBTkP1i(((B;{ehs9M%De zeJ82_Zy(`+^`t%bbd+S|cLNjm;!hseo9N>0^GbClR5T&d=LQx&sjfQ=D6s3B3F(`W zlva(VhTC_v@0oo=5UY3>xy@!nE{F@sEHLy65kyTR3*>J++pwD@u8XK^A(t*)dZ=nL ztxb@tZl9u{CQu-Awt9rK{VHh?XWYrgcw_8XW7}++Jt5y3sW-<|RZgVI#Y@2qNF3gb zg9Q3kjICN#l@i-v0OEwAQaE(cI9RQebpFpn7fT}>DZY1w+ZGX4-4>0J#CQ?3N)&Fh zS&c`ya+zRf&`p+3c%dDw(8KsTGgER8PiG4}BW>beE?sg=-Ak(>O#?R}_YBi{lt;k; zlomUJh=FeVwB@%|+$Y){Q(J3(0`r0?*Ln7bl{(VZua?jw-_|QHP??T5(UTUo@u>^P zAghnelo8lh0+p@kEy+AW(a_mvlu&>t`eI<@wJ)kn|41im9Cj$$vld4#^DcvgZMOY- zFLOSLAt>z1+8Alqi6y9@LoXcrnq4e&hoHm;!R0j_Gn5bwy6NzLoIX{)>6T73^)#?? zRzum0tK9ZDNqcPgYz=JsG~QidNEZEuU~K`T9V)D`4!o(bfW*C|-6;cZt1u=H{jwLM z2g=~vfJGv{jEww5<10S!RC=E{7PRyr-|xBFRzIVxapT@kZVO7wR(-I39{8NztDCI9X#Oy$rQRMaT_NF z0K}iS&&v16Sbw+Nlf?@E2u0jz!-*=~dgJbNczhH<7rf{min$Q zJI~_4QlnPqt4^LAcql>0Dq(6b#sKNPMCe`BE{ayeSb=IHEyADFzA65Cm7=Y|;ijs8 zDTIja--2n8_!}gV7>j1q{C=YXcAvTVUfWC*mq!Bp|utU}4T)o`8m^Q=4`;P=ZxBW_T&EZ#`&WI_k~h_RBW zXByeZ$Fe0Bn>IsXgzn#kQqFmn1Q^6k;ttFcn0C=~Q<_^&JSP)}P89%DXlP+|&wmS=)Mf3cL8^9{99bEZ6>8o)0V#yT;=s3&EUl`OOa2M?Bt zKPcdZj)>*@E)tYRrzJja5C}{KSJ87oy==X*@g&{tA@P##Wi7w=QI>3sB=H=$8qIY; z23^0D=zm}Yi#k;OX1WzzF;_2E;7@{$1ujNG$Ux@A3KT%DyTM5acr+fY`hf!n9OIX# z&Gk_FUIv*_Lbss-l#D@*?gI$ihQNW=x~h{X%ErR$Qsfj)1FUwrprR{MWYpTxHYD}c zT~=*NtY6Fxq@r~Z>vGK{RHRisYuMOTlx1}ih=>Bx+!Y?7L^H&N&aSXDFN0$+h0zl) zJcNLQtpRB(=$YY>BjJphh-8YhW)ojwrevhcA9&Bw_*c{+DjE&21)w3r?!{z{@o{dr zjEeMbc0oJ8rR@^7$g*|)RkcKaZzN1ogLRY&F+g%v_BJfVpVl`` zZz+iOG$I~>?-QaTq;6`EeDt2bq#HqC&hTi;AKmqr0ign#47~&ux_!xd>dBd)lk$)8 zbsm;J&dC%$8de=z!@9=$`l6~B8_wVNx~6k1mT||6k3gPPuM5SWVtvE!@1!pF`?Jd50{^dCKhqBg4;v6MCn zOMd`n{si(W5t>>_JA}F!F4OdI;DK5j=FBa_oTzjM&MrX|M<5&}45WUh3twM^vjiXb z=9@dvl?khua`R20qx3U_+5s{DFUMt&GpOSKfbrP&!P2$(KznC;QB+G1;p-Pwv^1^> ztRJ2vY$<*ej~l(KKtdLZyLB za&50w?~Hzw?l_=W4Yhy2E5`%34*SCD&^pAf2yBU*)nNB39oF?!t>VHu|^= zUkvOhxNY9^UnRy`{D0^GTC4KQ>(kif*DZA1R_=D6@aKye9sSZ5_;@m!DthY&M z>fIJI@UO5$1M_XkO!4&T(}Eguhk_YWGd4GJb;H(md4jeE`$WIzwuRwQ=gIh^WJ~<( z%l=QdY~6ZB$)()GAKR*M#lZ{#*^wL(Av7rdv}h{mTj3HI?_}3>$*klW0yx9Lki3&` zOPU?g(F^da1GGsIEy}Ef-+s(-BQxbM)u=gf;$wA*YTx#6c^s%kBY6l@i+UKgL5-E z$UDOH=hZq{;yON5TFaD|0e7ayveU?=l_TlZ3(hFO9;?f=mTa83|vaJ12848eYMX#sO`+R9QOb)tMzsjdVbnm{hk9MW~ zPiue-W8(#MQ3Tl5>%`1OqGn;scN*XE+J-SUj^4PjwGLCLk9n~`f<0ElguQ^(h)KhIxwU(|kq z)D8Uh*Rk^g3CT*s{D(s;*(bgGdChHcho>~k-sj%_+n4{!zcQTV~l=`K|Xt;jq2$ z8gQn9MtTNx>g^+w+_u*~deS#PamWl5E+f*=k!vVw7#zz02hgggU|1I<7YuQ?ZN^ja z9u}1jYcpuJZRrb5RKx96V1yjj#$bc!5QMBxUB8SL(0T+O5bRy(K(yvo9AU6rG5%oR z$X5qo&KRsQsjVE~m!U`uemf`zbL);yGc?`Yh02L(MC8gO0jxrUb)%B%TGaqGq8C*S zb#Z&O^FOllF{shcm&d!1eY+$n?7LnJy2JgvUOxM7xw>GDCGBdwv{H*Q)1QqBcvhMl zn==k{J1%Vb+);G-^x~v|3$6UqfyFTk`}zo&qO|~rx(mhPXjRpO13;xjy6QQ`Ux`Vn z93L|NEE=`++*@9}2H8^HmeP;uKJ9onuLpxxT|1rWUoL2ulzeW+-F$xZ^YAbbi}AR1 zrpnahiQ$QY1ZyC_4(@;e+0|lBPp%EUgz@EwjAV?D*5=95~E2{hkz_;dlu}}a8iS0 zcjW0NK-lhmnHlX0xu>iTj7UDcRbQvu*$7GaJ&+)ul8O1q~f~_y;5%^bj%WXCBGDoRAfe?BoB}sg0EQIqF_CD+GgLR3KQngK8nkj}K6iD~`~4V0x~z#s zv!*z15OUlvKK4%aRX;@Vz3;(Cy$K<2y@&0hCkCv6#fe_%W-^M@I;dE=qP5n4_k(gv zKQ$?5pR6fQP0ZXbdrE_V6soc&(sJ?>fkA+CYu$izRU4e1di1qIgbt@(6^{)6Cc(%V zLRYXnU;UzIDj1tuR^(f6Eg5$lPdKo*VXKv~u>h~3ouO(V6$S~hhnUed?iH*Ty{{xyXh%ZR5gGrK@6Jlldtqg| zeOkeF6He^V(`wps!o{)}yfNnR>V7%v<_7#nel_f+Bq6%`_Ve23Wy@_-cUs=i;w*5_ zr%zWhD$~Y}msE*4YC=uLrlAV-8x0{-J5J@fIW zAuZqax%nZxM1nc3a2knEs|Cglb9u+qUWNi`|C8@p3yRQa=b%1tNFz#kYi>&(rm;5FIPkq%Q;W`sB?vyEiYaG z6axy=*|25gck9Z{?CMtdQ%4Igft&;I?P~)2E6kC4+saM1R;{)=7$i1N1t{mZv`TH5 zuL`)!V_Vr6)O7AlalfYIA4_omH&bxox%|(s_FC~3CJg9p>EDC>_trO1p@OU1LqX72 z3u^e~Dv2mA%TkVfm;W=do~Ll-izrAA`ifHr@P9jolskt6o`IS&%WkwztKmA z&C%jm)u#s9KuNoRTmHN8(z2;y_}q0*??EOXjhK9siv(`};ZZdKDiCZF_Oo!vnUxQI&^$4mFSS4};6Sd^D;Z157Hz1ftzXFW3hT-6t|j>K&HkR1f6{+q_aN-4Eyi`s)waWkDKJNfvXJh=&w zA~KZUPRnRfcWT~H3lv)3{wZ;@0L5fP!q}dIo`U7FW>et=EaW-Vl`V`v5ZrLR@6S&> z(sc0ma#m>QUOXNuYnAxq;%kUt$!>WUm_sQ5huco2`%2~P%MUBGJCm1|{}V_t$>oNa zQzh@cnprdC%0T{fYDWdHEXQg>f>_`5ZB!lUjDS6hMuqL2zk8OhIpHzCr^5>}Mwx65 z2;;WP2t8;u2Lm20ZMdu3GGMakM*@sX@(uFQwyZL)sgHV6qsbyRwi0qIx zwjIoDNzhw2Px-mB#HxiC?rMlc=#cm(Uf9@*2B0)#0-I}GDjTfl7&f?nK8i@o5eCfu zvh&jeH_by~1)@`7+P7XJa@Aad&bt4O3VFFPu?V--1YV|pc9EQdbx7IBogd>jjZvQP zm`bw0=Y?{0zyGdGg|_FNJ5byy4TPvpFe9cAD4AH==>MTJ+^E6&C>}3L;*iAGI7(**aAbpKXy_$ z(5u@s-E)r|J`7(@ZUogVUR8<^oI5eRR4T!4nAisyiQ%^E4XsC>9{fF0Si`t;h;2;$ z8Alr-Xn|qAo`i`2i0EiJf@j*)c$CnAYWh?)&7R=X0Nk{yJ}=lXJQt z;tCw~TC{Ghx~|hOlMJuIfc^{y^uQ<^Up}%1V`gwkQiGM2NqX!rosHJ=2Q6mQj6;wG z?3#;8iD8jtybnx?EqY63VBKlIJg$Tf!jSzBa$5{Yyi|PNF(+x6)ms;^v#efMHz055 z)!56;lJn9>6k3jlP_>ukS+YQV?MOs98Z&{4>p7RDrZ^ zl|Z>7{C#M6_uEf%d?*l_{!^PJGOWT0-nKCqSrFx0MowoL`)*EWm@K)3 zys#HRRg1uvkdugluxG&8hb8Mo6)QbDD;Q`hWq@eK;3sMgntvfCb~l^&<;DxX?Aa)5%h6Z}K^v`}m&NJ0dD_oa zjBlZk{qxbOSOIRE@veXB`_9G-fgKFQHXs*!skLMCC%9+t^}Mzfw;wS>Fp&3-Jiio` zRzsgpI#g7}%__NLGCY?_!Y&=OL(oxjZ2l(9*Q48R9VYR7@`)vrZ(a1inCNw3F}hQu zmQHUC$^gT`%R_$U@W%SnKNto4TB5gxMl3airAwCzr6O~gNZdvs)#V_U1Vfky287hu^Ah1+Pf_3@a1=_jehSp0Jn29mOXR zgoqUlQcJo4@;U!6GPdAAU45vs9lBJkT=|;HRx~C%eQ>ac$5Pkn`HB8H@X^2Bkknwo zORqGl?t2>SwRqoMB)V%p@I0%<3f_>^Zl*$_i|B$@H9pV8`8cS=XY?uEyoy_<6ds9b zdpTnR*jb=ZLvEfp3ZLZ>*g>$I&$o#ye%1oMlC}@YS&yRQNxnHyB7Pizz*_2i6$G*^ zAHSSBb_fV)um^KL8h7GiM9vDF415AE4^n*qIsofN#lDhqWw+QeUCceUi(dJAKNZYJ zzkOgx2^-aeOkI_6#T;+t9`kQ5PZ@7R_UZDY7i{UknA$#@-lgskIn*Jqp8}Yr)igme zMd*HXe{knK-4s3pP8F=}O%Tc31*p%q)9-pu;`X~vKZP6#STU%L^ zDpAGFGx>*L!TX;_)=*q=fzFQ9N33yOf0xDkCdh?6%U6MTJsl(~xp>Z$EO-j@MBx;0 zTP6hl7g~io$QKgXpzT)oEd`$l~ld+c3)kOcv^rc&+J zS&KXa9}SY5kBFE@VcAHsdRrRV6$eYx8tnPpM%15EvbmbU*F#cpp zc4Y~)%Y9^g?(tDMlC4kTJ4taR8!4ljLDPw!9=7wY{=e*WzQB?`hx@~mfdvIAaL%m3 z6K@_-8`pXiuMc-Ak81r zK&h%ix3q~Zeh201b6+{H81+MSD>fT)=QGcIuwVG{zY8!vgpW9a;Tc=m%T4ZI)4mz&K>CHD%o+X zu*fIyD|tKS(Pi0-GNFTS0scH9&`Ie?692I{PGv7j@-G{NZ;hna$lIRozv-c%PLlHd z`}VorS*8=NCP=N3uG5bvTAL6nlgXy{0aVxfy*9PJRTA1ntg~FmrH~mqczVj|-C8=t zJ}F4pe%RY^=`N|oMtJ0)WP-Y;SV+QB6EQr+0GnsFpt0f;JLHN)8sKauh)`|ie!Z&G zD<6>NuBz6#>Z|A(#z@h#+CG4ng-xXVzQ^g-ty`PUV0-k<4fbBGpmg`lscLg)`Ci?M zbhA1|Ta#}zvqmqydA8<6*q{z;FRe=eZ*IBN_04*Ls-Wmpx)Fm30j-n=M+BW{MIMS- zw65a1phf3>AmjLksXH%I196$PXvn56a&mIA>MeMw1x|D**-{19lt|Szqj)kf9oD!I z`j!eW!E62e#1RK8Xe~ShsV=+W605`J!Cyy3NoIxzR(jc**svtjzR+cW_MZr?(II}i z9JQAFyC=zD>mk8dm8lyBfl(fw$aYtst}x;)BwMCz&snOz%AB@m%ahrMaF~F3gk&D; zmsv(i(lWwwTy|tC`fNs>W_4Y^*}@p3&@CBZx<8U4ap-w@8?+YaSCxa98~htn>3Ss+ z-clNPQPu|`fHl4HFZDx-d7|X6j-f1J1^-XCz*8m|Rxszl-2<|kTeid=&s}-u^TmrF z*g4O5M|2G=xa`G0fNUClv$~!%3hHsb(N!ls3_K(9T8Js^F5qGy1{jB;=^>8G;OS>Y zZOiKHdUmGAt*RhLdnOJ{L=Plj&9<`-a?^fHcPl49!ikyCbhAF^7!NPb7$q_G+{wW4 zH;miiD0-!%8cXZ=hk04?Y3VL?5k534O41LfAYandWifVD`ZD+VTqjFh7l@|f1uLa3 zRoU$!aqFLh<|+t1+P`R|Gj6ehpPbXy5&iwid1pxOj-AvnrUS@+*kF^_#t5riO>9N9 zH4$c7_UH_O&BNx8e>_1>4d~B>OY}J2L0>WVC-< zT|7+vHl#UBry4WBWdD?OsQoA%-|338Mj$gcODm#s+pG@IJ~OVQzF(y* zxO-+l)Ys@uu=pU5F|*-V|BsJ<&SDoNG92R(SaFgzY*48xyPR+kG4x-2H%P9~;(7Vy zmn|1#>-pC~yuBC~x;Y-~f43FAAf>rlsT@aT`j)AeozDLTrN^a~5j&%<=OA<{gWo|e zGiz*qx<~PjZRj6-*cL=G!PZB!vb;xvJ|rtTrMOLykn7F zuM)J&O#hMxo;+b48w63M-WLpj2GA53dvar?Ac;T~j!#(UYU2Nqf#x|yN}N-gSba#0 z;DVPNI<^l2i_@IDZ-IvHoA<>`DNCtwSa_{poOFWM#(+@9ZW#iuM`3d-I3K0YwXW1_ zD1XvehTULBZU6Xc9nQiYcF@@u=y#9d@eAhvXePHoT4>&RCX@k%{JeCy;J_F1#nei**`)fN|} zWnlR=sWHnMu^tPEHG^AM=qB9Sy|nuYD|nLF zb6Ts_R$%PhX(f?61S*fOs62j)ot(?nYk>%M?uxBj7lX~EH#656iT8(fO1LoEt`L9l zmBx=abXnetgStMA)On5g8ifs|!PERrOev%>E0z|VSjmz^^S;w}H{!V9G7vtQ}m^X4c0E46e#~g<)|M|Ex{M}yb%6E8T>0TIw>d$ss*}rr> z`Ok`i({ObkQkyQLT3Q+1ve}zl0R4!r{DNgx2eDXRF$g~--4Cuc$6g_vO*P* zCGu*zTR$#(u4Yif2^lRCEVjj`FKSBMsoB=+y#n%QdCil`OvV+d^u*ml1NO$nB>x$$ z!gK17A#ltXVLeHjq8Zm}I_H1x`OOk81^_OfD5OYOd`o-4oszbT)y0@pU3)kjJ|9FX z*nMkbR3|sb+=?d*DbK2=rkh)>u=Md)r$Vzp+l~f@MBbPP4Q^JVmh|v|SbuyDwQbkB zUT!m%ne(LMEv;&^YBqKBzPaW#J*KrWZRZS#8(PZp8?=!>rfo2Ia{l?j0lRBtT=|UY zER*!qYlz^BQ%Re>MMB*Q3L|xyaBx__8Xcp{&oWDQmrji?@V^~`rknRvW$KU>E~_`< zje|r)HWhx{UjZ zy)V~JDQw^xu(#Vn26Jf9-2QV$H1&$D6SQkarP}P48(j3f{#(Y@vVuqQCFHfKxw7XT zJFop@?%aprtz1Do&3I-WFr4hCvDp*cyTPLQg*g>j_6XN*z2imrv46WBKv zC%+AhtC@__?Mu@n+z1}}T|T*C=D)tM;{Us{H^UT3|8|ihbk+zU2vePg{{nL!h1Yaf z%@D+U|2{p3InIC$qeRf@zkPHKrt>-ys;O&QZ`~W&!+uQ|{Ls32?C?uvg0`DG8=O5J zrbFfUxRZ>t9iOm_f(3mlzOH!w{@I9F(6DGZsI^bodM(D}j)j1PzI-Gywy#EUI1i&z z$0&Df9q93qU1*NNMMwkvBth^(M_+1tD_E${vN|WVEr57l@$Z>YnK)2`I5ijpMcj>*7sV zG^rP*X$bn45kebbH01K*Z~1xnxXZLB%+H^9~tn4m0&f`WR}tiW8kuLD{5 z=vrnfmD~SP&q%x$=gstxAU`o1(S#bBhFW>jJUm3HDtF5&2r(cP*XYPV{b_RsJ+1-y z-2%{Mn-KqQxoZd?lnp|CZZ}ONQKGNCPw#3LW7qxd3&yfo+o5+?^0a*!o{*}n^*h?k zlFFUds&7m8-0ZmuTzQO|_ap`8wqnqjBvrej%z%Wg$@x9Ljn0cr(p1Q5u+;Z#aGmkk zt)Yz`d^GmLvl7{oK8fV=x8*(?b%vn`=Pm(BPTlEPid#~UDuDv+e-4X#qQ(2+wv18R z(xLIMY7#YqTenV5KVd7Y1fF{mB}2#N*coe5sadBCqftoNT$Fnj9fCOr9Zds2#iC)`5b&Vis~nJJbm%6q&YVXPby`$q3$Qs(J;yB6`+-&K1Ba9@Rp^kP@?x*qP{1y5`a-UEBF2M z(?c<*{98E}|1?u3-^TdvmtTId>SeE+8@NOSVHqg~6mJNZrZWp-PiElF=Y&Ta7P=te}rzV-CAiVfc1jfe!QrkfP*-G6j1-O-~0R^Z!PF>lSt^Nx~O+86nS4WFs@BI3O z=OR4nmv^M4E&=a$Y1a?7%f_riW<3n&KVr%exODiO=N}2ey4P5otn3}(I1UkTgT~RU z(Tq4i9B#9fa#rui%6Pd`o~EL&yy#phUeGilR5nc7TWyZV)wYr?4QzuqNOxVSBuId*%UwRHexgI*x6nvdm@elp1Z=#Wy3b=MK z96mo#88y3>j7Q1R_qzwAgFn*S+v-GvE^fc_Rupz=5<3U&@(IkTJ;a|O6KY!sH6AIuHqT!(a z&7u<3>~z30mP3cj4G@vKk;glz0ycl;y<^ZCmi|2DMYM_Z+bPgYd%Z5v5<~07Ofp+fhpb+BXav z=cuSrizZ{$*xVaY6VbZlKx@M9jXqLL%47qmYcmVX zQSu!YIWh+^$!}lVFe7d5b91E8!x-M-rx=Ev%xcc}M$yDN{L+VQ;?6f!vQcl3)tuTj z;Ch-f`Z|Y@sx@v1reB9VXhF(NcZt|o$v0_CakJg8pQ(Zry?m+!E>-ivZ9r`1M0ytv z!cEGS)_48v7E5RfmqL%P9kVSeO!ZC!if4I^lZ;=_F)V!ln}D~z-LRid7F{JZFV3>r zOL_M~cT{z5iQIk~;ZC|_+b;Eu`f?5W?<5+Zhi)%Cuky>ZRYoO}5ag?$wm9Y5Bf1IS zoG);nRnyggBAxT%%Smr#`(r3M6&BWvlP)H`S~Ot*OvOsPzfAmwEAoL>Vs`P;B`l7z zW;=KzK~giWpeVE5jsG6h8*~NV=g+X_0^rJr(PFUrp8tyRCQ$6*6?G0&t;+m_2J_Aw zx1Q&pL=TJK1a;L`oXYm;!Mico`;@qx$4UgML0Wkb#-`pFGek6BD+FrxnUSbnrF)HY&{2A*JtBR1`K|#w%t$Cw zy>y`0GfTv-{?%9RSq|!*kd)Y^mH#87cLWl4j-A}DcI&q>vy3X|?Gu=n=6rnZ_$LDk z4~CP1MuV!JK}|>J_<{IX{9n|kR{>Zb< zr6M}-3esN6Hw&b;)8^E;QW4>kA64dJN%GW(C1y!tNVaAlel)J&t(fdE9C3t~Ti>I7 z{s_66$9?0T$^8*KzGS7RnIpbIq6h zGPlkZj#2}=&1w6e+kBml-TsN;6>zDgLXRsv9JR^zZn;f)^Sf<2kM3R0nytRsYKIA? z*?R0Lq`{a|BqKl(e(QP!P9%PtuHL%5IrX~=oB4Bnn3){N+%o#>m&5n-4Z0c>bLz^` z=+Eyhx+aSE;(B&f>6LvZuuzA21=_>8|h~96ldGEykW(;H{&KIAb)vrWGL<^?lprFa5yoO zLJ0=GLq6BJ=4%FnCCF{M`^|QYbgTjmzb6)#sW~ol`pxX5S*{m+WGH`XYQxYWlWQeS zy*iO8!hbIwG`HAu`mKZ1JIX7CP-(ann~8tyKRf+7`RP1gwdzm|)g`=T6sqa@M#wqC z_WDFx+|nl4u-|>LGt*!_WdGs{Uf$|B9Ab%i>{B)+*C8=4P*gq`UNl>P&am&%VN5AL z5_61l+DZ?bQddW?!@}#_gU~7gzjU-tO<_qSgTyh~Vqb0m&}s}$izKYasu9ebs+*lf zPThZ-ZwoY4Kmxt+ko5PZ4USA2F|_zJ_p3>jwoDsuZWhAj=|LN38-;gz|GVbb@7AAQ z9bgwmb9u3zjOm}C$Yy=ew@*Nu_RY87E}NPHM28QN-SgsSpM7>6xewHY9>{}Oo}F7S zu21(m%a?C$E6Z+F)m zrSIS-1s?zT#m9Ebx2}f=zCjHhLkzhvcS>9uo-oG5O~weXpNT||VBFRO@8dKK55Hvh6Ci<7T65Xff&Tq;4FsSZgMl;&GH zx^LxX{HpF#T$5JjI92djpbJ9prE*@=90e_}-1(TkxcX<$G80elUdT^=^VJ+2tr!>3 zFDNnp%R9t6;yj*ajXs|+fX@ir z0S@DN{OJa)9rt(?2ZlW{mVdUFcBvg{n`K%zbZur2c25#l!t9&ely3({R{QXA&D!(G zDAhlvV6OA<18E00IH*$jQ0(SG`tx zY1^{*04$VTp!p-o_ zgI~A69YiDBKLX}okC)t(5tx2;U)=f=G9d^^7N41MEOzM^_VG5kwojou!Sg-7d3ZU7 zmlhBfsh(ma@)B4b&iXs)YZfmCmD+l-eE**69nq54sj7vos@r)7rLA0>sEBZ1-YFU?;~pZmY>O|78@Dr`&w|+vr0_ zkG7hAYmz46_L5X2#>Tc*m5$L!!oY5sS5M#ZBHra94yGKP8T}dFA!LR2zKAr@7zN1{ zZcNjn>C^2$`cgdl5kQ&IDr^uUs*wF?oy(fxf^{-|7O@)8ZFbm?vLNv!!I;mAoxkor>omb+m72B^CXt_X;x}p;-57S z|Ni~vGq-5809@ps`^$esq>Mmh&AfU>qJ&D*-p(KKQke<#X;Q=fZ8Ex~UzL}Gl-&n- zVLrd@;MCdP=ahUIg{nJk>A`A1w4P&cDeK1Eb>{fT-7oOOVeNLa)<5pmZSLTD+cW>$ zA9gZSRzzXn!%GEzCiu)eajs)dO{gFVMYkG91~M9S$6}Hjn(K@3AlRC6G6H}mW@own zwtoJ&E?rWtoH@t(XdVz-aCJCvHlWUi@+`A(*u>TYee2H|F4xX~^{wUX+z>QX_e+_X z={w3fpIg~~!|RnWpW6s*Q?r%2NfMUmB1(!36B-UL$h|__5=(E?X3@HBzUS@}cYV$E zwlc6M{Z>as5ogx{^%Ke+mC|LeMh_hM#vA3Uej(;kU<3_RzNzPKK`LyLx&?ItG(OL# zYUirlFSjs*xrN>;mca;+N7PYZDmeiybY1c0yf&f+KfI?Kw=s@W^Gf6wU%U%2VjhJc z-#|bx#HO1QfUEQcfYhpO+b)dT5IZd&&#z&?A2OcOo+3l2=Yi**rOI2Ktm&GLEYk%= zTw*eEffj-0!aJbh_g**VXfs`VHS_fA_FWMbHCIMxa+wvMTLuS%e6}y3-yg!|R0G^J zH(hUn?}SS>SkG53(|sDrU~l%EIS{5&7jayruH9Mrt5=CN-acZt@~`1u7xpi$5cH9i z6-5QR_Vs;_TGfCm$WO81YOvb(w0G7Q?Pg_p5#(t+)7PxPCeV?N@fI8o>nVAKL9<5s!xba+A3>AL4W5$E#pvF<7oWy@l%~5u8oBP=xzloC9JumX zCZ_N~@x?5+iAiZKzE0}JYtzHWw(i!nD)>H(F*sObCH4w5wIO1(hO|~Efl2;)JXwnu z2vAnqLEg#&(cb%Q%E)F&3>1hiCrlD>q`Kw3R(8TI9Z~N?KLC;8vs&WmGx}v?-6=RD z>?{e2T4ukIC(bLs6KFho%Jlj5_!yI04FF~uxF0wAL+Ww2zE8m55&zI_06ZCkz3qav z9dPVX!-hEqF?Gew^~c9&*7;7DclwTt&{KHgUnPGMA-H!!T7f$z6hj3mU)27c#?lb( z^omfIKN7C2g5D6Wg$zx@o+gbb;eu@&cNxU<{{21bdcyvEZgNJwiXGLTVr+F+vg3bB zWX-b52U3WzKF-$M9u_S3@ZS7eu?-LEtT)p-)An4S7X@fb# zg^2yGd1>!JCi|8IMsv|?kll3u=+T6Dy^cN6vv2FM$Kv(HfU++;f_$xJ2~Pnkq3MP% zHr6qBO))n&vLFN(+c?CuY26`LV!iS9+g~g0j6zpG827FD2GPvQIpiwHv#{wQ$i^(Uj z4?zdB`Y>LVfP;k+c}z^rpoqqEhQl+6=AdcVq+&;5pQY-M@fQ1=P>q+LtgRNcnu#Ga zc17jp?Hxg;eumcGP)P){$A$6NQvWtuPmeGAn}+gThxL;wxS)l7Ouw%3^VfTd`WxS> zua{TSp_RS6UcY|5L|X*FXnpjPt?1b;wAXurL;5BvDgse*WQ7o|wzzWHH9UMgdQ43V zp=5Q{b64mZ>y?+Wf+f?vIqLRur-a&+;ftaNqQmS@Joe7Or&!JrS0*)Bg0~=Y=K5XT zJag;SEwhLT6jbZhRW+U`t;QUATEKDXes|#pkHh=nAS(8C5i7^cP3&Ick9#<12QABXCte5+qT~W0y%aP zL_0b_-!&hR6!yIAkWMjur)C^F`wOI*Q*M$wFj%>ICfP631nNqas&YzszDT@cZM7BP zu!`(0y9u8ne>86|;PBG6Muot*BQ0WDQLfH7ORUz^i$AbBl;hj9ZUS?}Y;sE85z zznY40AeSY|F+4)^kGD9x$kjjs=n=|-7_|d(f{#EUFKHJ-#9RUxl!57s&3%&HDDAMX zmof^7@P+zo<8T@AQYXk}4&Ku4a0_EAeAgT&2(|c+g%X?)pfF^i5zOD2$vq$gJZyH# z)RPbJDiG$t4*wk~AH64-Qr3Cr&?_!-G{yd;gMiI@8!dQAm?F&vl}LbyI>GbJOIJ~E;cVRxw_7q8?oc)A;N)ZDWq;J^w zFvBc-#gLiz@^Y4mqtdXAC*L-VY@RUr6Ey}ym2U@cp06jUs4s^gk_VJtK4~!n=NqNY z(ph>9E*Yua4@^xs48Q8xXxXZJyL%ZPyi-*3N}oCpB(7<4((tq+DmM*C{@>RA%t0W!26vOReMU7M$Pdupk)(1;-tJJ~{}#0V8p?9R87|~@m#2n-Aoe3cak;wv<`AxpoG34L zuEa#DNGXWcSZ{z8-(ZH^%wl~iIJnXFupXf5^xeqAUmwF3#7kL5diyl`$VpEU2{0@3 z8IHP6A5aM3gZ<63V$~6~yg?NWg9{Hv3g@owI{h^U*7y%-1Q|c}1AuEsaN`6YX}&kd z4d73ICi7bDVqX*OM#1>}wR0p3m3t~h3p`s;pna_zMqE)7jRbUqrZmCmWpRn%JLYOB z4{la2UM^BRv~H0vFK#Wj0R1B|SsFA+oro`_@!vGwIwiBLcdzQ5NdU_QG1Rrfx==-w zP}nxA#3D25c^NE@%w+Yr6m4`gdVaXdu{n5SMD4zjPmtf`j0_nqqTtVPouEIomnLnS zH`fXp$g&<*;$|5_6w|HrSpfE1Gs?6F-V5Sq+6YQem4DphJz>)8*MN&6;1V|#0amKM zX1~i#p{j%7Y>6iz6C=t#EAdDirzfORz*gBV&<{ARIUjBDtA(%BiQC ztVTwKq)$CWAu+hwi(H@1u99gix;s6ee&phnD=S`QEoOA%mq!NjkW0lcSEVX)!m&Y= zE8gBz8*XkDJ5=ushKTGc5#Q|FFY?>}bfbp`)ejq!SY;@Bw;xF>4SN)5J`2#B99py{ z4j1{Z84e72ap`4{Z1j)qE8ydVtt6$!&w{86AE~5(AR~fi?)E9c!>Y!OgqbWXc#DKO z9V1oGc7*SSZJjJ-;N`HZ0Qp@274+q+m_lT9BbM!P(hgkZNE9JBjA&WQ@O)~cS;ZbX zAB-oqBlM?TilA}24fdNyf2t3D|IgfVBrXwSxQE1lVz#Qu)L@E#ficzC2qq=p;F~>+ zvosszQ39rRK7fZl0u$|HC6!IeQBYG|YO(KoHLBix4~Iyl_&9P4Svh-;9b2()nN%;z zMX7YS2te=g6vYmnhsp>)({aP383q-VfGJQo!~2hU6C+)3FaAh};9R^@%UM;2at11S zsS&Ke8fEjH7p=C#^T8Ktl}D!4e;nDBW0*B>yD70X{Qo- zmMbwu`AT7%+DJw#e1*b>9}?r)i`chu&V1E_dV=6Zwbd(wtlKsa_#q*Eu5UGUmtvbvpL@(}NofcB_vd4MQvf=akHkybh zG~Thr(VvkEDpdMyIMR~uOB!}>g_$3@lAjx+0p*m+T_DR0r@V0*z7Tgdf@#N2);EWF zaPdgeY?iqshiLho^FUCWlTB?gs@>_Z7?m4i>vJqr1nCW5u*!mvO7o6fLG2+9ZrIbq zD{^$b*onBMe3POK*)a>d9M0N(4C${V(sNKbssNOM84Tdk z*~BVgOyG)ruT^v`l=~yXfPSdtlLyF#9ZulmJOpVbJ&BA&j%|w_e-7K&5neD49^key zxl&tN*4mV2@Ly@xRZPm^o^ij%oqQ{1e8H8NiO@~PUyh8?u?N54!45R{kZZJ3J(^bR z$ia*K8uFaJtETz+)j}GlUN-Q}mhSG5acEDDIlAUN(Qfy^ZXA$Ul%W&b>6K2K2miYh zih!u#3^ZDlM+UO_92Sm<-ILbbbiAzBwi#ZL^o$7SOiLb} zo(yTSxz+CU0Xs&f&|iI3Dk$PB?5Wt}~NN5X3%pOW2_EC8`AHl$BzSl`(yN9Z>Xy172t$W<5(ZsCL zD;t;49LW3k5&q~`$ibVw5jnypS~Q|vOr<0rKsSfyJhJH44HsHD9U`&o+wk`3_EKXe z6;iW@6xg92rd^PUR>Z}H)5BFXk~^h`XtQ-NUWAtn?^0|~ypbmYbORm?{~j=XyW+gG zMe?|xi(#SIAcDTCJ4T9Wie^gxggO%|O4-9$8i#@QpCk7}PC&e;YC}X<`DPMN!KL(h$bcaPFQuDvj$GF9(lVX0Y zxHFK$ofs)A&|&lj^h)w;N6qs9Xp+6D6R!3IWyxsz!LyV-h;7JIjF!%-GG;b2x&U?u zinlF350mDtewGY^qVD+E&kVWxp{m05(rU$7j{X&S0R0*T4w8iV!qZ37nchn;L+XGu z?8(X>f5JjD32*l-$Z9{IJXt|im(`3Ul+Fa}lfvr@x{iF?f1U(-0dijVBYvc<8AApYsA{8s z^eYD!&i`;!#Eh*w@A5dddx)b_SF{5tZH(hXI(-1Q12w;yg3qf>5DcXNn}Sk|8J+CJ+QoUAhjg78-2#aQCIgKo)ML2J#E5mv-RLAI|Ax+}3*0i=+j*=y|`^Pz**QVh7)A ze&YFiy|UNut%xW&L;g>DV&EQ#*Ck1{{S>Mz#gkJK_xJY8y~I*6{-(6$?$CBoN9bO#K!pk9At)$cbn(i6- z>QmKcS?8|($%gW8eGy|A+I&yX6x@zg5Z(SZ^3(qvuSC1wtk|Q;f|ro0v&1E|cSDOq zYX#YAkY5mDEg(nsFSOX>a%N_^W$RW3vlH+5cJ-S^Mj|f3ltB#)dk?Bvf$a@aZc1&^ zes;LMm6H*;Q~;XfpE0?l!#+7Pxstt4ADnC3%hZMrIL?25Iy-LDR^|jNu%Y4-C794V zN^6k#qf_$=C5ruf8M@%5`l@G{eD87EvSlkTw)=%`!sj#!+4~c=fUtWeZ2pldLVZa| z@c&*m|E<4J_yNAcdhYN=|K;RS2xW=_OFwqz z9bji^dp^;;mByZ`W(4KBFKzwFXZHEd26kox?L`f}|HlCvn57HuKdJpE_T719`L`#B zoF1L>pE%=q895TL27A-u^|mO~KN{|F0t-*eU=#(hh6cwcUbw^E)1lY<>mDnpTejSq zM#gOiwUc3c&i+HVUEnEM9{=ZoFt^_*clhc56V%Ne-uCv}nWtQvdB%m~C=@f&A^R+&`)BpVYS>4*dF4vp@>G z#v`ht5{kAvfEfDSWm7M1p?z%hjcW88>=2(efVrKd{`5<`RkfYN>UhXduo-b@WhWG| zbn>H;%R5bn!o@B(Vk^-+zv{&WawkfQA z;McNZP&bfj_yM|@v3p4L_?=~u?sgqtbmM5L=1g&4jT-W@y46;9B-mse=pqP(@2Xen z&sKT<`a|C!kWel@*(g~tXY&QLY2{7X4A5({OxsY=liSTeBMoY&+_!T}7A5AsjuJe> z*g0{wD?a|k6T{PPMEN}?^^5$1l;S-lih_SN%=0`V-LZfhiC)*fZnHg@zJbHMUrujd zoc{dH%`O4ALc(!h`h3iqGji|?KIs$p7$9>gY@ns{aldWI^**28F3F*)C8!tRc&Wf> z_}?|u3lWFauSTAw^*!=HqNGLpEXWKKaS9=6lo)pW}Y(4d##U zeq^EfRWl2f&4uVBSz%9AJ@XvGdQu@a@A;?-@`yJXdIb1iXHp8~CF!qI+!Wp;xRQ8K2-v5z+nD^-=CSr5j$HJvY$GnlmLf z#53ShZ=~NcFCY#sFqI_>0KsbSdHL@`^POw^9=UgQHIeVLb5xKqJS@rA}cEshmO4CNdaX$j2TqB;S%2`z{Wg1 zc+=8Z7ml5yWX zAT?l@8#w(WX- z*E3}h>bDhQIB2&uXlH#g`Ynt?1pmu{E3LJ7WSPOzYBUd|3gujomoW9`&FQ^X_OX0h zGOwO`9x)k-$qhmC33xnWtFma2WmManon0MMQ}Flvf5l_WPNT|h2tQeqefA{{ztZt= zj&OG=?d{d0?U~X;Zf-gHsYd68O|X&Kc4IRp~DfkI*wmbrZOYQ!%u!1Vy!;mc^2 zy(YYaLX73vsV`R<_n@F^qtM=CI$dtrz?Ew!iw5&b^>y+K$vqE(>1=Yr)ii8L%6ea1 zKk}4c%Je1v*Y+Z}yCJ;jX(^caUA}bb4g|ixcHw_JgpbGKQ&3-49~J-**?I zG9Fs5`~ERQibl77>jXoxGpH4o5l+*xy(yW5|L^>D?)91YJpp5N?~Cr&sxlR|G)(oG zmR7qR6KZKQ?Uw?dQEBe|%F~Zbm9;ACn^tn++0eLiYj;*D=N+iWVjO#ZVCGv1B9e~c zSlnbZEGY5GqAPD;5*#{Tg9@0@X4|{95j6q{kVEW$C}>pTvqy*EtyMUx_1niRyDjjRPVqe2*&rXI2cE}I zq{+>cKo6SZ;kL6i4Vo^)wPEjd-;oOWi`kX=;*uRjU=a@l9m zpGl8s@YLNkuyR_2-meA3>O!*LRfp7P`TidG<`~Mx6{v}?pLwrL;f<)kGO0jahw%^d zvU^@ot=SWOK_gVSzjJkppUqJ5TPF)#4m z{V{iOUyfUk=Wq0}I(^Wwa=X$&(+Rg;dr?vUlyfn#a))6^vr1w;Bsm9no3%008*i}q z9+4`|CLVv7FPb8dZ@<_}eM#JqbRv+gS{-%aKfQZ6c$3`v-@=nOHSXsV(PJDmc25rv zbU!%iN8oMpO>huqSJHP@?(Q5nHsCAOOBC`AG_#>c_5)uwdSYo0gGV_jL>^X3cBiuT zl40{Lo5)~jj{BuX)s7CCSP44Fo)%UxLb% zMj1{PD2&Ae+u8h4=mJ;LjwG%v4z3P)hg}2rf-iy)iAZBhd6s$~kl0{Y!IE_Tpb9rO z_IbZX)gQFFFK%Ds-Vi1%4bNR1sBJ;uXp$Ba6Y5-)mmr*OZfm4w@mjCgVVHns8BNi3 z?a2VYN8!LF$WC`9(A0bWZIDpWG5 z7s!+@dun<%hMzJ!T{wxfQb5cecNj3*tvJFyBBA@4v6K;}+*#%MU9}>@_j?oU@wn|CQ8m%;$Nc6 z%M`GRe`+a-c0N65%YP2`cz%KYEhO@|TbQvFITu>^s&;mrR#oC$mVEtGKdgEXvse&B z*QA#^e2)9)F|p^H3JqoXXLWRzaFhGBPv>T(vfAd!dUJPZY+mP{=Y^`N5#?Xs3IPtB zcf%)O=L5KAs?3FZ&=w9=JJQ+w12yBmXFR{hNqaF{hUK1tmwFdwmGe;63o+o{NYyk5 zzx|BIeE^Rsv=|2^7lqw4Sgd488p?bo1#=YX?YRw*{D1%dzERbZqP7Pf^bU$-|HqXt z@AceJ=6)0@MCk`Uh5^QDG~+S@Y<{tMyIrqQT0yBR;ZFZ5h4@!hzh$qsUwUCaHdV;t zxg=lvEQV)xI4W=-vVX=#AmlF^fZhiX3`~?H;F8HF-lWPg1_a=y9KqAB5{go;DES5W zt@tGu46&-on8t|mcNpHWlzdaRU3tqVP1Q{dn#(|!ErX;yHEw66B>^Yi96b5>NNdT| z3yZd$Z|dWadL2KzK?wIg@f|)>d;F4%-o7J5=5OiEuVc*PE+o(20?m~4naninVAccH z*D=d>`V=tOZ+zZJCBMB;4kc%)=Y`w8tJxp8mlf^K8kk6ucI`t~|91(@;64klkMcm` z1#P#MEn4h|iM!F%$ntL{xlpjS8a$7-;raKSZ#`t)JRRsk&T#L|0v8Z!w#hF#V@8M# zQQM-~t*nKU^fgYR*DRi+T;`Sp+`4F-j?h;QC|aB{?iaac_=-*>$M^9vZfm|0{Ids6 zZ%YF9)H0z%76HK4g9iFcJGeM+7hYs^wRWfszt_^L^K@WS(qE{Z@W-%tmQLy zO<#)RT3WWt0q-2$xh+4c-^7b2_E~jWN6wm3XY+h#H`|Ef;?E~;y4uur&tIR_A4f-l z&$XLJm+Y((7T7Yg?S%R>U%u>^%6FZb;}vzG-<0R=677mT_}tYSD}R!GHE?i3ewCSkNmuJ`n^LyiP*bDBy7eD#-qVwzHU##HaTfBGM|7!2r18Tne z|F@dO@EFaSWo~P-8QDB@?P4y&Y+^AFOJ$5rQ*K))mCnp9%&n123cDCKrBKo3Op$Ai zW;B-$sUC_>MLN2i-|O``_@&cf&;K8PP&$1tpU?aAzQ13uybV?x&;DsX-BXb|<$ZUn zl0F3k=Ig4%#$P&fcvI~pago1!nr}i)1&@gjUw7kf-^u$v%KyG^w!XIM$VsE8bv0|C zfx?D02r@L*g_OyQO`{p+`CunFRbaR<~ zq9s4~!@4(e59-^Q`)GxM2ybP(54D?mTGL~B{|S}HULR^cRHzBLv&Ma;Hx|_A{u2GU zi7m_}L}CqJAC4qsf^?|F53d(RBq((KC$Xt#vxXD|p5a@+|5W~rM3R2r?e z1BN&_zu7%JlsJdZ6KAz`?;eh%FCaO>F=q!jDQylWP%(gkFz;=Y4H7*7%_448<8Wlw z2bja1JNJ9kihxNHy1j7&kC>XhJ@ezp)G6(haHoU#>*3jc{KVHd_EZc_p@v4B2Y9mn zWC5NK)2L`1{t%TOO}P_N@u3)2!ph~(?h9A3iTcyM$Iv(YI#Lr(Hb9X;j??|k7cF#7 zt1wW!9U~{alVLbe55PodiH`7Cj3X0u#>9Dy4cSrXxVfq3C!gN#znDdEFk-CDEIDvl zsFT+}Y>q5CQL{j~2{0AhC&&d!H+xiU=jv130AX|Hk3R;GH4N0w4ErNj;&0-=7$4Ck zD&t;oEAce7VL`bLTSkTf)3*Z>Asp*{gd1FUnPNl2aa$;}rs}}>TV?|VV{88s|GQQ* zkL~NO)UF%2O+)Y@WMIW3!V%8}Pg9tJSvNsUVR8o&o1zP0O|wB|U!H`!Dt7$!77;D#YBtOTUe= z`25(9U}X~6FkD%zx~oWYz4>AjoOlPokdd7MqhvIH0COXG6_7O=#1M&`(R|~-73efm z>3z`B6}AV_u+c6$ zx3tn3&|;2Jr7F^fZvAK{#;A$)#M4x3%Q5(e-m@24F76tY2fQ z8&L8v{}8u0A{T1iK4^9NcfGF+yT-|2wB;KmxFJOsyCaQZ@Nsf6x_~Ecjy^x!D#j7u zMj+)gXu&Dbio6k(xBXLrzZto!&q{rDp{tu9#%p-Hrcar&kPk2%=$`gUEBq~>&+s_M z0B&Qx0vh=ovj&~ddj@v1^&q!EpraCKhiV_{63(pV>QT!))h+(gK*)*PSmY{EnzzR^ zg&Du=3|4}pcLbVsYJ-W*t3cP|VwhiSx5Yr(GOYERU%vTQ&*YbL@AWM|)sr+BM|QZ} zech-94gsu9hez6yPVhw2q(eQ`s-*{BB!pWDuZhO+Ie|iEo&pW2ZZ5bJZ9oT( z`j61ucyr`L0dRE*OkpNZonx5BsaI`|4uhf?4z)DzBdhSJwc~&u1Hl$)s=2&kHFA{> z;HBWdg{(OdB8hTe2S$8PK*%05_&%g6Sd%P6M3&2Rch)HF<0J3ZRXA8c48?Yp; z6vM4w_j`PGZfw%+ucGrR^HvpdN^>1@$>hF-n}RGgN~mDS(KpTt$ehy$G~Io{bgm*J zB2LayAeg9dg}__;LBi)(a6Qbe0F#ne6VV?Og)Eo=_6%&AwwjM<_Tox;CZ&ze7n5TJRf>^oG%z>Kzv@R&`zCn)L(oNHx+ z7r0p>)1XLBfw)ufCHf|nz6ThUHY}>zUV+C`GXLHovQxChY6zkYFu-2} ztfQ4S6nE7zl{+r!T5FC+x5bPoXhyQ~Q3dY7YAo15&J3{?1!A}PWnl{O$XMqpUK&jd1f~+OZ6KZ$Rcmc1|CxqwxxuFF&NU(XM5}?o9}Xr5qqi4i<_YJ%iU3E^P8Am> z^ChVr2@D#S<+7Pl2B?Bj0RMmzvGr7S1PEJqyWYNNm+(!v`mAT^vT-x-o*Nejz$46N zv5(3hS!jw3nE)|!47fjbM+xm2iotcF9jp;Qb2|e48T9i98xI3`1D$CGp#0T+GqmjK z(Hc{Uip_Cr7o7zK2tG7{;IR4EyL#!LV=kPPH>Wit9FxBfM+Y9$-5~1z4j9q^f+OH< zNl;;bW=IuHo}Q-*jJ2L@Q$yq4Ao%70{jA1wTqoCY{FQlKmHof&=PKCaR(hP%tRl~Q z{d^PiU*zYvjq*mk5oD;!EkyC@9g@XQqj@2;9$}_PRa6N&+mlem7=nNQCrizb|K!Q8 zkb|CRUjV&Hhs#d#64V;z|BmaxV?kgsJFR(d-KO(Ni*dOZ)WI7!Y|ygkFPaLF$OfX` zj~?CQfU?p(wR*NENbkzgF$AjHTL5R{(?N)q5=$dK`PPhTJmF-B570ZecVUwv-`5GX zOoi|Qp!~i(p^jfGdIpRL6ZTUAXg48&GaHgQ9T=o1_Dp9_6Et)&P#~iY;XJ#L(mXpx!6m-od zO3~)r;V+?4#q-W~Tkw1ZeZxH`-U-?OQVsK6?(TH6AZlcJs9Vs2rjALgS*#N6Ow}M; z%)CH1pBi+hi>;%DuCg<^j=b-^S=0aaMmO(rk_L>#Ge5X=RUIU zA{HWpKv50M6!=W*_^mxlIp_uiU~Qp$)cqF}s%2U%aSyQ0q#t*PYNi2&Vb5CZB)~-2 z23F`?LM)f_Nd@l&fx#2hm$icb)TuS-h06(XO8{(GYvAkELKujwK*dI}H+7DOJa9ol zF1A(~#;MG7cn^Rhg|MUd!@MJNz!83t+h2ro?X@`{B<0;`OjubXJV*Pg3mKJ1d#7Na znD!H7!mZT9vG1rOEg!gTKya`#XlfmW%oExMZ7iAAu9ff3hr!Q2e?EigN57y|@(B2u zj7;mm3fVh5|NT}xdD2j}A03HcI(@F+jaYkm}!nQ&} zVgMznbiN!^ei|A**Fwv-DRC0=R`g_%8H8ddNn>}sT3?Qw2OT4aWu0JTfvq$9&(T9m zQL0-To?QIdsVLVd@>T`i^6|i_vs zD|!JJ8l0~X;FL6;GWPJYhZWNEH6%5cC^>+cMx%FcT9V;jU=&Rx8EhrGLyytJC-WQe zMqumSpI=ktH3x7WlYSE}O40dfYi~b9>?q(1u1;!^h@w~|w=lv=GtH&E4f;1gZHLqcpx(3) z_MXBez5AzoU+5pf@4R!~(*>ixs)p|_qM%6l7OM$ryB~7TFv=vlG{)G0{ouhzF5~$| z!;~_rh~6s0FohN#@$2df3A0{BH-)vfXOzAf*siU&{snA5y~f%_0X|SY^E2|(2hNkx z#fT z3II&-E*+Gx`8W_i?4l1z^Jlg7V^D6fpM*&LIO4y#&a3s;$?yN9az!DLC1yq~FB|>j5fd>U_a+}G0;3r#%04MBlj~~H( zE3!OQv%vss5#!kV@?0AQz%knaBc)E(r({74EyQ@og>ccp6wIa^a$Yc^SEmC9^h0Eg zw1QOC3rJJrVXc+kxj{XZK%dV)_eOgZvbRsT>L*~=!~Kwjz5}x!1-LrGT(yD=a+R)# zxpY_qy(yQ+Q4)n0< zo8XzHb_zxNRD1Lh_UqRVK`)@+6%m`$70H;xVVjug=iX2%*1!J~;SiOoPMwH{qz2lO zvKnAmjXDr|3{9H?oekf6`{5ws45~WA<+jieg_S&t?Xtk}-GUACXtf0zG@ztRb6Qyk zh$a$w4CY|CNFnhH#2)-#(@z^!S!^)zQ)2}lTppUD;Hp@~e39K*X(fUv;5Om{P|udS zC~#K(e}MkTpD>I-+x9aW;2IAJ{rsX!LW#sTD?;{bvmFe0LC`T2he6r_vR7HP2r<;c#FWH zf_>=~bw*F((E`5%@y_O5OtuWTR$ZP|SgP0`@V&eYV19;$Fh3>5mlDH>{QSsAFil5& zxC!c>dSH|f`Z4V>7xpCT8+5g!<}x0L*cRGSC_px1k}cPs9pXSYL?BPxTV2$hI*Z5i zAI3J+|6jze{rdN}5T7pvM4eSk@0J|_x*G#ztdy$DC1on={c=uRQNu&*!5pkk_wg)$ zx!#ZclZz%ICn(7SP_*=jssJIM^v;kltW zK{}DG0HAMaq0L1I@QNDo;Yg>_!R@kOTu!)6*}f)^v*H)Vf(yifz}g71u0 zBKN>ye+kIeRb}u*ED1mltYdBNE&3%|U^fpM3+JMpiml=tS|FK23;*<5fqbVFOoFl4ayWQ6F@2HJw;)=ev0)o1 zuq}fBJ1FVDLmaOO4>ByWJfcvUi89C>-~j@{5k+UF-E_>KcHNVJ;maAAVkikN351_= zuxL+S?8)v{-ay*Jv$640;F?nT2sqFsE_XVLXdd}hD|N6QhV}%9IDTopxTBb!ZM;BD zpJ+897{!1ILYJ0yy}pFu@v8d!JdLWB`AqNxfB~QbDS517;(KN}*{C^010`y!P9E!;%vSPedlmA%S5J!5ADBZZE7MtgAMMvXOMa{A*_9}LzX;ck4&P-=;$%n1 z%zWW!FvHGYiFV35hDSjDx+F{;6rel1Gt+&Wv0D~8`l5}RzKEz4<1klG`_1pEFp=!1 zO0(-jR`Z*`{Gfu_&tz_wxhuhElbVfGiYV)dj9^$PHF7%U7BC4AM+G3F5Rqq0D$mSA zax5Pa0%n=3UIOkevS&*aVZN9EGA0!fTz1WLzUQ&B-_0)MweZv}u$_riZHG=lWnod* z5x`h_uPv!!?qm`$GcjZ0Qk9c68^cGlFlj&z3f=nnq(vyljmDS>N**>CD!}9nQw8LI zV4i3Wk})$esy_Q#s6*Mttf0kkSZ)>-=|NLHlqmSF`7`_i?ZjC@%lrHzY;7M6Qf+cU zHTBe4GLJrZAz((pVe-}%&S*p4bt(PgrjMxJTWc> znRw>DX_l?E<5KUpW^$3U-<+}I_;f=4`cIy3>7oZgH?k}ix)NfHlE_DBE6~^DHyvi- z$eG#^gBSSA=|CxwOtq|if)lkAscH98rsN!ht~Le!CX%BgL5oA?lIWoPuFT*dj^Z>v zr?$w$O_cSIc9Pj*K4fJMmL9>vOg@Ni44l12YQxu&g{3&r%MrbX6Hk;rghRwkOX#d9 z0qpB`ojHUORYrfzjw#+i2ArL^qA;NCoKtUABO7}iaW5hxw3@T59f5{;_QA97gro#% zoSlNYO5F?8QH*e*J03O0Qsfjig4T?LC!rAS?>Uep0u94HW=E)Pwi3e`;D!hsA`54s z#w#jrcuHHfKp;jAta>phY(N=9{B7`e2oY=%QkjM|0F0}iHIOC_5rDM^NHEMfuJ|pt zG3fT3D1e!(I$wd6F2l2J0ZjhjXOx!US>NGW^94t^0h4E;L3TfXcAKp7J_Q>lSE5LI z3?kW7lbS6~nme-vCVp12;gHxwRX<`N(*T+EiECTKao90%aG$E;xWtOTeG+dUEE0Fw zj~UX}!aWYpi7aoc0ODT9>B*w#)X0;LsSzAIx`Z^Q2g;RgzXpfA*aL9&T@@MW{m6V1 zrZ*HR>_&)w)?g-(iFQH)i=s5Dp=#E2Xz~e~JLZieP{aeTt>~EvBMn*L*7)p6Tj zFrnMQO6%2EtG23yM5dGE26E%jJ^9W@%*X6NLDf*5g%Dl!AG;p8I%Tsr&A zUI))3(43i*BBRpIw~zjjlXD!{5e&tV|IVWW8-IXM!GjU?nRrbe-`lU5*@L3>;F*|Q5sGVxZu zJDU2*Ret}5c3bIMtAf284w|cQ}_L*dlEy%XP=_sx#OFM8;xnGBM&BOLK?VuEvF z(~+(oj&)u01oa7GT0`T#qDN(TY?+J3 zjVKOsT`kz`SFkFi`c-rZdW9oXU@EZ6GjnQ3VZ@lxjnf*A zmAB0htILkN)}+kA|NW0^8S?JrevLH$NwgGHJD>#`Ao}7AU+{A1Jq3Y_)0z&ANhm}H zNiJNCBZ|ukAhJPN1sJZtyvM$g0WRvorxM`T| zyjJBxI!jz#v~P-QV-%B|2M%fXg2`Bh*L>K%xr<*Ke{6by(7w6J*-AMz;|sH85n~r? zzVj{RQe3hvR3PBb`Fea?ZEVPrF3XV8=9}X6j)MZ!sWmJ-2h1UL#v^O!w!O>tQC+zI zU{bROCszY!-#>90I^RwP{@uV58O2%fMQgP;E-RPzCx>r&bu#*XNt3`*&CW3lra<3| z9#*zbkA7hdo(kM*I5k@_LMX^YNGbAQalpigiT7cTMDWKh%Z8!(7BL0NJaC5wBHeAO zQqcE{+v4uF>Th@WH}Doh2R(qlc4@7<)F@7=ta{ZBFeSi*2Vx9a7O^a#+C7HG;_iV5 zaYK-qko0P_nIN`Qkmk0lr(fdde_z-p8}1?g^Vm*b9@SR4VWa$!CFVPpMy3|q_?5s zA?Cw6hyBjRQ64UbfXJbsR0mZS*d-9Fk#xi4335`iZ#sWlU9%TG@P0RpzuCZ33fl7M z4LN5u%fsPH4~@UvLgNm#$PJH=&qlWe?Ay$o>~q=KgHWEre+%&WLC-$}|BeIZz~8}u zf0}5ZCKwMGh47Q-p;%2UMXX_A`^#UuDvq8mQ5(B8H_f3L$6|^@HgZ(qVA}e)nBW!wFOKdR@`S`}`+)+ViFx^RQ(y zBnSJGRPKl9=;(}p|Lo3R-f-q@1D)qaOIypykmWD>A1J$9*e>hvfvF$gZC<)E)$3#n z-^3`MQnMvE!-%g}t|M%Lq*b&{3waf_#+dF&Fxxi=jRcStY{?e{g zkKc3Kf6{`oR(Pl|B7MdBXU!Sf3PUKQ;2xx)Jli(%#2njGmfAhHlB=~9)&Uv0(fRsR zy|qKl^`M6C%q2#zZh^;1)pr){plq4fDyy)Y);&LYSyi~# zx-@=V)ai_oeZDX3zTwE>im5MW8~bz{I7z(YcQ;;WdU10AZWC0zaFG7u?yQ0>8STbX9?x9467D1JBSUPM@ecI>oS=j^-|8V`htr{z(Rdws#UEl2#b`iAtPXEpJv=I^;6evjb8<9uFa;fJgy( z)GbqQDTHy80#a{Pqy~{1q^^mTK1jU-Qr{<*8l>I=Nev=3h}0nF`m-Cz@JkIMHHdUl z>)TZm$D_^%q=}IxMw%FDVsHwi@s!3>8qYem@u^=*<0*}&G@jCUO5X=k1AW}f2fJoyhji>1;kh)RoMpLPf#`AHhsFT;0xht8wlDVro3gW3>N)z+AGLafY zY7nVGo~kzLd_ZatsX?TZDg$_&0%<&@@s!51j%|GEm(qAj<0*}&G@jCUnw|ow8>MbE zm5MqhRSJj{5Gf$icuM1GdJ3d&l)BMWDx~pzTq1; zkh)RoMpLP%V^XDnNCA-oB8{gso~EZj>PD#>O{GE_&&Q=gc1_evSV;kq0`f$)Aq7MV zh!ha%T|IsZWZ|ct9hCwi1>}ipQVNI^5Gf!scZE|Rji)r8vTLGFVENQ9rSX);QyNcc zJf-n8Jq1!XO5JEG6?IIi6c8yOQb45fl*ZHa6iD4Db)%_NJZ3y=Ta40oAMc-e&sM>o O;X_6a-ZRj9!T$l@22u9_ diff --git a/backend/package/yuxi/repositories/agent_run_repository.py b/backend/package/yuxi/repositories/agent_run_repository.py index 5fe0f791..d387c07b 100644 --- a/backend/package/yuxi/repositories/agent_run_repository.py +++ b/backend/package/yuxi/repositories/agent_run_repository.py @@ -23,12 +23,19 @@ class AgentRunRepository: result = await self.db.execute(select(AgentRun).where(AgentRun.request_id == request_id)) return result.scalar_one_or_none() - async def get_run_for_user(self, run_id: str, uid: str) -> AgentRun | None: + async def get_resume_run(self, parent_run_id: str, resume_request_id: str) -> AgentRun | None: result = await self.db.execute( - select(AgentRun).where(and_(AgentRun.id == run_id, AgentRun.uid == str(uid))) + select(AgentRun).where( + AgentRun.parent_run_id == parent_run_id, + AgentRun.resume_request_id == resume_request_id, + ) ) return result.scalar_one_or_none() + async def get_run_for_user(self, run_id: str, uid: str) -> AgentRun | None: + result = await self.db.execute(select(AgentRun).where(and_(AgentRun.id == run_id, AgentRun.uid == str(uid)))) + return result.scalar_one_or_none() + async def create_run( self, *, @@ -38,6 +45,11 @@ class AgentRunRepository: uid: str, request_id: str, input_payload: dict, + conversation_id: int | None = None, + parent_run_id: str | None = None, + run_type: str = "chat", + resume_request_id: str | None = None, + checkpoint_thread_id: str | None = None, ) -> AgentRun: run = AgentRun( id=run_id, @@ -45,6 +57,11 @@ class AgentRunRepository: agent_id=agent_id, uid=str(uid), request_id=request_id, + conversation_id=conversation_id, + parent_run_id=parent_run_id, + run_type=run_type, + resume_request_id=resume_request_id, + checkpoint_thread_id=checkpoint_thread_id or thread_id, input_payload=input_payload or {}, status="pending", ) @@ -52,6 +69,15 @@ class AgentRunRepository: await self.db.flush() return run + async def set_input_message(self, run_id: str, message_id: int) -> AgentRun | None: + run = await self.get_run(run_id) + if not run: + return None + run.input_message_id = message_id + run.updated_at = utc_now_naive() + await self.db.flush() + return run + async def mark_running(self, run_id: str) -> AgentRun | None: run = await self._lock_run(run_id) if not run: diff --git a/backend/package/yuxi/repositories/conversation_repository.py b/backend/package/yuxi/repositories/conversation_repository.py index 67b7dd2b..4b3f77f6 100644 --- a/backend/package/yuxi/repositories/conversation_repository.py +++ b/backend/package/yuxi/repositories/conversation_repository.py @@ -98,6 +98,9 @@ class ConversationRepository: message_type: str = "text", extra_metadata: dict | None = None, image_content: str | None = None, + run_id: str | None = None, + request_id: str | None = None, + delivery_status: str = "complete", ) -> Message: message = Message( conversation_id=conversation_id, @@ -106,6 +109,9 @@ class ConversationRepository: message_type=message_type, extra_metadata=extra_metadata or {}, image_content=image_content, + run_id=run_id, + request_id=request_id, + delivery_status=delivery_status, ) self.db.add(message) @@ -129,6 +135,9 @@ class ConversationRepository: message_type: str = "text", extra_metadata: dict | None = None, image_content: str | None = None, + run_id: str | None = None, + request_id: str | None = None, + delivery_status: str = "complete", ) -> Message | None: conversation = await self.get_conversation_by_thread_id(thread_id) if not conversation: @@ -142,6 +151,9 @@ class ConversationRepository: message_type=message_type, extra_metadata=extra_metadata, image_content=image_content, + run_id=run_id, + request_id=request_id, + delivery_status=delivery_status, ) async def add_tool_call( diff --git a/backend/package/yuxi/services/agent_run_service.py b/backend/package/yuxi/services/agent_run_service.py index e3a59c37..169588db 100644 --- a/backend/package/yuxi/services/agent_run_service.py +++ b/backend/package/yuxi/services/agent_run_service.py @@ -24,7 +24,7 @@ from yuxi.services.run_queue_service import ( publish_cancel_signal, ) from yuxi.storage.postgres.manager import pg_manager -from yuxi.storage.postgres.models_business import User +from yuxi.storage.postgres.models_business import Message, User from yuxi.utils.datetime_utils import utc_now_naive from yuxi.utils.logging_config import logger @@ -39,31 +39,37 @@ def _build_run_response(run) -> dict: "thread_id": run.thread_id, "status": run.status, "request_id": run.request_id, - "stream_url": f"/api/agent/runs/{run.id}/events?after_seq=0-0", + "stream_url": f"/api/agent/runs/{run.id}/events", } -def _format_sse(data: dict, event: str | None = None) -> str: - lines = [] - if event: - lines.append(f"event: {event}") - lines.append(f"data: {json.dumps(data, ensure_ascii=False)}") +def _format_sse(data: dict, event: str, event_id: str | None = None) -> str: + lines = [f"event: {event}", f"data: {json.dumps(data, ensure_ascii=False)}"] + if event_id: + lines.append(f"id: {event_id}") lines.append("") return "\n".join(lines) + "\n" +def _format_heartbeat() -> str: + return ": heartbeat\n\n" + + async def create_agent_run_view( *, - query: str, + query: str | None, agent_id: str, thread_id: str, meta: dict, image_content: str | None, current_uid: str, db: AsyncSession, + resume: object | None = None, + parent_run_id: str | None = None, + resume_request_id: str | None = None, ) -> dict: - if not query: - raise HTTPException(status_code=422, detail="query 不能为空") + if not query and resume is None: + raise HTTPException(status_code=422, detail="query 或 resume 不能为空") if not thread_id: raise HTTPException(status_code=422, detail="thread_id 不能为空") @@ -87,9 +93,22 @@ async def create_agent_run_view( if not agent_manager.get_agent(agent_item.backend_id): raise HTTPException(status_code=404, detail=f"智能体后端 {agent_item.backend_id} 不存在") - request_id = str((meta or {}).get("request_id") or uuid.uuid4()) + run_type = "resume" if resume is not None else "chat" + request_id = str(resume_request_id or (meta or {}).get("request_id") or uuid.uuid4()) config = {"thread_id": thread_id, "agent_id": agent_id} run_repo = AgentRunRepository(db) + if run_type == "resume": + if not parent_run_id: + raise HTTPException(status_code=422, detail="parent_run_id 不能为空") + parent_run = await run_repo.get_run_for_user(parent_run_id, str(current_uid)) + if not parent_run or parent_run.thread_id != thread_id: + raise HTTPException(status_code=404, detail="被恢复的运行任务不存在") + if parent_run.status != "interrupted": + raise HTTPException(status_code=409, detail="只有 interrupted run 可以恢复") + if resume_request_id: + existing_resume = await run_repo.get_resume_run(parent_run_id, resume_request_id) + if existing_resume and existing_resume.uid == str(current_uid): + return _build_run_response(existing_resume) existing = await run_repo.get_run_by_request_id(request_id) if existing and existing.uid == str(current_uid): return _build_run_response(existing) @@ -98,7 +117,11 @@ async def create_agent_run_view( run_id = str(uuid.uuid4()) input_payload = { - "query": query, + "query": query or "", + "resume": resume, + "parent_run_id": parent_run_id, + "resume_request_id": resume_request_id, + "run_type": run_type, "config": config or {}, "image_content": image_content, "agent_id": agent_id, @@ -117,7 +140,38 @@ async def create_agent_run_view( uid=str(current_uid), request_id=request_id, input_payload=input_payload, + conversation_id=conversation.id, + parent_run_id=parent_run_id, + run_type=run_type, + resume_request_id=resume_request_id, + checkpoint_thread_id=thread_id, ) + input_content = query or json.dumps(resume, ensure_ascii=False) + input_metadata = { + "request_id": request_id, + "run_id": run_id, + "run_type": run_type, + "parent_run_id": parent_run_id, + "resume": resume, + "attachments": [], + } + if run_type == "resume": + input_metadata["source"] = "ask_user_question_resume" + + input_message = Message( + conversation_id=conversation.id, + role="user", + content=input_content, + message_type="resume" if run_type == "resume" else "text", + image_content=image_content, + run_id=run_id, + request_id=request_id, + delivery_status="complete", + extra_metadata=input_metadata, + ) + db.add(input_message) + await db.flush() + await run_repo.set_input_message(run_id, input_message.id) await db.commit() except IntegrityError: await db.rollback() @@ -170,7 +224,6 @@ async def stream_agent_run_events( run = await repo.get_run_for_user(run_id, str(current_uid)) if not run: yield _format_sse({"run_id": run_id, "message": "运行任务不存在"}, event="error") - yield _format_sse({"run_id": run_id, "last_seq": last_seq}, event="close") return except asyncio.CancelledError: raise @@ -184,7 +237,6 @@ async def stream_agent_run_events( }, event="error", ) - yield _format_sse({"run_id": run_id, "last_seq": last_seq}, event="close") return try: @@ -199,32 +251,38 @@ async def stream_agent_run_events( }, event="error", ) - yield _format_sse({"run_id": run_id, "last_seq": last_seq}, event="close") return + emitted_terminal = False for event in events: seq = str(event.get("seq") or "0-0") last_seq = seq + event_type = event.get("event_type") or "message" + envelope = event.get("payload") or {} + yield _format_sse(envelope, event=event_type, event_id=seq) + if event_type == "end": + emitted_terminal = True - yield _format_sse( - { - "run_id": run_id, - "seq": seq, - "event_type": event.get("event_type") or "message", - "payload": event.get("payload") or {}, - "ts": event.get("ts"), - }, - event=event.get("event_type") or "message", - ) + if emitted_terminal: + return if run.status in TERMINAL_RUN_STATUSES and not events: terminal_seq = last_seq if terminal_seq in {"", "0-0"}: terminal_seq = await get_last_run_stream_seq(run_id) - + if terminal_seq in {"", "0-0"}: + terminal_seq = None yield _format_sse( - {"run_id": run_id, "status": run.status, "last_seq": terminal_seq}, - event="close", + { + "schema_version": 1, + "run_id": run_id, + "thread_id": run.thread_id, + "event": "end", + "payload": {"status": run.status}, + "created_at": utc_now_naive().isoformat(), + }, + event="end", + event_id=terminal_seq, ) return @@ -232,11 +290,10 @@ async def stream_agent_run_events( elapsed_seconds = (now - started_at).total_seconds() heartbeat_elapsed = (now - last_heartbeat_ts).total_seconds() if heartbeat_elapsed >= SSE_HEARTBEAT_SECONDS: - yield _format_sse({"run_id": run_id, "last_seq": last_seq}, event="heartbeat") + yield _format_heartbeat() last_heartbeat_ts = now if elapsed_seconds >= SSE_MAX_CONNECTION_MINUTES * 60: - yield _format_sse({"run_id": run_id, "last_seq": last_seq}, event="close") return await asyncio.sleep(SSE_POLL_INTERVAL_SECONDS) @@ -248,15 +305,27 @@ async def get_active_run_by_thread(*, thread_id: str, current_uid: str, db: Asyn from sqlalchemy import select from yuxi.storage.postgres.models_business import AgentRun - result = await db.execute( + active_result = await db.execute( select(AgentRun) .where( AgentRun.thread_id == thread_id, AgentRun.uid == str(current_uid), - AgentRun.status.notin_(list(TERMINAL_RUN_STATUSES)), + AgentRun.status.in_(["pending", "running", "cancel_requested"]), ) .order_by(AgentRun.created_at.desc()) .limit(1) ) - run = result.scalar_one_or_none() + run = active_result.scalar_one_or_none() + if not run: + interrupted_result = await db.execute( + select(AgentRun) + .where( + AgentRun.thread_id == thread_id, + AgentRun.uid == str(current_uid), + AgentRun.status == "interrupted", + ) + .order_by(AgentRun.created_at.desc()) + .limit(1) + ) + run = interrupted_result.scalar_one_or_none() return {"run": run.to_dict() if run else None} diff --git a/backend/package/yuxi/services/chat_service.py b/backend/package/yuxi/services/chat_service.py index 7669e385..3b5ed5e8 100644 --- a/backend/package/yuxi/services/chat_service.py +++ b/backend/package/yuxi/services/chat_service.py @@ -711,6 +711,7 @@ async def stream_agent_chat( image_content: str | None, current_user, db, + save_user_message: bool = True, ) -> AsyncIterator[bytes]: start_time = asyncio.get_event_loop().time() @@ -819,21 +820,22 @@ async def stream_agent_chat( init_msg["image_content"] = image_content yield make_chunk(status="init", meta=meta, msg=init_msg) - try: - await conv_repo.add_message_by_thread_id( - thread_id=thread_id, - role="user", - content=query, - message_type=message_type, - image_content=image_content, - extra_metadata={ - "raw_message": human_message.model_dump(), - "request_id": meta.get("request_id"), - "attachments": request_attachments, - }, - ) - except Exception as e: - logger.error(f"Error saving user message: {e}") + if save_user_message: + try: + await conv_repo.add_message_by_thread_id( + thread_id=thread_id, + role="user", + content=query, + message_type=message_type, + image_content=image_content, + extra_metadata={ + "raw_message": human_message.model_dump(), + "request_id": meta.get("request_id"), + "attachments": request_attachments, + }, + ) + except Exception as e: + logger.error(f"Error saving user message: {e}") # 先构建 langgraph_config langgraph_config = {"configurable": {"thread_id": thread_id, "uid": uid}} @@ -1001,8 +1003,7 @@ async def stream_agent_resume( + b"\n" ) - init_msg = {"type": "system", "content": f"Resume with input: {resume_input}"} - yield make_resume_chunk(status="init", meta=meta, msg=init_msg) + yield make_resume_chunk(status="init", meta=meta) resume_command = Command(resume=resume_input) diff --git a/backend/package/yuxi/services/oidc_service.py b/backend/package/yuxi/services/oidc_service.py index 27a38d5a..a4972234 100644 --- a/backend/package/yuxi/services/oidc_service.py +++ b/backend/package/yuxi/services/oidc_service.py @@ -855,7 +855,7 @@ async def oidc_callback_handler(code: str, state: str, db, request: Request | No response_data = { "access_token": jwt_token, "token_type": "bearer", - "uid": user.id, + "user_id": user.id, "username": user.username, "uid": user.uid, "phone_number": user.phone_number, diff --git a/backend/package/yuxi/services/run_queue_service.py b/backend/package/yuxi/services/run_queue_service.py index c0de3bae..e6c13d35 100644 --- a/backend/package/yuxi/services/run_queue_service.py +++ b/backend/package/yuxi/services/run_queue_service.py @@ -159,13 +159,22 @@ async def clear_cancel_signal(run_id: str) -> None: logger.warning(f"Failed to clear cancel signal for run {run_id}: {e}") -async def append_run_stream_event(run_id: str, event_type: str, payload: dict) -> str: +async def append_run_stream_event(run_id: str, event_type: str, payload: dict, *, thread_id: str | None = None) -> str: redis = await get_redis_client() key = _event_stream_key(run_id) - now_ms = int(datetime.now(tz=UTC).timestamp() * 1000) + now = datetime.now(tz=UTC) + now_ms = int(now.timestamp() * 1000) + envelope = { + "schema_version": 1, + "run_id": run_id, + "thread_id": thread_id, + "event": event_type, + "payload": payload or {}, + "created_at": now.isoformat(), + } fields = { "event_type": event_type, - "payload": json.dumps(payload or {}, ensure_ascii=False), + "payload": json.dumps(envelope, ensure_ascii=False), "ts": str(now_ms), } @@ -198,11 +207,22 @@ async def list_run_stream_events( except Exception: payload = {} + event_type = fields.get("event_type") or "message" + if not isinstance(payload, dict) or payload.get("schema_version") != 1: + payload = { + "schema_version": 1, + "run_id": run_id, + "thread_id": None, + "event": event_type, + "payload": payload if isinstance(payload, dict) else {}, + "created_at": None, + } + ts_value = fields.get("ts") events.append( { "seq": str(event_id), - "event_type": fields.get("event_type") or "message", + "event_type": event_type, "payload": payload, "ts": int(ts_value) if ts_value else None, } diff --git a/backend/package/yuxi/services/run_worker.py b/backend/package/yuxi/services/run_worker.py index 78272eae..0d3d1cf6 100644 --- a/backend/package/yuxi/services/run_worker.py +++ b/backend/package/yuxi/services/run_worker.py @@ -11,15 +11,15 @@ from dataclasses import dataclass, field from sqlalchemy import select from sqlalchemy.exc import OperationalError from yuxi.repositories.agent_run_repository import TERMINAL_RUN_STATUSES, AgentRunRepository -from yuxi.services.chat_service import stream_agent_chat +from yuxi.services.chat_service import stream_agent_chat, stream_agent_resume from yuxi.services.mcp_service import ensure_builtin_mcp_servers_in_db -from yuxi.services.skill_service import init_builtin_skills from yuxi.services.run_queue_service import ( append_run_stream_event, clear_cancel_signal, has_cancel_signal, wait_for_cancel_signal, ) +from yuxi.services.skill_service import init_builtin_skills from yuxi.storage.postgres.manager import pg_manager from yuxi.storage.postgres.models_business import User from yuxi.utils.logging_config import logger @@ -77,8 +77,9 @@ class RunContext: class ChunkedEventWriter: - def __init__(self, run_id: str, interval_ms: int = 100, max_chars: int = 512): + def __init__(self, run_id: str, thread_id: str | None, interval_ms: int = 100, max_chars: int = 512): self.run_id = run_id + self.thread_id = thread_id self.interval_seconds = interval_ms / 1000 self.max_chars = max_chars self.buffer: list[dict] = [] @@ -97,7 +98,7 @@ class ChunkedEventWriter: async def flush(self): if not self.buffer: return - await append_run_event(self.run_id, "loading", {"items": self.buffer}) + await append_run_event(self.run_id, "messages", {"items": self.buffer}, thread_id=self.thread_id) self.buffer = [] self.buffer_chars = 0 self.last_flush = time.monotonic() @@ -109,8 +110,8 @@ async def _get_run(run_id: str): return await repo.get_run(run_id) -async def append_run_event(run_id: str, event_type: str, payload: dict): - await append_run_stream_event(run_id, event_type, payload) +async def append_run_event(run_id: str, event_type: str, payload: dict, *, thread_id: str | None = None): + await append_run_stream_event(run_id, event_type, payload, thread_id=thread_id) async def mark_run_running(run_id: str): @@ -169,6 +170,31 @@ def _iter_json_chunks(chunk_bytes: bytes) -> list[dict]: return chunks +def _map_chunk_to_run_event(chunk: dict) -> tuple[str, dict]: + status = chunk.get("status") or "event" + if status == "loading": + return "messages", {"chunk": chunk} + if status == "agent_state": + return "custom", {"name": "yuxi.agent_state", "chunk": chunk, "agent_state": chunk.get("agent_state") or {}} + if status in {"ask_user_question_required", "human_approval_required", "interrupted"}: + reason = "human_approval" if status == "human_approval_required" else status + return "interrupt", {"reason": reason, "chunk": chunk} + if status == "warning": + return "custom", {"name": "yuxi.warning", "chunk": chunk} + if status == "error": + return "error", {"chunk": chunk, "retryable": bool(chunk.get("retryable"))} + if status == "finished": + return "end", {"status": "completed", "chunk": chunk} + return "custom", {"name": f"yuxi.{status}", "chunk": chunk} + + +async def _append_end_event(run_id: str, status: str, *, thread_id: str | None, payload: dict | None = None): + end_payload = {"status": status} + if payload: + end_payload.update(payload) + await append_run_event(run_id, "end", end_payload, thread_id=thread_id) + + async def _consume_stream_with_cancel(agen, run_ctx: RunContext): while True: next_task = asyncio.create_task(agen.__anext__()) @@ -200,11 +226,14 @@ async def process_agent_run(ctx, run_id: str): payload = run.input_payload or {} query = payload.get("query") + resume_input = payload.get("resume") + run_type = payload.get("run_type") or "chat" config = payload.get("config") or {} agent_id = payload.get("agent_id") image_content = payload.get("image_content") uid = payload.get("uid") request_id = payload.get("request_id") + thread_id = config.get("thread_id") or payload.get("thread_id") user = await _load_user(uid) if not user: @@ -229,23 +258,45 @@ async def process_agent_run(ctx, run_id: str): run_ctx = RunContext(run_id=run_id) writer = ChunkedEventWriter( run_id=run_id, + thread_id=thread_id, interval_ms=LOADING_FLUSH_INTERVAL_MS, max_chars=LOADING_FLUSH_MAX_CHARS, ) await run_ctx.start() + await append_run_event( + run_id, + "metadata", + { + "request_id": request_id, + "agent_id": agent_id, + "backend_id": payload.get("backend_id"), + "uid": uid, + }, + thread_id=thread_id, + ) terminal_set = False try: async with pg_manager.get_async_session_context() as db: - stream = stream_agent_chat( - query=query, - agent_id=config.get("agent_id") or agent_id, - thread_id=config.get("thread_id"), - meta=meta, - image_content=image_content, - current_user=user, - db=db, - ) + if run_type == "resume": + stream = stream_agent_resume( + thread_id=thread_id, + resume_input=resume_input, + meta=meta, + current_user=user, + db=db, + ) + else: + stream = stream_agent_chat( + query=query, + agent_id=config.get("agent_id") or agent_id, + thread_id=thread_id, + meta=meta, + image_content=image_content, + current_user=user, + db=db, + save_user_message=False, + ) async for chunk_bytes in _consume_stream_with_cancel(stream, run_ctx): for chunk in _iter_json_chunks(chunk_bytes): @@ -255,10 +306,13 @@ async def process_agent_run(ctx, run_id: str): await writer.flush() status = chunk.get("status") or "event" - await append_run_event(run_id, status, {"chunk": chunk}) + event_type, event_payload = _map_chunk_to_run_event(chunk) + if event_type != "end": + await append_run_event(run_id, event_type, event_payload, thread_id=thread_id) if status == "finished": await mark_run_terminal(run_id, "completed") + await _append_end_event(run_id, "completed", thread_id=thread_id, payload={"chunk": chunk}) terminal_set = True elif status == "error": await mark_run_terminal( @@ -267,6 +321,7 @@ async def process_agent_run(ctx, run_id: str): error_type=chunk.get("error_type") or "stream_error", error_message=chunk.get("error_message") or chunk.get("message"), ) + await _append_end_event(run_id, "failed", thread_id=thread_id, payload={"chunk": chunk}) terminal_set = True elif status == "interrupted": status_value = "cancelled" if await _is_cancel_requested(run_id) else "interrupted" @@ -276,8 +331,9 @@ async def process_agent_run(ctx, run_id: str): error_type=status_value, error_message=chunk.get("message"), ) + await _append_end_event(run_id, status_value, thread_id=thread_id, payload={"chunk": chunk}) terminal_set = True - elif status == "ask_user_question_required": + elif status in {"ask_user_question_required", "human_approval_required"}: questions = chunk.get("questions") if isinstance(chunk, dict) else None first_question = "" if isinstance(questions, list) and questions: @@ -288,9 +344,10 @@ async def process_agent_run(ctx, run_id: str): await mark_run_terminal( run_id, "interrupted", - error_type="ask_user_question_required", + error_type=status, error_message=first_question or "需要用户回答问题", ) + await _append_end_event(run_id, "interrupted", thread_id=thread_id, payload={"chunk": chunk}) terminal_set = True if await run_ctx.is_cancelled(): @@ -298,36 +355,40 @@ async def process_agent_run(ctx, run_id: str): await writer.flush() if not terminal_set: + finished_chunk = {"status": "finished", "request_id": request_id} await mark_run_terminal(run_id, "completed") - await append_run_event(run_id, "finished", {"chunk": {"status": "finished", "request_id": request_id}}) + await _append_end_event(run_id, "completed", thread_id=thread_id, payload={"chunk": finished_chunk}) except asyncio.CancelledError: await writer.flush() + cancel_chunk = {"status": "interrupted", "message": "对话已取消", "request_id": request_id} await append_run_event( run_id, - "interrupted", - {"chunk": {"status": "interrupted", "message": "对话已取消", "request_id": request_id}}, + "interrupt", + {"reason": "cancelled", "chunk": cancel_chunk}, + thread_id=thread_id, ) await mark_run_terminal(run_id, "cancelled", error_type="cancelled", error_message="对话已取消") + await _append_end_event(run_id, "cancelled", thread_id=thread_id, payload={"chunk": cancel_chunk}) logger.info(f"Run cancelled: {run_id}") except Exception as e: await writer.flush() if _is_retryable_exception(e): job_try = _job_try(ctx) logger.warning(f"Run retryable failure {run_id} (try={job_try}): {e}") + retryable_error_chunk = { + "status": "error", + "error_type": "retryable_worker_error", + "error_message": str(e), + "request_id": request_id, + "retryable": True, + "job_try": job_try, + } await append_run_event( run_id, "error", - { - "chunk": { - "status": "error", - "error_type": "retryable_worker_error", - "error_message": str(e), - "request_id": request_id, - "retryable": True, - "job_try": job_try, - } - }, + {"chunk": retryable_error_chunk, "retryable": True}, + thread_id=thread_id, ) if _is_last_try(ctx): await mark_run_terminal( @@ -336,6 +397,12 @@ async def process_agent_run(ctx, run_id: str): error_type="retryable_worker_error", error_message=str(e), ) + await _append_end_event( + run_id, + "failed", + thread_id=thread_id, + payload={"chunk": retryable_error_chunk}, + ) logger.error(f"Run failed after retries exhausted {run_id}: {e}") return @@ -344,20 +411,21 @@ async def process_agent_run(ctx, run_id: str): raise RetryableRunError(str(e)) from e logger.error(f"Run failed {run_id}: {e}") + error_chunk = { + "status": "error", + "error_type": "worker_error", + "error_message": str(e), + "request_id": request_id, + "retryable": False, + } await append_run_event( run_id, "error", - { - "chunk": { - "status": "error", - "error_type": "worker_error", - "error_message": str(e), - "request_id": request_id, - "retryable": False, - } - }, + {"chunk": error_chunk, "retryable": False}, + thread_id=thread_id, ) await mark_run_terminal(run_id, "failed", error_type="worker_error", error_message=str(e)) + await _append_end_event(run_id, "failed", thread_id=thread_id, payload={"chunk": error_chunk}) return finally: await run_ctx.close() diff --git a/backend/package/yuxi/storage/postgres/manager.py b/backend/package/yuxi/storage/postgres/manager.py index f563a0f2..80e53da8 100644 --- a/backend/package/yuxi/storage/postgres/manager.py +++ b/backend/package/yuxi/storage/postgres/manager.py @@ -447,23 +447,6 @@ class PostgresManager(metaclass=SingletonMeta): updated_at TIMESTAMPTZ DEFAULT NOW() ) """, - """ - CREATE TABLE IF NOT EXISTS agent_runs ( - id VARCHAR(64) PRIMARY KEY, - thread_id VARCHAR(64) NOT NULL, - agent_id VARCHAR(64) NOT NULL, - uid VARCHAR(64) NOT NULL, - status VARCHAR(32) NOT NULL DEFAULT 'pending', - request_id VARCHAR(64) NOT NULL UNIQUE, - input_payload JSONB NOT NULL DEFAULT '{}'::jsonb, - error_type VARCHAR(64), - error_message TEXT, - started_at TIMESTAMPTZ, - finished_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ) - """, "CREATE INDEX IF NOT EXISTS idx_agent_runs_uid_created ON agent_runs(uid, created_at DESC)", "CREATE INDEX IF NOT EXISTS idx_agent_runs_thread_created ON agent_runs(thread_id, created_at DESC)", "CREATE INDEX IF NOT EXISTS idx_agent_runs_status_updated ON agent_runs(status, updated_at)", diff --git a/backend/package/yuxi/storage/postgres/models_business.py b/backend/package/yuxi/storage/postgres/models_business.py index 1c9a2419..69e9cef8 100644 --- a/backend/package/yuxi/storage/postgres/models_business.py +++ b/backend/package/yuxi/storage/postgres/models_business.py @@ -300,6 +300,9 @@ class Message(Base): token_count = Column(Integer, nullable=True, comment="Token count (optional)") extra_metadata = Column(JSON, nullable=True, comment="Additional metadata (complete message dump)") image_content = Column(Text, nullable=True, comment="Base64 encoded image content for multimodal messages") + run_id = Column(String(64), ForeignKey("agent_runs.id"), nullable=True, index=True, comment="Agent run ID") + request_id = Column(String(64), nullable=True, index=True, comment="Request ID for idempotency") + delivery_status = Column(String(32), nullable=False, default="complete", comment="Message status") # Relationships conversation = relationship("Conversation", back_populates="messages") @@ -317,6 +320,9 @@ class Message(Base): "token_count": self.token_count, "metadata": self.extra_metadata or {}, "image_content": self.image_content, + "run_id": self.run_id, + "request_id": self.request_id, + "status": self.delivery_status, "tool_calls": [tc.to_dict() for tc in self.tool_calls] if self.tool_calls else [], } @@ -769,6 +775,16 @@ class AgentRun(Base): comment="Run status: pending/running/completed/failed/cancel_requested/cancelled/interrupted", ) request_id = Column(String(64), unique=True, index=True, nullable=False, comment="Idempotency request ID") + conversation_id = Column( + Integer, ForeignKey("conversations.id"), nullable=True, index=True, comment="Conversation ID" + ) + parent_run_id = Column(String(64), nullable=True, index=True, comment="Parent interrupted run ID") + run_type = Column(String(32), nullable=False, default="chat", comment="Run type: chat/resume") + resume_request_id = Column(String(64), nullable=True, index=True, comment="Resume idempotency request ID") + input_message_id = Column(Integer, nullable=True, comment="Input message ID") + output_message_id = Column(Integer, nullable=True, comment="Output message ID") + checkpoint_thread_id = Column(String(64), nullable=True, comment="LangGraph checkpoint thread ID") + last_event_id = Column(String(64), nullable=True, comment="Last Redis stream event ID") input_payload = Column(JSON, nullable=False, default=dict, comment="Original input payload") error_type = Column(String(64), nullable=True, comment="Error type") error_message = Column(Text, nullable=True, comment="Error message") @@ -785,6 +801,14 @@ class AgentRun(Base): "uid": self.uid, "status": self.status, "request_id": self.request_id, + "conversation_id": self.conversation_id, + "parent_run_id": self.parent_run_id, + "run_type": self.run_type, + "resume_request_id": self.resume_request_id, + "input_message_id": self.input_message_id, + "output_message_id": self.output_message_id, + "checkpoint_thread_id": self.checkpoint_thread_id, + "last_event_id": self.last_event_id, "input_payload": self.input_payload or {}, "error_type": self.error_type, "error_message": self.error_message, diff --git a/backend/server/routers/agent_router.py b/backend/server/routers/agent_router.py index 58d3fc40..46264162 100644 --- a/backend/server/routers/agent_router.py +++ b/backend/server/routers/agent_router.py @@ -1,6 +1,8 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, HTTPException +from typing import Any + +from fastapi import APIRouter, Depends, Header, HTTPException from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession @@ -46,11 +48,14 @@ class AgentUpdate(BaseModel): class AgentRunCreate(BaseModel): - query: str = Field(..., description="用户输入的问题") + query: str | None = Field(None, description="用户输入的问题") agent_id: str = Field(..., description="智能体 ID") thread_id: str = Field(..., description="会话线程 ID") meta: dict = Field(default_factory=dict, description="可选,请求追踪信息,例如 request_id") image_content: str | None = Field(None, description="可选,base64 图片内容") + resume: Any | None = Field(None, description="可选,恢复 interrupted run 的输入") + parent_run_id: str | None = Field(None, description="可选,被恢复的 run ID") + resume_request_id: str | None = Field(None, description="可选,resume 幂等键") class AgentChatRequest(BaseModel): @@ -293,6 +298,9 @@ async def create_agent_run( image_content=payload.image_content, current_uid=str(current_user.uid), db=db, + resume=payload.resume, + parent_run_id=payload.parent_run_id, + resume_request_id=payload.resume_request_id, ) @@ -311,9 +319,15 @@ async def cancel_agent_run( @agent_router.get("/runs/{run_id}/events") -async def stream_run_events(run_id: str, after_seq: str = "0-0", current_user: User = Depends(get_required_user)): +async def stream_run_events( + run_id: str, + after_seq: str = "0-0", + last_event_id: str | None = Header(default=None, alias="Last-Event-ID"), + current_user: User = Depends(get_required_user), +): + cursor = last_event_id or after_seq return StreamingResponse( - stream_agent_run_events(run_id=run_id, after_seq=after_seq, current_uid=str(current_user.uid)), + stream_agent_run_events(run_id=run_id, after_seq=cursor, current_uid=str(current_user.uid)), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"}, ) diff --git a/backend/test/unit/services/test_agent_run_service.py b/backend/test/unit/services/test_agent_run_service.py index 7dce295e..e8c0a096 100644 --- a/backend/test/unit/services/test_agent_run_service.py +++ b/backend/test/unit/services/test_agent_run_service.py @@ -4,21 +4,12 @@ from contextlib import asynccontextmanager from types import SimpleNamespace import pytest -from sqlalchemy.exc import IntegrityError import yuxi.services.agent_run_service as agent_run_service -class FakeConfigRepo: - def __init__(self, db_session): - self.db = db_session - - async def get_by_id(self, config_id: int): - return SimpleNamespace(id=config_id, agent_id="ChatbotAgent", uid="user-1") - - @pytest.mark.asyncio -async def test_stream_agent_run_events_emits_error_and_close_on_db_error(monkeypatch: pytest.MonkeyPatch): +async def test_stream_agent_run_events_emits_error_on_db_error(monkeypatch: pytest.MonkeyPatch): @asynccontextmanager async def fake_session_ctx(): yield object() @@ -42,14 +33,13 @@ async def test_stream_agent_run_events_emits_error_and_close_on_db_error(monkeyp ): chunks.append(chunk) - assert len(chunks) == 2 + assert len(chunks) == 1 assert chunks[0].startswith("event: error") assert '"reason": "db_error"' in chunks[0] - assert chunks[1].startswith("event: close") @pytest.mark.asyncio -async def test_stream_agent_run_events_reads_redis_and_close_terminal(monkeypatch: pytest.MonkeyPatch): +async def test_stream_agent_run_events_reads_redis_and_ends_on_end_event(monkeypatch: pytest.MonkeyPatch): @asynccontextmanager async def fake_session_ctx(): yield object() @@ -60,7 +50,7 @@ async def test_stream_agent_run_events_reads_redis_and_close_terminal(monkeypatc async def get_run_for_user(self, run_id: str, uid: str): del run_id, uid - return SimpleNamespace(status="completed") + return SimpleNamespace(status="completed", thread_id="thread-1") calls = {"count": 0} @@ -71,21 +61,36 @@ async def test_stream_agent_run_events_reads_redis_and_close_terminal(monkeypatc return [ { "seq": "1700000000000-0", - "event_type": "loading", - "payload": {"items": [{"status": "loading", "response": "你"}]}, + "event_type": "messages", + "payload": { + "schema_version": 1, + "run_id": "run-1", + "thread_id": "thread-1", + "event": "messages", + "payload": {"items": [{"status": "loading", "response": "你"}]}, + "created_at": "2026-05-27T00:00:00+00:00", + }, "ts": 1700000000000, - } + }, + { + "seq": "1700000000001-0", + "event_type": "end", + "payload": { + "schema_version": 1, + "run_id": "run-1", + "thread_id": "thread-1", + "event": "end", + "payload": {"status": "completed"}, + "created_at": "2026-05-27T00:00:01+00:00", + }, + "ts": 1700000000001, + }, ] return [] - async def fake_last_seq(run_id: str): - del run_id - return "1700000000000-0" - monkeypatch.setattr(agent_run_service.pg_manager, "get_async_session_context", fake_session_ctx) monkeypatch.setattr(agent_run_service, "AgentRunRepository", Repo) monkeypatch.setattr(agent_run_service, "list_run_stream_events", fake_list_events) - monkeypatch.setattr(agent_run_service, "get_last_run_stream_seq", fake_last_seq) monkeypatch.setattr(agent_run_service, "SSE_POLL_INTERVAL_SECONDS", 0) chunks = [] @@ -96,17 +101,36 @@ async def test_stream_agent_run_events_reads_redis_and_close_terminal(monkeypatc ): chunks.append(chunk) - assert any(item.startswith("event: loading") for item in chunks) - assert chunks[-1].startswith("event: close") - assert '"last_seq": "1700000000000-0"' in chunks[-1] + assert chunks[0].startswith("event: messages") + assert "id: 1700000000000-0" in chunks[0] + assert chunks[-1].startswith("event: end") + assert "id: 1700000000001-0" in chunks[-1] @pytest.mark.asyncio -async def test_create_agent_run_commits_before_enqueue(monkeypatch: pytest.MonkeyPatch): +async def test_create_agent_run_persists_input_before_enqueue(monkeypatch: pytest.MonkeyPatch): + class FakeResult: + def scalar_one_or_none(self): + return SimpleNamespace(uid="user-1", role="user") + class FakeDB: def __init__(self): self.order: list[str] = [] self.committed = False + self.added = [] + + async def execute(self, stmt): + del stmt + return FakeResult() + + def add(self, item): + self.added.append(item) + + async def flush(self): + self.order.append("flush") + for item in self.added: + if getattr(item, "id", None) is None: + item.id = 10 async def commit(self): self.order.append("commit") @@ -117,14 +141,14 @@ async def test_create_agent_run_commits_before_enqueue(monkeypatch: pytest.Monke db = FakeDB() created_run = SimpleNamespace( - id="run-1", + id="", thread_id="thread-1", status="pending", request_id="req-1", uid="user-1", ) - class Repo: + class RunRepo: def __init__(self, db_session): self.db = db_session @@ -134,6 +158,13 @@ async def test_create_agent_run_commits_before_enqueue(monkeypatch: pytest.Monke async def create_run(self, **kwargs): assert kwargs["request_id"] == "req-1" + assert kwargs["conversation_id"] == 1 + created_run.id = kwargs["run_id"] + return created_run + + async def set_input_message(self, run_id: str, message_id: int): + assert run_id == created_run.id + assert message_id == 10 return created_run class ConvRepo: @@ -142,33 +173,36 @@ async def test_create_agent_run_commits_before_enqueue(monkeypatch: pytest.Monke async def get_conversation_by_thread_id(self, thread_id: str): del thread_id - return SimpleNamespace( - uid="user-1", - status="active", - department_id=1, - extra_metadata={"agent_config_id": 1}, - ) + return SimpleNamespace(id=1, uid="user-1", status="active", agent_id="default") + + class AgentRepo: + def __init__(self, db_session): + self.db = db_session + + async def get_visible_by_slug(self, slug: str, user): + del user + return SimpleNamespace(slug=slug, backend_id="ChatbotAgent") class Queue: async def enqueue_job(self, job_name: str, run_id: str, _job_id: str): assert job_name == "process_agent_run" - assert run_id == "run-1" - assert _job_id == "run:run-1" + assert run_id == created_run.id + assert _job_id == f"run:{created_run.id}" db.order.append("enqueue") assert db.committed is True async def fake_get_arq_pool(): return Queue() - monkeypatch.setattr(agent_run_service.agent_manager, "get_agent", lambda agent_id: object()) - monkeypatch.setattr(agent_run_service, "AgentConfigRepository", FakeConfigRepo) + monkeypatch.setattr(agent_run_service.agent_manager, "get_agent", lambda backend_id: object()) + monkeypatch.setattr(agent_run_service, "AgentRepository", AgentRepo) monkeypatch.setattr(agent_run_service, "ConversationRepository", ConvRepo) - monkeypatch.setattr(agent_run_service, "AgentRunRepository", Repo) + monkeypatch.setattr(agent_run_service, "AgentRunRepository", RunRepo) monkeypatch.setattr(agent_run_service, "get_arq_pool", fake_get_arq_pool) result = await agent_run_service.create_agent_run_view( query="hello", - agent_config_id=1, + agent_id="default", thread_id="thread-1", meta={"request_id": "req-1"}, image_content=None, @@ -176,55 +210,83 @@ async def test_create_agent_run_commits_before_enqueue(monkeypatch: pytest.Monke db=db, ) - assert db.order == ["commit", "enqueue"] - assert result["run_id"] == "run-1" + assert db.order[-2:] == ["commit", "enqueue"] + assert result["run_id"] == created_run.id assert result["request_id"] == "req-1" + assert db.added[0].run_id == created_run.id + assert db.added[0].request_id == "req-1" @pytest.mark.asyncio -async def test_create_agent_run_handles_integrity_error_with_same_user_existing(monkeypatch: pytest.MonkeyPatch): +async def test_create_resume_run_marks_input_message_source(monkeypatch: pytest.MonkeyPatch): + class FakeResult: + def scalar_one_or_none(self): + return SimpleNamespace(uid="user-1", role="user") + class FakeDB: def __init__(self): - self.commit_called = 0 - self.rollback_called = 0 + self.order: list[str] = [] + self.committed = False + self.added = [] + + async def execute(self, stmt): + del stmt + return FakeResult() + + def add(self, item): + self.added.append(item) + + async def flush(self): + self.order.append("flush") + for item in self.added: + if getattr(item, "id", None) is None: + item.id = 11 async def commit(self): - self.commit_called += 1 - raise IntegrityError("insert", {"request_id": "req-1"}, Exception("duplicate")) + self.order.append("commit") + self.committed = True async def rollback(self): - self.rollback_called += 1 + raise AssertionError("rollback should not be called") db = FakeDB() - existing_run = SimpleNamespace( - id="run-existing", + created_run = SimpleNamespace( + id="", thread_id="thread-1", - status="running", - request_id="req-1", + status="pending", + request_id="resume-req", uid="user-1", ) - state = {"lookup_count": 0} - class Repo: + class RunRepo: def __init__(self, db_session): self.db = db_session + async def get_run_for_user(self, run_id: str, uid: str): + assert run_id == "parent-run" + assert uid == "user-1" + return SimpleNamespace(id=run_id, thread_id="thread-1", status="interrupted") + + async def get_resume_run(self, parent_run_id: str, resume_request_id: str): + assert parent_run_id == "parent-run" + assert resume_request_id == "resume-req" + return None + async def get_run_by_request_id(self, request_id: str): - del request_id - state["lookup_count"] += 1 - if state["lookup_count"] == 1: - return None - return existing_run + assert request_id == "resume-req" + return None async def create_run(self, **kwargs): - del kwargs - return SimpleNamespace( - id="run-new", - thread_id="thread-1", - status="pending", - request_id="req-1", - uid="user-1", - ) + assert kwargs["run_type"] == "resume" + assert kwargs["parent_run_id"] == "parent-run" + assert kwargs["resume_request_id"] == "resume-req" + created_run.id = kwargs["run_id"] + return created_run + + async def set_input_message(self, run_id: str, message_id: int): + assert run_id == created_run.id + assert message_id == 11 + return created_run class ConvRepo: def __init__(self, db_session): @@ -232,106 +294,45 @@ async def test_create_agent_run_handles_integrity_error_with_same_user_existing( async def get_conversation_by_thread_id(self, thread_id: str): del thread_id - return SimpleNamespace( - uid="user-1", - status="active", - department_id=1, - extra_metadata={"agent_config_id": 1}, - ) + return SimpleNamespace(id=1, uid="user-1", status="active", agent_id="default") + + class AgentRepo: + def __init__(self, db_session): + self.db = db_session + + async def get_visible_by_slug(self, slug: str, user): + del user + return SimpleNamespace(slug=slug, backend_id="ChatbotAgent") + + class Queue: + async def enqueue_job(self, job_name: str, run_id: str, _job_id: str): + assert job_name == "process_agent_run" + assert run_id == created_run.id + assert _job_id == f"run:{created_run.id}" + assert db.committed is True async def fake_get_arq_pool(): - raise AssertionError("should not enqueue on integrity fallback") + return Queue() - monkeypatch.setattr(agent_run_service.agent_manager, "get_agent", lambda agent_id: object()) - monkeypatch.setattr(agent_run_service, "AgentConfigRepository", FakeConfigRepo) + monkeypatch.setattr(agent_run_service.agent_manager, "get_agent", lambda backend_id: object()) + monkeypatch.setattr(agent_run_service, "AgentRepository", AgentRepo) monkeypatch.setattr(agent_run_service, "ConversationRepository", ConvRepo) - monkeypatch.setattr(agent_run_service, "AgentRunRepository", Repo) + monkeypatch.setattr(agent_run_service, "AgentRunRepository", RunRepo) monkeypatch.setattr(agent_run_service, "get_arq_pool", fake_get_arq_pool) result = await agent_run_service.create_agent_run_view( - query="hello", - agent_config_id=1, + query=None, + agent_id="default", thread_id="thread-1", - meta={"request_id": "req-1"}, + meta={"request_id": "resume-req"}, image_content=None, current_uid="user-1", db=db, + resume={"language": "python"}, + parent_run_id="parent-run", + resume_request_id="resume-req", ) - assert db.commit_called == 1 - assert db.rollback_called == 1 - assert result["run_id"] == "run-existing" - assert result["status"] == "running" - - -@pytest.mark.asyncio -async def test_create_agent_run_integrity_error_returns_409_for_other_user(monkeypatch: pytest.MonkeyPatch): - class FakeDB: - async def commit(self): - raise IntegrityError("insert", {"request_id": "req-1"}, Exception("duplicate")) - - async def rollback(self): - return None - - db = FakeDB() - existing_run = SimpleNamespace( - id="run-existing", - thread_id="thread-1", - status="pending", - request_id="req-1", - uid="user-2", - ) - state = {"lookup_count": 0} - - class Repo: - def __init__(self, db_session): - self.db = db_session - - async def get_run_by_request_id(self, request_id: str): - del request_id - state["lookup_count"] += 1 - if state["lookup_count"] == 1: - return None - return existing_run - - async def create_run(self, **kwargs): - del kwargs - return SimpleNamespace( - id="run-new", - thread_id="thread-1", - status="pending", - request_id="req-1", - uid="user-1", - ) - - class ConvRepo: - def __init__(self, db_session): - self.db = db_session - - async def get_conversation_by_thread_id(self, thread_id: str): - del thread_id - return SimpleNamespace( - uid="user-1", - status="active", - department_id=1, - extra_metadata={"agent_config_id": 1}, - ) - - monkeypatch.setattr(agent_run_service.agent_manager, "get_agent", lambda agent_id: object()) - monkeypatch.setattr(agent_run_service, "AgentConfigRepository", FakeConfigRepo) - monkeypatch.setattr(agent_run_service, "ConversationRepository", ConvRepo) - monkeypatch.setattr(agent_run_service, "AgentRunRepository", Repo) - - with pytest.raises(agent_run_service.HTTPException) as exc: - await agent_run_service.create_agent_run_view( - query="hello", - agent_config_id=1, - thread_id="thread-1", - meta={"request_id": "req-1"}, - image_content=None, - current_uid="user-1", - db=db, - ) - - assert exc.value.status_code == 409 - assert exc.value.detail == "request_id 冲突" + assert result["run_id"] == created_run.id + assert db.added[0].message_type == "resume" + assert db.added[0].extra_metadata["source"] == "ask_user_question_resume" diff --git a/backend/test/unit/services/test_chat_stream_interrupt.py b/backend/test/unit/services/test_chat_stream_interrupt.py index bbff3df1..77ed9c3f 100644 --- a/backend/test/unit/services/test_chat_stream_interrupt.py +++ b/backend/test/unit/services/test_chat_stream_interrupt.py @@ -1,7 +1,11 @@ """测试 chat_service 中的 interrupt 相关函数""" +import json import sys import os +from types import SimpleNamespace + +import pytest sys.path.insert(0, os.getcwd()) @@ -9,6 +13,7 @@ from yuxi.services.chat_service import ( _normalize_interrupt_questions, _build_ask_user_question_payload, _coerce_interrupt_payload, + stream_agent_resume, ) from yuxi.utils.question_utils import normalize_options @@ -173,6 +178,24 @@ class TestNormalizeInterruptQuestions: assert result[0]["question"] == "有效问题" +@pytest.mark.asyncio +async def test_stream_agent_resume_init_does_not_render_resume_input(): + stream = stream_agent_resume( + thread_id="thread-1", + resume_input={"language": "python"}, + meta={"request_id": "req-1"}, + current_user=SimpleNamespace(uid="user-1"), + db=object(), + ) + + first_chunk = json.loads((await stream.__anext__()).decode("utf-8")) + await stream.aclose() + + assert first_chunk["status"] == "init" + assert "msg" not in first_chunk + assert "Resume with input" not in json.dumps(first_chunk, ensure_ascii=False) + + class TestCoerceInterruptPayload: """测试 _coerce_interrupt_payload 函数""" diff --git a/backend/test/unit/services/test_run_queue_service.py b/backend/test/unit/services/test_run_queue_service.py index f07642ad..ecccc417 100644 --- a/backend/test/unit/services/test_run_queue_service.py +++ b/backend/test/unit/services/test_run_queue_service.py @@ -103,7 +103,9 @@ async def test_run_stream_event_roundtrip(monkeypatch: pytest.MonkeyPatch): events = await run_queue_service.list_run_stream_events(run_id, after_seq="0-0", limit=100) assert [item["event_type"] for item in events] == ["loading", "finished"] - assert events[0]["payload"] == {"items": [1]} + assert events[0]["payload"]["schema_version"] == 1 + assert events[0]["payload"]["run_id"] == run_id + assert events[0]["payload"]["payload"] == {"items": [1]} next_events = await run_queue_service.list_run_stream_events(run_id, after_seq=seq1, limit=100) assert len(next_events) == 1 diff --git a/backend/test/unit/services/test_run_worker.py b/backend/test/unit/services/test_run_worker.py index 57beb783..c3e9000b 100644 --- a/backend/test/unit/services/test_run_worker.py +++ b/backend/test/unit/services/test_run_worker.py @@ -90,8 +90,8 @@ async def test_process_agent_run_non_retryable_error_marks_failed(monkeypatch: p terminal_statuses: list[str] = [] events: list[str] = [] - async def fake_append_event(run_id: str, event_type: str, payload: dict): - del run_id, payload + async def fake_append_event(run_id: str, event_type: str, payload: dict, **kwargs): + del run_id, payload, kwargs events.append(event_type) async def fake_mark_terminal(run_id: str, status: str, error_type=None, error_message=None): @@ -121,8 +121,8 @@ async def test_process_agent_run_retryable_error_retries_then_completes(monkeypa events: list[dict] = [] attempts = {"count": 0} - async def fake_append_event(run_id: str, event_type: str, payload: dict): - del run_id + async def fake_append_event(run_id: str, event_type: str, payload: dict, **kwargs): + del run_id, kwargs events.append({"event_type": event_type, "payload": payload}) async def fake_mark_terminal(run_id: str, status: str, error_type=None, error_message=None): @@ -169,10 +169,20 @@ async def test_worker_startup_ensures_builtin_mcp_servers(monkeypatch: pytest.Mo async def fake_ensure_builtin_mcp_servers_in_db(): calls.append("ensure_builtin_mcp_servers_in_db") + @asynccontextmanager + async def fake_session_ctx(): + yield object() + + async def fake_init_builtin_skills(session): + del session + calls.append("init_builtin_skills") + monkeypatch.setattr(run_worker.pg_manager, "initialize", fake_initialize) monkeypatch.setattr(run_worker.pg_manager, "create_business_tables", fake_create_business_tables) monkeypatch.setattr(run_worker.pg_manager, "ensure_business_schema", fake_ensure_business_schema) + monkeypatch.setattr(run_worker.pg_manager, "get_async_session_context", fake_session_ctx) monkeypatch.setattr(run_worker, "ensure_builtin_mcp_servers_in_db", fake_ensure_builtin_mcp_servers_in_db) + monkeypatch.setattr(run_worker, "init_builtin_skills", fake_init_builtin_skills) await run_worker._worker_startup({}) @@ -181,4 +191,5 @@ async def test_worker_startup_ensures_builtin_mcp_servers(monkeypatch: pytest.Mo "create_business_tables", "ensure_business_schema", "ensure_builtin_mcp_servers_in_db", + "init_builtin_skills", ] diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index 0fdf8a88..a1bf508f 100644 --- a/docs/develop-guides/roadmap.md +++ b/docs/develop-guides/roadmap.md @@ -66,6 +66,7 @@ - 收敛用户身份命名:原业务登录标识统一改为 `uid`,Agent/LangGraph runtime、conversation、agent_run、sandbox 路径和前端用户态均使用字符串 `uid`;`user_id` 仅保留给外部响应中的数值 `users.id` 或真实外键场景。 - 工作区知识库分类显示:知识库侧边栏按创建者分组为“我的知识库”和“共享知识库”,自己创建的知识库显示在“我的知识库”下,非自己创建的显示在“共享知识库”下;`knowledge_bases` 表新增 `created_by` 字段记录创建者 uid。 - 聊天附件新增 MinIO tmp 临时上传、可选 PDF/图片解析、确认后加入线程附件的流程;前端改为弹窗内上传、解析与确认。 +- 标准化 Agent run/SSE 执行链路:run 创建时持久化输入消息并提交后入队,worker 统一写入 Redis Stream envelope,SSE 输出 `event/data/id`、心跳注释、`Last-Event-ID` 回放和终止 `end` 事件;前端强制使用 run API 并支持 ask_user_question 中断后以 resume run 恢复。 --- diff --git a/notion_preresearch.md b/notion_preresearch.md deleted file mode 100644 index e1d2965e..00000000 --- a/notion_preresearch.md +++ /dev/null @@ -1,159 +0,0 @@ -# Notion Zotero Knowledge Base Reader - -This folder contains a small Python CLI for reading the shared Notion `Zotero` -knowledge base through the public Notion API. - -## Verified configuration - -- Connection token: provide through `NOTION_TOKEN`; do not hard-code it. -- Required capability: read content. -- Required sharing: the parent database must be shared with the connection. -- Notion API version used by the script: `2026-03-11`. -- Data source found: `Zotero` -- Data source id: `1a958b0f-ad1f-4f78-a082-cbdc66a3cd23` - -## Usage - -```bash -export NOTION_TOKEN="ntn_..." -``` - -Search shared pages/data sources by title: - -```bash -./notion_kb_reader.py search zotero --type data_source --limit 5 -./notion_kb_reader.py search "chain of thought" --type page --limit 10 -``` - -Query the Zotero data source and locally filter page properties: - -```bash -./notion_kb_reader.py query \ - --data-source-id 1a958b0f-ad1f-4f78-a082-cbdc66a3cd23 \ - "reasoning" \ - --limit 10 -``` - -Read a page into Markdown-like text: - -```bash -./notion_kb_reader.py read 12fee1d7-d69e-813b-81c8-f150b3d324af --output sample_page.md -``` - -Find text inside one page after fetching properties and block content: - -```bash -./notion_kb_reader.py find 12fee1d7-d69e-813b-81c8-f150b3d324af reasoning -``` - -Search or query, select the first matched page, then read it: - -```bash -./notion_kb_reader.py demo \ - --data-source-id 1a958b0f-ad1f-4f78-a082-cbdc66a3cd23 \ - "reasoning" \ - --output first_match.md -``` - -```python -#!/usr/bin/env python3 -"""Search and read a Notion knowledge base via the public API. - -Set NOTION_TOKEN before running: - export NOTION_TOKEN="ntn_..." -""" - -from __future__ import annotations - -import argparse -import json -import os -import sys -import textwrap -import time -import urllib.error -import urllib.parse -import urllib.request -from typing import Any - - -API_BASE = "https://api.notion.com/v1" -NOTION_VERSION = os.environ.get("NOTION_VERSION", "2026-03-11") - - -class NotionAPIError(RuntimeError): - pass - - -class NotionClient: - def __init__(self, token: str) -> None: - self.token = token - - def request( - self, - method: str, - path: str, - body: dict[str, Any] | None = None, - query: dict[str, Any] | None = None, - retries: int = 4, - ) -> dict[str, Any]: - url = f"{API_BASE}{path}" - if query: - url += "?" + urllib.parse.urlencode( - {k: v for k, v in query.items() if v is not None} - ) - data = json.dumps(body).encode("utf-8") if body is not None else None - headers = { - "Authorization": f"Bearer {self.token}", - "Notion-Version": NOTION_VERSION, - "Content-Type": "application/json", - "User-Agent": "notion-kb-reader/1.0", - } - - for attempt in range(retries + 1): - req = urllib.request.Request(url, data=data, headers=headers, method=method) - try: - with urllib.request.urlopen(req, timeout=45) as resp: - return json.loads(resp.read().decode("utf-8")) - except urllib.error.HTTPError as exc: - detail = exc.read().decode("utf-8", errors="replace") - retry_after = exc.headers.get("Retry-After") - if exc.code in {429, 500, 502, 503, 504} and attempt < retries: - sleep_s = float(retry_after or min(2**attempt, 8)) - time.sleep(sleep_s) - continue - raise NotionAPIError(f"HTTP {exc.code} {exc.reason}: {detail}") from exc - except urllib.error.URLError as exc: - if attempt < retries: - time.sleep(min(2**attempt, 8)) - continue - raise NotionAPIError(str(exc)) from exc - - raise NotionAPIError("request failed after retries") - - -def require_token() -> str: - token = os.environ.get("NOTION_TOKEN") or os.environ.get("NOTION_API_KEY") - if not token: - raise SystemExit( - "Missing token. Run: export NOTION_TOKEN='ntn_...'\n" - "Tip: do not hard-code the token in this script." - ) - return token - - -def paginate( - client: NotionClient, - method: str, - path: str, - body: dict[str, Any] | None = None, - query: dict[str, Any] | None = None, - limit: int | None = None, -) -> list[dict[str, Any]]: - results: list[dict[str, Any]] = [] - cursor: str | None = None - - while True: - request_body = dict(body or {}) - request_query = dict(query or {}) -``` \ No newline at end of file diff --git a/web/src/apis/agent_api.js b/web/src/apis/agent_api.js index f42bcfdc..1b732ec7 100644 --- a/web/src/apis/agent_api.js +++ b/web/src/apis/agent_api.js @@ -1,10 +1,4 @@ -import { - apiGet, - apiPost, - apiDelete, - apiPut, - apiRequest -} from './base' +import { apiGet, apiPost, apiDelete, apiPut, apiRequest } from './base' import { useUserStore } from '@/stores/user' /** @@ -18,30 +12,6 @@ import { useUserStore } from '@/stores/user' // ============================================================================= export const agentApi = { - /** - * 发送聊天消息到指定智能体(流式响应) - * @param {Object} data - 聊天数据 - * @returns {Promise} - 聊天响应流 - */ - sendAgentMessage: (data, options = {}) => { - const { signal, headers: extraHeaders, ...restOptions } = options || {} - const baseHeaders = { - 'Content-Type': 'application/json', - ...useUserStore().getAuthHeaders() - } - - return fetch('/api/agent/chat', { - method: 'POST', - body: JSON.stringify(data), - signal, - headers: { - ...baseHeaders, - ...(extraHeaders || {}) - }, - ...restOptions - }) - }, - /** * 简单聊天调用(非流式) * @param {string} query - 查询内容 @@ -111,39 +81,12 @@ export const agentApi = { */ getMessageFeedback: (messageId) => apiGet(`/api/chat/message/${messageId}/feedback`), - createAgent: (payload) => apiPost('/api/agent', payload), updateAgent: (agentId, payload) => apiPut(`/api/agent/${agentId}`, payload), deleteAgent: (agentId) => apiDelete(`/api/agent/${agentId}`), - /** - * 恢复被人工审批中断的对话(流式响应) - * @param {string} threadId - 会话 ID - * @param {Object} data - 恢复数据 { answer: { question_id: answer }, approved } - * @param {Object} options - 可选参数(signal, headers等) - * @returns {Promise} - 恢复响应流 - */ - resumeAgentChat: (threadId, data, options = {}) => { - const { signal, headers: extraHeaders, ...restOptions } = options || {} - const baseHeaders = { - 'Content-Type': 'application/json', - ...useUserStore().getAuthHeaders() - } - - return fetch(`/api/chat/thread/${threadId}/resume`, { - method: 'POST', - body: JSON.stringify(data), - signal, - headers: { - ...baseHeaders, - ...(extraHeaders || {}) - }, - ...restOptions - }) - }, - /** * 创建异步运行任务(Run) * @param {Object} data - run 请求体 @@ -155,7 +98,10 @@ export const agentApi = { agent_id: data.agent_id, thread_id: data.thread_id, meta: data.meta || {}, - image_content: data.image_content || null + image_content: data.image_content || null, + resume: data.resume ?? null, + parent_run_id: data.parent_run_id || null, + resume_request_id: data.resume_request_id || null }), /** @@ -188,16 +134,18 @@ export const agentApi = { */ streamAgentRunEvents: (runId, afterSeq = '0-0', options = {}) => { const { signal } = options - return fetch( - `/api/agent/runs/${runId}/events?after_seq=${encodeURIComponent(String(afterSeq))}`, - { - method: 'GET', - headers: { - ...useUserStore().getAuthHeaders() - }, - signal - } - ) + const headers = { + ...useUserStore().getAuthHeaders() + } + const cursor = String(afterSeq || '0-0') + if (cursor && cursor !== '0-0') { + headers['Last-Event-ID'] = cursor + } + return fetch(`/api/agent/runs/${runId}/events`, { + method: 'GET', + headers, + signal + }) } } diff --git a/web/src/components/AgentChatComponent.vue b/web/src/components/AgentChatComponent.vue index 9ff9516f..d832b318 100644 --- a/web/src/components/AgentChatComponent.vue +++ b/web/src/components/AgentChatComponent.vue @@ -234,8 +234,6 @@ const { threads, currentThreadId, currentThread } = storeToRefs(chatThreadsStore const userInput = ref('') const sendCooldownActive = ref(false) let sendCooldownTimer = null -const useRunsApi = import.meta.env.VITE_USE_RUNS_API === 'true' - // 预设的打招呼文本 const greetingMessages = [ '👋 您好,有什么可以帮您?', @@ -1085,13 +1083,12 @@ const handleAttachmentRemove = async (attachment) => { } // ==================== 审批功能管理 ==================== -const { approvalState, handleApproval, processApprovalInStream } = useApproval({ +const { approvalState, processApprovalInStream } = useApproval({ getThreadState, - resetOnGoingConv, fetchThreadMessages }) -const { handleAgentResponse, handleStreamChunk } = useAgentStreamHandler({ +const { handleStreamChunk } = useAgentStreamHandler({ getThreadState, processApprovalInStream, currentAgentId, @@ -1100,10 +1097,8 @@ const { handleAgentResponse, handleStreamChunk } = useAgentStreamHandler({ }) const { startRunStream, resumeActiveRunForThread, stopRunStreamSubscription } = useAgentRunStream({ getThreadState, - useRunsApi, currentAgentId, handleStreamChunk, - processApprovalInStream, fetchThreadMessages, fetchAgentState, resetOnGoingConv, @@ -1111,45 +1106,6 @@ const { startRunStream, resumeActiveRunForThread, stopRunStreamSubscription } = streamSmoother }) -// 发送消息并处理流式响应 -const sendMessage = async ({ - agentId, - threadId, - text, - signal = undefined, - imageData = undefined, - requestId = '', - attachmentFileIds = [] -}) => { - if (!agentId || !threadId || !text) { - const error = new Error('Missing agent, thread, or message text') - handleChatError(error, 'send') - return Promise.reject(error) - } - - const requestData = { - query: text, - agent_id: agentId, - thread_id: threadId, - meta: { - request_id: requestId, - attachment_file_ids: attachmentFileIds - } - } - - // 如果有图片,添加到请求中 - if (imageData && imageData.imageContent) { - requestData.image_content = imageData.imageContent - } - - try { - return await agentApi.sendAgentMessage(requestData, signal ? { signal } : undefined) - } catch (error) { - handleChatError(error, 'send') - throw error - } -} - // ==================== CHAT ACTIONS ==================== // 获取第一个非置顶的对话 const getFirstNonPinnedChat = (chatList) => { @@ -1289,72 +1245,6 @@ const handleSendMessage = async ({ image } = {}) => { .map((attachment) => attachment.file_id) .filter(Boolean) - if (useRunsApi) { - if ((threadMessages.value[threadId] || []).length === 0) { - const autoTitle = text.replace(/\s+/g, ' ').trim().slice(0, 2000) - if (autoTitle) { - void (async () => { - try { - const generatedTitle = await agentApi.generateTitle( - autoTitle, - configStore.config?.fast_model - ) - if (generatedTitle) { - const finalTitle = generatedTitle.slice(0, 30).replace(/\s+/g, ' ').trim() - if (finalTitle) { - void chatThreadsStore.updateThread(threadId, finalTitle).catch(() => {}) - } - } - } catch (e) { - console.error('Title generation failed:', e) - // 失败时使用原始文本作为标题 - void chatThreadsStore.updateThread(threadId, autoTitle.slice(0, 30)).catch(() => {}) - } - })() - } - } - - resetOnGoingConv(threadId) - const requestId = createClientRequestId() - const previousAttachments = markAttachmentsRequestId(threadId, pendingAttachments, requestId) - insertOptimisticHumanMessage(threadState, { - requestId, - text, - imageContent, - attachments: pendingAttachments.map((attachment) => ({ - ...attachment, - request_id: requestId - })) - }) - threadState.isStreaming = true - try { - const runResp = await agentApi.createAgentRun({ - query: text, - agent_id: currentAgentId.value, - thread_id: threadId, - meta: { - request_id: requestId, - attachment_file_ids: pendingAttachmentFileIds - }, - image_content: imageContent - }) - const runId = runResp?.run_id - if (!runId) { - throw new Error('创建 run 失败:缺少 run_id') - } - await startRunStream(threadId, runId, 0) - } catch (error) { - threadState.isStreaming = false - threadState.replyLoadingVisible = false - threadState.pendingRequestId = null - rollbackAttachments(threadId, previousAttachments) - resetOnGoingConv(threadId) - handleChatError(error, 'send') - } - return - } - - // 如果是新对话,用 fast-model 异步生成标题(不阻塞消息发送) if ((threadMessages.value[threadId] || []).length === 0) { const autoTitle = text.replace(/\s+/g, ' ').trim().slice(0, 2000) if (autoTitle) { @@ -1372,14 +1262,12 @@ const handleSendMessage = async ({ image } = {}) => { } } catch (e) { console.error('Title generation failed:', e) - // 失败时使用原始文本作为标题 void chatThreadsStore.updateThread(threadId, autoTitle.slice(0, 30)).catch(() => {}) } })() } } - threadState.isStreaming = true resetOnGoingConv(threadId) const requestId = createClientRequestId() const previousAttachments = markAttachmentsRequestId(threadId, pendingAttachments, requestId) @@ -1387,40 +1275,36 @@ const handleSendMessage = async ({ image } = {}) => { requestId, text, imageContent, - attachments: pendingAttachments.map((attachment) => ({ ...attachment, request_id: requestId })) + attachments: pendingAttachments.map((attachment) => ({ + ...attachment, + request_id: requestId + })) }) - threadState.streamAbortController = new AbortController() + threadState.isStreaming = true try { - const response = await sendMessage({ - agentId: currentAgentId.value, - threadId: threadId, - text: text, - signal: threadState.streamAbortController?.signal, - imageData: image, - requestId, - attachmentFileIds: pendingAttachmentFileIds + const runResp = await agentApi.createAgentRun({ + query: text, + agent_id: currentAgentId.value, + thread_id: threadId, + meta: { + request_id: requestId, + attachment_file_ids: pendingAttachmentFileIds + }, + image_content: imageContent }) - - await handleAgentResponse(response, threadId) - } catch (error) { - if (error.name !== 'AbortError') { - console.error('Stream error:', error) - rollbackAttachments(threadId, previousAttachments) - handleChatError(error, 'send') - } else { - console.warn('[Interrupted] Catch') + const runId = runResp?.run_id + if (!runId) { + throw new Error('创建 run 失败:缺少 run_id') } + await startRunStream(threadId, runId, 0) + } catch (error) { threadState.isStreaming = false - } finally { - threadState.streamAbortController = null - // 异步加载历史记录,保持当前消息显示直到历史记录加载完成 - fetchThreadMessages({ agentId: currentAgentId.value, threadId: threadId }).finally(() => { - // 历史记录加载完成后,安全地清空当前进行中的对话 - resetOnGoingConv(threadId) - handleAgentStateRefresh(threadId) - scrollController.scrollToBottom() - }) + threadState.replyLoadingVisible = false + threadState.pendingRequestId = null + rollbackAttachments(threadId, previousAttachments) + resetOnGoingConv(threadId) + handleChatError(error, 'send') } } @@ -1432,32 +1316,14 @@ const handleSendOrStop = async (payload) => { const threadId = currentChatId.value const threadState = getThreadState(threadId) - if (isProcessing.value && threadState) { - if (useRunsApi && threadState.activeRunId) { - try { - await agentApi.cancelAgentRun(threadState.activeRunId) - message.info('已发送取消请求') - } catch (error) { - handleChatError(error, 'stop') - } - return - } - - if (threadState.streamAbortController) { - // 中断生成 - threadState.streamAbortController.abort() - - // 中断后刷新消息历史,确保显示最新的状态 - try { - await fetchThreadMessages({ agentId: currentAgentId.value, threadId: threadId, delay: 500 }) - fetchAgentState(currentAgentId.value, threadId) - message.info('已中断对话生成') - } catch (error) { - console.error('刷新消息历史失败:', error) - message.info('已中断对话生成') - } - return + if (isProcessing.value && threadState?.activeRunId) { + try { + await agentApi.cancelAgentRun(threadState.activeRunId) + message.info('已发送取消请求') + } catch (error) { + handleChatError(error, 'stop') } + return } if (props.sendDisabled) return await handleSendMessage(payload) @@ -1479,30 +1345,35 @@ const handleApprovalWithStream = async (answer) => { return } + if (!approvalState.parentRunId) { + message.error('无法找到需要恢复的运行任务') + approvalState.showModal = false + return + } + try { - // 使用审批 composable 处理审批 - const response = await handleApproval(answer) - - if (!response) return // 如果 handleApproval 抛出错误,这里不会执行 - - // 处理流式响应 - await handleAgentResponse(response, threadId) - } catch (error) { - if (error.name !== 'AbortError') { - console.error('Resume approval error:', error) - } - } finally { - if (threadState) { - threadState.isStreaming = false - threadState.streamAbortController = null - } - - // 异步加载历史记录,保持当前消息显示直到历史记录加载完成 - fetchThreadMessages({ agentId: currentAgentId.value, threadId: threadId }).finally(() => { - resetOnGoingConv(threadId) - fetchAgentState(currentAgentId.value, threadId) - scrollController.scrollToBottom() + approvalState.showModal = false + threadState.isStreaming = true + resetOnGoingConv(threadId) + const resumeRequestId = createClientRequestId() + const runResp = await agentApi.createAgentRun({ + query: null, + agent_id: currentAgentId.value, + thread_id: threadId, + meta: { request_id: resumeRequestId }, + resume: answer, + parent_run_id: approvalState.parentRunId, + resume_request_id: resumeRequestId }) + const runId = runResp?.run_id + if (!runId) { + throw new Error('创建 resume run 失败:缺少 run_id') + } + await startRunStream(threadId, runId, '0-0') + } catch (error) { + threadState.isStreaming = false + threadState.replyLoadingVisible = false + handleChatError(error, 'resume') } } diff --git a/web/src/components/AgentPanel.vue b/web/src/components/AgentPanel.vue index e4180914..c883c0fa 100644 --- a/web/src/components/AgentPanel.vue +++ b/web/src/components/AgentPanel.vue @@ -792,14 +792,14 @@ watch( align-items: center; border: 1px solid var(--gray-150); border-radius: 8px; - background: var(--gray-0); + background: var(--gray-25); color: var(--gray-700); overflow: hidden; flex-shrink: 0; &.active { - border-color: var(--main-200); - background: var(--main-20); + border-color: var(--main-600); + background: var(--gray-0); color: var(--main-800); } } diff --git a/web/src/components/ToolCallsGroupComponent.vue b/web/src/components/ToolCallsGroupComponent.vue index c0fc3cd0..e177e5a2 100644 --- a/web/src/components/ToolCallsGroupComponent.vue +++ b/web/src/components/ToolCallsGroupComponent.vue @@ -150,7 +150,7 @@ const toggleToolCallsExpanded = () => {