pm8-$`#yUD4{izP>Q@RQO^*xlRKT%xevyC#(gMsKkM#Nek2c z;AWP)wD`5TeO2_H+d)u>iU{rI&byZpm_c%CTlk%Q6D&6uX9#?5kF49{z?s}KAWmju zZ^XR3TGNZ5+pedj*x7otl5uJ+7x?#e eciY)*YuL_n7%`GCMLroWdEV(`7--b$ L@F^}FR6I@xEyryH?J+?b8H^@*r>s$?7bYh_l~wE zIV}B^&0u8Fw*j$zeld~KQT>SKm`MlYHpWLnrvx7g6CsE%_OZ2Wz~cLu>gJ1@^v|*T z3+LozIh0b~eA+-tE}C~Hz??mKyb9?JfLY;tvMTP+;$oGObHW%>fw&+IRSEoUAVfq& z?CIyv6M>%x#^Qr8I_t^6G;br`)CAb4^ZgY&@L80eUO!T2=jG_{9QG)s$hUFxv@!va zd7k3pR|)KS%pDNHUsI8V^Kh*Byd^8lWHDRK`{XQ}#`^W(b-7e0r)ZMx;Mt|>MSnPp zYk$Mn$eNl8*eszXic~A2rp9LNZ2$-}ONEq81D9+jaQyl){Rwmbo6Nk5gR33CH)-$& zRd{*FRjEJs+A7kUnK`YD4Vwk`m>a!|I1*A`ckMz=i}*dPsd By{Mt&RD@(?h> z&lkt^8vm2ZT9yvGq#t6lCRoubQOF6EaS zLmevSQJ~su?fPxbhl3M zU~+8T)l+j~{kCcfVm`s|#ksUmh-6%VU!!6{7$-UXbfeZf$}e`Pa+NB34(do!7XrMF zY4K-dQt)cnhy-N2St-3$9wmJ88%Yv%nD_cv%dn3W9%YfcS9@zt(#?2)QIe!uadkDJ z92a%#HGZZ vk%LU}-vVtTSh$~BIwz#7-|AD@D zsJ*-ll=#)Uc;sOI|ER9t1i(H;9k?IiIP^=CSE(Lrt$9t0;EfD!J8X{Z7B<`3eRvuD z_dBndb4m((+5lA~`r52OC#PJuIpRxQdGOQ^0Mkl>H|qkOv*rM~s!uFP{(1QQZ}*aa zoRI(brvDAW|E_}nO@#l&g1-gB|AoW<5)N~LfN2e_b7+&PrWquUWoo#Jk|;LR>>rY= z8Tt258S1T{|9FnIx c6(y!F<}|fe*8oZ4@8>ily{`}_xEqosmQY$-p{-BKU}hWM*c1<(#IPe z!Xbq8K`2jAKze-9L>l^@hH!R{i=6uk;R__>JNPYRP4K2dD+|bMdwFs# JfsNFAsRhQ-O&k>I!8r@wYG$^X zb5-7bzdn&eP_dFtOr3mv+5~_7HYN}sw`Xqv*0EU}YcT=`_F301rug(Lk{h`#Z42D} zA*_Ib4OW(P6&D2UM#dhDEUEzsRi=KP4Y*a}XEx2{lA`BhSHh4pwr2WbQ+&r5?19L& z(C2g&oD(e9?mQIk0xy>tSzzWk5o+99TbZej-y^l=DpxPHj<2oXTiY7zoot)B7z+@! z#$EP=uGaxgWZW4y9i4XeXFAFQs&jF5V3(miS+GpbeoBka6Iow*H^??mAHk9`aONbR zM|RH<=^TiqYmiJyUR OfE2ez(rsNUX2NM$pQH1o)k?-hNJ zSG&r7FT)UhJ2F |aXcQ?CWEkoBgMquUWOhIl^^e=NP|<@ z@tU~J)3tW1SzniZd*XZK6(CNZ;Wf2rkQWcR4qIRFaA thidw$&gu30k)e^T95S $ rp8U1 zmjI|mKZCJmDpD(1=JzJ$yhuC4;$-`mOlume(M5&UjZq@7#>OjaZoXEL_4#@|%GG8z z6l$PxW2?L|xjR-GB$cOY`|wZt^(=0TeoGRRmDZL3os|TYew{jAMt Q0oC_S7- zQz*6y?aHcQ8i&r=QZUKYgX%-$2M8DMj@GM!p{$(3bfK%h+z{ygU8a-ZnAaV7#H~mA zZVy?jixV!b;FRLL>C<2h#4_7}_&j3+9CFd%P`di#-7&GzxtBdfz#fl~jNf9}U4k1S zsdrCoyAHfG26_mnSHOr^J8F+>1kPU_=B4Pl_p&$Kvm{g zkOSsOyYly^JIDqzH${}4;pU}Y9(ox?81(zYsRngnRsoC?A;*F`AX2k|lM@2#GbUB) zgzfDgUQ@phiGB^nnDc24YBv{Fz~7Xb&Gf;i+7WS!=CBg16J@tiEQh1E_WVerE?9JX zOCCP-V`DFkK_WxEEq};}B+bcL6EIE!mL2ll8K)Rqf}c!GPM^mJ$uh7XFa)?R@3t8c zx1MZIINfFZMEAngcVF%L<<`Vb``G+a9dscBrB&rLl`dE;MpE1!{1oBeNn$ #lb{6pyiur0$68TQmav1hN7sPIW-`Y2 R`KBXc88(SdJ0`Oqbmy~LOl bZSp z^wf1##Y}svehvI(&E*kTw6OMjQL$L6C }}PLM pYt{KSOi;D~3BzUo?jxl_oZ(xXKEow2?sVb$2-LVuC!gT4eV|?nmpiYlR zUG3jJyQn&`jIe7xwtuN=_oV~%CSNw=Y(^C#jrv&}rUhBNS*C~)`Qr9g2$%9ySQz&y zpu_5N@6S>}oxS!H63N2%GUV*=c ?7%C2{R462j&e(`iAG>*|_ z>j)Jfy}I0OT`uqR?w0{mxEQ}fUv$wZy0%tNAQp2P7+O_2>w)aV!9Mjj;<~EZugf^g zf899ew_Jw{N(%_M?Zh28)TQii?oUEe6_YbtJ&uhN)C 4k-9O{X4T IKxfp}J9%Md5Q{nnWs3&zG) zy3Fz$`%>@GJVs72vHo6mLlPe7 {Kw=@iT0a4zGKqtHr~g zI(n51F^^L>Y#*$%BCk5Fi@n%7tTw6I+}@m>HtoRmRD@gxcw?sTngzGKt_(@@eSjP@ z#8mL}@D*&@g7CNE?re@DPp&OTsnn^~6%ZYh{l&V2c=JXV7isV9>7yZWJG6h>7; -;{C2BKoYHtti7#k⋘hrY&}zgz-z9F*S(wTY3dnEG zbg~M*Y+yu zL_w4om! z%JL~HsZE!%C7d!uJGiLN+^qZl0xIt-0>WUtQ=EAD1`klc($hPbUmQ^qf%=z)7DuhK zBVV=RRhiYFb^9%^Bqi?4pPjqWY=D9_G>b+%J3H0V({Fu-Ju=2faE3o*%*k6yt&}Dq z^p-ao0JXFRS}I@ihuh{>1Iy`Q4_<~yT6a*WJ$`+72vHLOZA#A9JJHZF`#{*E48BZC zYzj(qEsDxM?)~u*E&{fwx2^Ou!QC|-5mzBsUkNkDIOsf6J7U_~{WRwrWMXKjUGNiG zlNlPSh+U|wt=qKsyzcwJ2=ejE{v`xjjcyKzylM?ho0Lg9koFA$m1~4oS+WMp93te& zEiLdPXT|A7(DJDnT~osM_G}O`Dqfkj6uX7|T~c3cs(07ulHLgH8k^kwfgZ$w-|m+( zW4ppDyy%?p#*hqr?IBK6GBeOic()>M?%*HGAh_H86RwP`J#_aX97GwBrt+R2BYuSv zLkeQ=fIe_FiVKR-iTfs(mcF@cYZkVKs7?hPVb@VrR!*zz*XH)JUM864%529LqTxOt zLZBDea&n-RsoTIPH6jYO%#ydPOU&u&u 8p@H5J=OuR`RnA1Ni?k= |~-hyei8%|oeJJOISG_+%MI<`VbH)&=t(Nj@%B&6h3T7rFk zkY8qLwgMl3W&505+&ZtabPz7XgK{Vi_S@fycjGid%%yw}tOzr`lB_&VJfjRSPd!$i zt;va>hEHEzq6;TTq<*QE? ct~E 6C6d91S=*dysfw+)qn(j8za z7sgLujNqPeAt3^$#^!$Z-Hcx5U< =2a8i-J(qo(YMFQ_C0_Vsxi!YvjPxeFEs z3ne#kO6Rv6x;OeLXVKjGA!DHMngnF(-ZeXCfzWq3<#JX^pW*N0?dEjX(luIT!l2rC zxgZ2s<*$*r(Z-jT)d^|2d&$vh!In+$(yNwti`qG1T1^S|DY6AHu%h(a&CDV`ommha zIv *9h%pVh<*F@alsI9*tg-u%40Qa2&Ops-9@d& z>;5WQi9eFF6Drcb9in3 EmvDZ&hIb$%<3gD$#~R67XXp9f8Qebd?JZ8*ANuDw zu>_?NQ5dX#QLD8`h6FiOaoZ=P&;dG9cade39gL>bGD-72)xU-<6wG`!!oI{`GrX!4 zo)&t;F{3AXYNQTkrGR)hdb9p*bWXXEo964^E#YNwodq^Ssvm`()n*$Z3*?3S8L2K7 zgo#Y_$ L6zA(Kj^&qmBw-7rK^=3<7;|(S6F?$DDus< zTdp01eF}(u8Ab}2eOwH<39$R`gohE`YP-oEw^ibc%xfdp&xt`mg~-ZI!CMc-V0crL zX<0g|Wdw=5w&Gp4CZDqRV zj*9;<&-}A=YimEHC# TwvYU)b+57n{T!L!&^Sj< msp`Oocah@j$A-*bsLkIhSOXg;_wh1g z_|tVC&!Y;sg>Jd>Bdbcb=MQ*|8NP`W!pWhqS2YF;5g~Z79iA5sk<@DO60WE(2)4IZ z{Q2>G+3mfbE1qlwxxS7s`ElLQk`U64ZpzM^%IaMA=Yie<#@vy0tUF`88b3hAYBw%v zcxsYGD1TUzck=*LQYJ%tgIclgzQ_SP4E0@wgM1_0>Z4xHpB`B4)W3~0SqqhmsC|uT z=&;!qKsh&O^(Gy#E;R$efqw}D7iDmvgZ=_PRN?RK#yNLZU8qcO6!|e$o|KkNUwQAB zE&5Y*@6Y>7=zaLHu{{6`4$lQzdkx6c_;RM#cz;@cGBGQict&i!-_Ejxjxg-pp}9Cw z=Cld`jrZu#2$A_)fCIcGZiUb}D_M)+1z7@7U_XJHLvxw1U5hjd1#&I&uORQSRG#Ii zN3Gm(3d_FS^6?h94HiN7^frm<%9G@^(vFJzvAi3!>fP+dmn;W2?>NRi=3ttB5?t`D zw;+(Vg#?lL=bgp5i`2xmQ^RGnb5rBo=HwS+=l3CVV&`U&FNsxJq98i=`Y|6*&xj^3 zt!3F)m&A~aug^Bu+!UD~vbsMolNrtluGs3G3=VkO69{+fn<3^TBTlP6EX>plUU=-w z>-)nQQFi>RG|l!`_S_?Z?u?E{NoTs3>(8(ohXZ!DX#$2-GHsscOTUbgvg!DBbvs@9 zzR?cY-z(1!PMjtt8O`3P2%gcqbPMv@Xcebl?2tJ+Tzr<+TyC96@_sZlo-Ln=mpPN3 z(Q?1TJ0%3$`t@d8<2OT9rj&qubR>!1k_lfAykk0vLn~l=N!0uSXO(fX>4;zFa*d@U z#j~`=Pu&;%zJa0E$Mv6y2G;$zd93~H2NwV>Cd-H=4Stq#;x(V4LWr?ymWHKV54>lN z7Z_7w{)hi2OnsoNtkKt57lNO`m-4@K;`JNvZ)*{I#bO!k3ylVPT?bVz)7M$_Okq zjE3X-c6Od=YpNT{=*G4xWr5}lWo%86VicgJ*M;ot6dG?mTM7bICJ$Sr%?df7#`0h5 zl3w6+-!`Ka8a05N^sE{o&wO`HXn~e{pKHg*)nZe6I)xo7%D>wcM8IP{XQtUE1vkp% zCTy&iRzz=I6A)fq9ld#gLNY^I78jQh7@z?vZpw^?)~Xgbxe4&<==4?ihkfa1J!EJ# zeJ7(`T~`hd4W8IZ)x5XL4%DV@3b>O3l=h{GGBD__U$j%Yb$bt)ZwB=BteqAvakI@_ zwO)APahVqs`f+uGw5J+%l>`0wtI9oaCn6=~qv|M|$7%QZ3DEE9>gdZ2tQb!Zq1|?j zYYJ;NZn|>nSu2tKO?^I#iJe1}vDdehf^&psVUFQahE~E;NE?+A{C^; zvtc%dV7AlI=h1|q;N8uLXs=deZ{y?x>jvAwnp?U#HXbHj$@CXBKi!7mO tm5C;l#PEJ? TBxqkpwK$+3_Qvqid*QG;DZU;pJ|?(r@I#<9IAy z_8@`DPr4N?84$)(9@9m&VPeIzB68dw5EgjYtlT;+js5zJqi|7I+i%whh%U5_AJ`h7 z$T{5G!)b`WrN{ZWq2}~F#RWy>1heCo2d)(TUz!JxR`y$7YU*rE&&d&`X8lVGnCk~> zEUpw5zU BUs{@_Y6enyLkNK48pltGV4MSxGM@|&hZ=wzr59L;re z3JY~9V>n?;s1C| mSE($`*6=3s6?M4lzu0ZcX1*C%LyP~4?_Hd+YfOUNR%{zTwsUZ>c(XPvR9 z#p8LwqPS1r)g>pv!R*ML7O@xhSn1^i#@q9;inEmRr_iJnl3J79S|MIdL94<85sw;~ zIc<`&)943R61*8|2rwKDJ1TpK^7|`fw?{z97I*voBXAYbmW%>?atyU5BkS2M_^jXd zP=;8|Vh(S#DQX+gldO5aZ9{ F+;Iv4^C> j@|Mr!U!mX8hYJ z46z?9Z~TP^YYZ6cy`7!fS2l}ET`zK)TV=0)K6HFwc)!mGRp=V(a@T=^>U2_5Q?t}< z@tam2cxsLi Gwt=DTG*XHhS8GXt$RctC>4(*L2Hoqy+FXQE+i zU|IU}ta)(*oi0kU$4x!Am1C2ZMr^PKo$rsqDK08FfO~i&4f!K=@10i7qPB!+@71pD zPIk9* XhzBt+6F>feoCs3 zN1(&Iw`=`WROF&zL8k5D219b(#9+CO{kuwtuRT?z2KURQC-8Q+p>Ruyi*<{%5`=Ii z=N*T;JCc?@SX^=a)ReWeF7bhi$4wv0KPCsgrl)IPrOnFT%X7@zFuO2 8OLPuUGlWUT%%u|aF4}*g$>@g|k`1r__&S`9EH~$tV znm#CUmnXqKaczp{(N?=~!c-lP2~!LDtHXUM4kH&+!s`dhy<2Nt+kicl0XmD6!e!o? z nsr|P$HcXomNAo+m-qKSO2ow6cMOfh zPh@|DA!pQ#bfqUE>}O8X->HIYleBxu8gpYyE-4yeus`ni&}g) IiT)M=4HCC=aeI?(#OT)N{zGV*ZM%Q;cEYvTe^{oU+?y6czf zIf!Zh%2t}zug}z%(KKWIiG^@S$(1r-H1{=8f{9EU1=DY040gn_hXh|3&ug2p9&!R) zZeGiB%d6AV{pnA5TLPsrc$j?;v*mj;4FuPUgV5Sjw2;K?17jzV(j2Y2ZP5>VJdq2a z=UzX8x*0N~W~9k2yaUg-cC*t3?H@5ht8#WNDwDCpY-LRcJ`=E#L>}|5w^fmrU>x~_ z0~sFwEkgGqT8SGE0?gjy=W;H)2#++n43L^Ak^|0+>S;Hpr|bDW7~~i%n)w th@Y%PEA8WR!XvtNUi$dCj^4`RI6lJg$$yE=RzA}zs+q`1Fw z|6U&D@=WE?ESI5| Zs8t>Jc0>KTdI~KTtlfpy5h=1n)Yetpk77 z$x1cgNO%stzlQd|dEJ}?boxiU$pq{Eq@@`SZKdO0)ScW;&9OxtGH5X;7c*IPaBY7% zxSwRFJ eemf*yO9?4C287i5tt)T+;3)B$Hqg(ve&~V z$p}$2a|lOEiiv-*GKg_cnLZ0YGjtnTQ+6|@PvL{~$3Fn3Oyk3w#_oR@hGps6hJmVL z&m?%G%vq83e>5UFi5%EwF?+A-9m@OMKUl{UfcS@QpSoaLazOTfoF`#fLN(vU{eDs? z?7ZrICo%FA2ZM;F;x0JI*(YWjPx3yz8vHR+m_oL6DA;q$g&h3<$%Wz&y~#B^#8>6# zLPYfOM1v8T6c}WD5H~Dv8$Z^u!7#|)3&mJM^!IRe$4bMK3p?Hsr{Y}KjWFz@L12-! zR`O@%%sugeEp~{#1Z7LjBPx@RAuOWfAb*Z^55MIo$l}wc{K+JAy`MOQt8RVJEJUwH z%H<%LhS+{6eR)r8rzyWQ<-1M)fS28^7N1O?zr=noi`_!x(*ut@jd__Mqk`^}%6_Nu zRSzqTt+ LgI z^qgOp9nOqzKTwWhyJ4Th!G_g1_#7B)wyeOkcNlWYODib!ET}c8qY%4azQxL7=gP9w zJAa?Yy7;NM32Jcg>kGXM+CYEQ%qN-dJ>ppDY4SUFPHGU>XC_P4AJFRNiTRV^&=eZy z#XCp!EXtmoK`#FJ{1E6|;5_=SZq!VRf2CQZQ(Yu*jgqK{#87ADIL45H&vm2K!xe2` z4sOU@8~0$^%nWF+m&NnVZh5&!A*2rG7NhD}DFnsK8z#D7EA-F+X5nPN7k*h+5r(q% zb4W3MmZI^cKWFJ_Ls>)7ZqJ@n=`&2Wfkq00RtTX67dd*Lh%|PG$$Hg)z{KaJf4k-L z52;g$dq!vKR 8*UaO$+bF<80;fu8-6IY21y$F+q|4JYD|uFn)}W_h0Z zBL~>*12PL4q(8K>W%& cU>9KAE2BPnd>_3Zy@<-VetYP&pu2SHI06cA7$bOb>_ zQABz#kq#21E4>P#g_3}D>Cy#4Kzff9kqCsQ(rf5lq<07e2yNo~%{%4$*36oVxjXBu z(=MKiXYaHBzf>)MUG}q<{IY^42WUdxpicTHH#yT=HO=1(-e!+wm;u4vFy6RZzjnsi zFd6nqd=K`5`c+7g0WZ}yOs}V^b_w`z2g-a!e53$n1lj6VBjat9P$%q$-8rC_ybm}v zl5knzIOFked-^9ILP%PCbc*(>zU1A!jV)RNs}+cOLq4ZJjz;f)bpDF$?oXL5|2CD6 z5emxJlHb1*Gv;NXa6m^z$6>50&?Z<^GhsKd=o^U?__h@{WGfjxh19YzcFesO)`5&m zxNw cv8%y&F{JL`O+d5u@$AUuUCJJnDl?~6N7<;0#WDjlShK4CXbsd7e>|T za9L9Ds>epLOO20EGgC#1|0tCYl5mwj|0 13GdDzmPp{ENm|-yyZgpH zQ@5XCaNts;$atNq-CnB6`Z*)LpRUesRDi6a7j#=75wzD8J^`XhzyhZiYy_<~R&3Ap zU5ocpM-WGemgl(kmLFy2X2-#Kb|BD~uLCGA+@76=s;+J#HrT)`mSx?VTiUag3w7s+ zk86)tby3-Xw?ma+)z%T;+)ScgQoM76FJISV!#r;HQN#S@X}-$P(dhy9gH?*ad|BVx zqJ42uZ>%)O{q_<7cQ)3 Z(H@5xw0W#{E&A##TX8-2`Bu$ca%tKKDLm8=I; zq($EIy2qFWyW!Sk{3~H^-ij9oDW}@qBTfUW1bTc%&w<;Ms}(319#x3)OuFs4!_pt$ zTQO*U>|Q)SmF7kd8G3SlrHnY@;3#pTK_61>q44m%SbuU%{k7z~2OSzh4M@?d1-D=^ z&2q`$i^6Nle#zmQ;USkZ6JP4hGh0~~Pso~~db>vT_lD#dgb)Q~UIx^GPd#&rkr1Xj zSZGhh7qhL#jk8c_q#;ORLU3{4o-dXyFfFTjm`pfLI}fBk@48VpnXnX}c_;Tcg? exZFlV?eQJwJ%8|p%Ixu2BXu@eTdGXV?33cYdCAmQt# uY*F{P zrSu>sv8U~egac!zA+HVx<5gWlUhJs;`c%Q5Pzuh_3nWr4wJ7?2h2-6{Kke+%e@P}I z`F&zEAm^o&Z(R)$JJa(9ZkI@v`v`Md3FUdjW^e(J! @V&eH^U*;5E=Q!u?5m-q z>N`rcfHC6mLZ9a*Y1sj1c?6UoW24ccA`Ojq!GA)_D;tqlC3e+c%#2B~A9}5jlUH2> z)iFu$k1bvX#`f)+>dkE-d_DVS-?G5I*;#L8KcJQ%PdH+{&tg0?;Tslvf$S9&OINu8 z2Rk9a9QSVY+&&}`s`mKI8^cG8d2bH6jq7cPNCS=LWoV@FGpF+vsCtz_pXJzvLkS?- zr*6KNp{?M?^N*ETht0JQe9QS=S=+eaw5Gsam)|PO(QUKa>Jf)ANbh?0POBhA@>Y4r zBre`wcrIe8$TbfDxK{he4wP-{@2*+14hdh^pH4@&h5v~UuG3`_LeXgUW74*nh^|q$ z*gh7t)Z} 3>{b>Uh`1$t4rY6z0p32{S)(kB5Xj^N@?o7yA8+Ea7 9PC>KYqO+JAs1z=7G$}0J5g-C)a%<+-S=Y z>O7u1cu18nn_AWgX%l^r;Z5L!?M)Gk2yy>NHT&z*|7U6-%{@h~qLx+K%-HzhaJNCc zhr9c~Gle$pqj~__`@;jcBZ!8!b_E)3SD>M^&&iz@_P?cYt%+H+N}^l9$jF$NC%NEq zUF(pfA4aTrY{er-pif1i#s4jX{2x?dbrTujDKg>;!=} 3|fa@yK{7haHAqQB!-rrAc_ nMk@7 zEAOB|8lH4r;GmFUBtMN!5~P&RK{WceCyoj17X31jU$~Oyshu;x_Lh?k*V6s ~ehhkWx^58~M5B| m9fR{^e7+k-bPoFF6k5Dn9L{=;<>AK^ z!e)^#mYC~@JO~@h@F|$-UEovBrq&Kah*|XC+`yV3E9XVOL@X*t*`N6o2NKFRq;RnA z`BA+Vwkc_I@r~M8Qf|v}KOHUjmLMc@3BaHD@C8>On5AW8U^K2#3-_hevL%b3$c$9` zrIl=SE{*$ MA|)_Kko?*Ae~| v$%WOtqw(G$T I- a65@53fWUh`H8Tz_L|u;P-Fy-KicdD?3(!7`v>);cNFvHI zjI&qf%(5<)Io$cz8({`34XX@qdhu-;HmO_~h>Me>d|1{14x#Ao)qZ{O<6c{(G(Cb< zJzs)6vxpqwVA}V^-x#A;HdQn>SlFxxuvJC98VkpjI}_H=EfRV;j)swQhq&z}@%H#? z5*Yk;RzvE98LMizwAxmJW#4Fq5A~k7$@rUzeogv`w60D|1sh_9eAA_;vp0*O#?L9e z;@8ohiGA8C)c|RK@AE^~tPa-%DKQwIb0;6Y#VV;>;7ms4_zKESZXZE%?vvgC%uA4 yEYK%3|Pd`-TFeMY5l8rK8 zi_9n!{g|6~>8jm7w5g_)(+bP(1FAXLMm#I)GogA$ReZQ@?_LeI1 72Xd}Ju`_iyd$CyM&pAW=If&CUfug*45Bt115@EKxYv zQ`*%q_%wU6vmw(;bA=*q$zgErAJT*^gLK&P_Vk4E4MT_!6M9~!5!sipL{vHe7aG{i z^ttwJLT+KvS28CO9~S}**68Wk<81P|*^aSxo{f;NOx`A)ZmLL?uJ`v&;GLQMq=T)4 zLD-b6KVPKGr-T~1nsQt2MB5OYoRY!-S#Lic8cUoa&s$t8A{p?rBCiq%+LYuzWOTB( zFGh{lzqKY>9n3jxO-)&<3~T~Y!JgTl3kz#&YfDSjOt{4*q5J#$Hz+BMv}Qk9dF`+^ z )ogzdV$EZx0#C6PD+>!n>C3q{Wzw~g5F$G)P=low;F>T}szZpo zf6fL)ncBP?k)aL6Z(CYgV$U ~I%E{rTd;qZj;`cyQ&<9*z{SETET6jb5@Mo&kUq LH5AI_UI+dgFQYxF literal 0 HcmV?d00001 diff --git a/scripts/package-image-source.mjs b/scripts/package-image-source.mjs new file mode 100644 index 000000000..e79260542 --- /dev/null +++ b/scripts/package-image-source.mjs @@ -0,0 +1,46 @@ +import archiver from 'archiver' +import { createWriteStream } from 'node:fs' +import { mkdir, readdir } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = fileURLToPath(new URL('../', import.meta.url)) +const outputDirectory = path.join(root, 'build') +await mkdir(outputDirectory, { recursive: true }) +const outputPath = path.join(outputDirectory, 'chatgptbox-image-support-source.zip') +const output = createWriteStream(outputPath) +const archive = archiver('zip', { zlib: { level: 9 } }) +const completed = new Promise((resolve, reject) => { + output.on('close', resolve) + output.on('error', reject) + archive.on('error', reject) + archive.on('warning', reject) +}) +archive.pipe(output) + +// Include hidden build/configuration files, but never local dependencies, +// browser test profiles, Git metadata, or generated artifacts. +const excluded = new Set([ + '.git', + 'node_modules', + 'build', + 'test-results', + '.cache', + 'coverage', + '.coverage', +]) +async function addDirectory(directory, relative = '') { + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (excluded.has(entry.name)) continue + const relativePath = path.posix.join(relative, entry.name) + const absolutePath = path.join(directory, entry.name) + if (entry.isDirectory()) await addDirectory(absolutePath, relativePath) + else if (entry.isFile()) { + archive.file(absolutePath, { name: `chatgptbox-image-support/${relativePath}` }) + } + } +} +await addDirectory(root) +await archive.finalize() +await completed +console.log(`Created ${outputPath} (${archive.pointer()} bytes)`) diff --git a/src/_locales/en/main.json b/src/_locales/en/main.json index 445148a56..e3e6bde5a 100644 --- a/src/_locales/en/main.json +++ b/src/_locales/en/main.json @@ -26,6 +26,22 @@ "Wechat Pay": "Wechat Pay", "Type your question here\nEnter to send, shift + enter to break line": "Type your question here\nEnter to send\nShift + enter to break line", "Type your question here\nEnter to stop generating\nShift + enter to break line": "Type your question here\nEnter to stop generating\nShift + enter to break line", + "Images require an OpenAI-compatible vision API.": "Images require an OpenAI-compatible vision API.", + "Choose a model that supports image input.": "Choose a model that supports image input.", + "Attach images": "Attach images", + "Drop images here or paste a screenshot": "Drop images here or paste a screenshot", + "Attached images": "Attached images", + "Image": "Image", + "Reading image": "Reading image", + "Remove image": "Remove image", + "Images must be PNG, JPEG, WEBP, or GIF.": "Images must be PNG, JPEG, WEBP, or GIF.", + "Each image must be 4 MiB or smaller.": "Each image must be 4 MiB or smaller.", + "You can attach up to 4 images.": "You can attach up to 4 images.", + "Total image size must be 12 MiB or smaller.": "Total image size must be 12 MiB or smaller.", + "This file is not a supported image.": "This file is not a supported image.", + "Unable to read image.": "Unable to read image.", + "Unable to send images.": "Unable to send images.", + "Describe these images": "Describe these images", "Ask ChatGPT": "Ask ChatGPT", "No Input Found": "No Input Found", "You": "You", diff --git a/src/_locales/zh-hans/main.json b/src/_locales/zh-hans/main.json index cfad6d2d1..5d89b21fe 100644 --- a/src/_locales/zh-hans/main.json +++ b/src/_locales/zh-hans/main.json @@ -26,6 +26,22 @@ "Wechat Pay": "微信打赏", "Type your question here\nEnter to send, shift + enter to break line": "在此输入你的问题\n回车 发送\nshift+回车 换行", "Type your question here\nEnter to stop generating\nShift + enter to break line": "在此输入你的问题\n回车 停止生成\nshift+回车 换行", + "Images require an OpenAI-compatible vision API.": "图片需要兼容 OpenAI 的视觉 API。", + "Choose a model that supports image input.": "请选择支持图像输入的模型。", + "Attach images": "附加图片", + "Drop images here or paste a screenshot": "将图片拖到这里,或粘贴屏幕截图", + "Attached images": "已附加图片", + "Image": "图片", + "Reading image": "正在读取图片", + "Remove image": "移除图片", + "Images must be PNG, JPEG, WEBP, or GIF.": "图片必须是 PNG、JPEG、WEBP 或 GIF 格式。", + "Each image must be 4 MiB or smaller.": "每张图片必须不超过 4 MiB。", + "You can attach up to 4 images.": "最多可附加 4 张图片。", + "Total image size must be 12 MiB or smaller.": "图片总大小必须不超过 12 MiB。", + "This file is not a supported image.": "此文件不是受支持的图片。", + "Unable to read image.": "无法读取图片。", + "Unable to send images.": "无法发送图片。", + "Describe these images": "描述这些图片", "Ask ChatGPT": "询问ChatGPT", "No Input Found": "无输入", "You": "你", diff --git a/src/_locales/zh-hant/main.json b/src/_locales/zh-hant/main.json index 3dd20a229..2e668a37f 100644 --- a/src/_locales/zh-hant/main.json +++ b/src/_locales/zh-hant/main.json @@ -26,6 +26,22 @@ "Wechat Pay": "微信支付贊助", "Type your question here\nEnter to send, shift + enter to break line": "在此輸入你的問題\n按 Enter 傳送,Shift + Enter 換行", "Type your question here\nEnter to stop generating\nShift + enter to break line": "在此輸入你的問題\n按 Enter 停止產生,Shift + Enter 換行", + "Images require an OpenAI-compatible vision API.": "圖片需要相容 OpenAI 的視覺 API。", + "Choose a model that supports image input.": "請選擇支援圖片輸入的模型。", + "Attach images": "附加圖片", + "Drop images here or paste a screenshot": "將圖片拖曳到這裡,或貼上螢幕截圖", + "Attached images": "已附加圖片", + "Image": "圖片", + "Reading image": "正在讀取圖片", + "Remove image": "移除圖片", + "Images must be PNG, JPEG, WEBP, or GIF.": "圖片必須是 PNG、JPEG、WEBP 或 GIF 格式。", + "Each image must be 4 MiB or smaller.": "每張圖片必須不超過 4 MiB。", + "You can attach up to 4 images.": "最多可附加 4 張圖片。", + "Total image size must be 12 MiB or smaller.": "圖片總大小必須不超過 12 MiB。", + "This file is not a supported image.": "此檔案不是支援的圖片。", + "Unable to read image.": "無法讀取圖片。", + "Unable to send images.": "無法傳送圖片。", + "Describe these images": "描述這些圖片", "Ask ChatGPT": "詢問 ChatGPT", "No Input Found": "找不到輸入內容", "You": "你", diff --git a/src/background/index.mjs b/src/background/index.mjs index 79b4f0c0d..6dd4cf181 100644 --- a/src/background/index.mjs +++ b/src/background/index.mjs @@ -945,77 +945,6 @@ try { }, ['requestHeaders', ...(Browser.runtime.getManifest().manifest_version < 3 ? ['blocking'] : [])], ) - - Browser.tabs.onUpdated.addListener(async (tabId, info, tab) => { - const outerTryCatchError = (error) => { - console.error( - '[background] Error in tabs.onUpdated listener callback (outer):', - error, - tabId, - info, - ) - } - try { - if (!tab.url) { - console.debug( - `[background] Skipping side panel update for tabId: ${tabId}. Tab URL: ${tab.url}, Info Status: ${info.status}`, - ) - return - } - console.debug( - `[background] tabs.onUpdated event for tabId: ${tabId}, status: ${info.status}, url: ${tab.url}. Proceeding with side panel update.`, - ) - - let sidePanelSet = false - try { - if (Browser.sidePanel && typeof Browser.sidePanel.setOptions === 'function') { - await Browser.sidePanel.setOptions({ - tabId, - path: 'IndependentPanel.html', - enabled: true, - }) - console.debug( - `[background] Side panel options set for tab ${tabId} using Browser.sidePanel`, - ) - sidePanelSet = true - } - } catch (browserError) { - console.warn('[background] Browser.sidePanel.setOptions failed:', browserError.message) - } - - if (!sidePanelSet) { - console.debug('[background] Attempting chrome.sidePanel.setOptions as fallback.') - const chromeApi = globalThis.chrome - if (chromeApi?.sidePanel && typeof chromeApi.sidePanel.setOptions === 'function') { - try { - await chromeApi.sidePanel.setOptions({ - tabId, - path: 'IndependentPanel.html', - enabled: true, - }) - console.debug( - `[background] Side panel options set for tab ${tabId} using chrome.sidePanel`, - ) - sidePanelSet = true - } catch (chromeError) { - console.error( - '[background] chrome.sidePanel.setOptions also failed:', - chromeError.message, - ) - } - } - } - - if (!sidePanelSet) { - console.warn( - '[background] SidePanel API (Browser.sidePanel or chrome.sidePanel) not available or setOptions failed in this browser. Side panel options not set for tab:', - tabId, - ) - } - } catch (error) { - outerTryCatchError(error) - } - }) } catch (error) { console.error('[background] Error setting up webRequest or tabs listeners:', error) } diff --git a/src/background/menus.mjs b/src/background/menus.mjs index d3a61ee1e..6abaa1d9d 100644 --- a/src/background/menus.mjs +++ b/src/background/menus.mjs @@ -108,45 +108,49 @@ const onClickMenu = (info, tab) => { }) } export function refreshMenu() { - if (Browser.contextMenus.onClicked.hasListener(onClickMenu)) - Browser.contextMenus.onClicked.removeListener(onClickMenu) - Browser.contextMenus.removeAll().then(async () => { - if ((await getUserConfig()).hideContextMenu) return - - await getPreferredLanguageKey().then((lang) => { - changeLanguage(lang) - }) - Browser.contextMenus.create({ - id: menuId, - title: 'ChatGPTBox', - contexts: ['all'], - }) + // A service worker may be starting because this very menu was clicked. + // Subscribe before storage/localization/menu creation can yield, otherwise + // the waking click can be delivered before its listener exists. + if (!Browser.contextMenus.onClicked.hasListener(onClickMenu)) + Browser.contextMenus.onClicked.addListener(onClickMenu) + return Browser.contextMenus + .removeAll() + .then(async () => { + if ((await getUserConfig()).hideContextMenu) return - for (const [k, v] of Object.entries(menuConfig)) { + await getPreferredLanguageKey().then((lang) => { + changeLanguage(lang) + }) Browser.contextMenus.create({ - id: menuId + k, - parentId: menuId, - title: t(v.label), + id: menuId, + title: 'ChatGPTBox', contexts: ['all'], }) - } - Browser.contextMenus.create({ - id: menuId + 'separator1', - parentId: menuId, - contexts: ['selection'], - type: 'separator', - }) - for (const index in defaultConfig.selectionTools) { - const key = defaultConfig.selectionTools[index] - const desc = defaultConfig.selectionToolsDesc[index] + + for (const [k, v] of Object.entries(menuConfig)) { + Browser.contextMenus.create({ + id: menuId + k, + parentId: menuId, + title: t(v.label), + contexts: ['all'], + }) + } Browser.contextMenus.create({ - id: menuId + key, + id: menuId + 'separator1', parentId: menuId, - title: t(desc), contexts: ['selection'], + type: 'separator', }) - } - - Browser.contextMenus.onClicked.addListener(onClickMenu) - }) + for (const index in defaultConfig.selectionTools) { + const key = defaultConfig.selectionTools[index] + const desc = defaultConfig.selectionToolsDesc[index] + Browser.contextMenus.create({ + id: menuId + key, + parentId: menuId, + title: t(desc), + contexts: ['selection'], + }) + } + }) + .catch((error) => console.error('failed to refresh context menus', error)) } diff --git a/src/background/redact.mjs b/src/background/redact.mjs index 3a0d027ce..06a411258 100644 --- a/src/background/redact.mjs +++ b/src/background/redact.mjs @@ -7,6 +7,7 @@ const SENSITIVE_KEYWORDS = [ 'credential', 'jwt', 'session', + 'images', ] export function isPromptOrSelectionLikeKey(lowerKey) { diff --git a/src/components/ConversationCard/index.jsx b/src/components/ConversationCard/index.jsx index 6835faa6a..e988c0fa6 100644 --- a/src/components/ConversationCard/index.jsx +++ b/src/components/ConversationCard/index.jsx @@ -41,6 +41,7 @@ import { getConversationAiName, } from '../../popup/sections/api-modes-provider-utils.mjs' import { getDisplayErrorText } from '../../utils/error-text.mjs' +import { canSendImages, validateSessionImages } from '../../services/apis/images.mjs' import { createConversationPortMessage, createRetrySession, @@ -60,11 +61,12 @@ class ConversationItemData extends Object { * @param {string} content * @param {bool} done */ - constructor(type, content, done = false) { + constructor(type, content, done = false, images = []) { super() this.type = type this.content = content this.done = done + this.images = images } } @@ -84,6 +86,7 @@ function ConversationCard(props) { const [completeDraggable, setCompleteDraggable] = useState(false) const useForegroundFetch = isUsingBingWebModel(session) const [apiModes, setApiModes] = useState([]) + const [inputResetKey, setInputResetKey] = useState(0) /** * @type {[ConversationItemData[], (conversationItemData: ConversationItemData[]) => void]} @@ -120,7 +123,7 @@ function ConversationCard(props) { } else { const ret = [] for (const record of session.conversationRecords) { - ret.push(new ConversationItemData('question', record.question, true)) + ret.push(new ConversationItemData('question', record.question, true, record.images)) ret.push(new ConversationItemData('answer', record.answer, true)) } setConversationItemData(ret) @@ -151,7 +154,7 @@ function ConversationCard(props) { useEffect(async () => { // when the page is responsive, session may accumulate redundant data and needs to be cleared after remounting and before making a new request if (props.question && triggered) { - const newSession = initSession({ ...session, question: props.question }) + const newSession = initSession({ ...session, question: props.question, images: [] }) partialAnswerRef.current = '' retryRecordRef.current = null setSession(newSession) @@ -386,6 +389,19 @@ function ConversationCard(props) { }, [port, conversationItemData]) const getRetryFn = (session) => async () => { + try { + validateSessionImages(session) + if ( + (session.images?.length || + session.conversationRecords.some((record) => record.images?.length)) && + !canSendImages(config, session) + ) { + throw new Error(t('Images require an OpenAI-compatible vision API.')) + } + } catch (error) { + updateAnswer(error.message, false, 'error') + return + } updateAnswer(` ${t('Waiting for response...')}
`, false, 'answer') setIsReady(false) @@ -581,9 +597,11 @@ function ConversationCard(props) { }, }) setConversationItemData([]) + setInputResetKey((key) => key + 1) const newSession = initSession({ ...session, question: null, + images: [], conversationRecords: [], }) newSession.sessionId = session.sessionId @@ -661,6 +679,7 @@ function ConversationCard(props) { {conversationItemData.map((data, idx) => () : ( { - const newQuestion = new ConversationItemData('question', question) + onSubmit={async (question, images = []) => { + const newSession = { ...session, question, images, isRetry: false } + validateSessionImages(newSession) + if ( + (images.length || + session.conversationRecords.some((record) => record.images?.length)) && + !canSendImages(config, newSession) + ) { + throw new Error(t('Images require an OpenAI-compatible vision API.')) + } + const newQuestion = new ConversationItemData('question', question, false, images) const newAnswer = new ConversationItemData( 'answer', ` ${t('Waiting for response...')}
`, @@ -703,7 +733,6 @@ function ConversationCard(props) { setConversationItemData([...conversationItemData, newQuestion, newAnswer]) setIsReady(false) - const newSession = { ...session, question, isRetry: false } setSession(newSession) try { await postMessage({ session: newSession }) diff --git a/src/components/ConversationCard/session.mjs b/src/components/ConversationCard/session.mjs index 40c60f166..4e2cfee9a 100644 --- a/src/components/ConversationCard/session.mjs +++ b/src/components/ConversationCard/session.mjs @@ -56,6 +56,7 @@ export function createRetrySession(session, conversationRecords, retryRecord) { return { ...session, conversationRecords, + ...(retryRecord ? { images: retryRecord.images || [] } : {}), isRetry: retryRecord === null, } } diff --git a/src/components/ConversationItem/index.jsx b/src/components/ConversationItem/index.jsx index d9e9e9f35..e590abc22 100644 --- a/src/components/ConversationItem/index.jsx +++ b/src/components/ConversationItem/index.jsx @@ -16,7 +16,7 @@ AnswerTitle.propTypes = { descName: PropTypes.string, } -export function ConversationItem({ type, content, descName, onRetry }) { +export function ConversationItem({ type, content, descName, onRetry, images = [] }) { const { t } = useTranslation() const [collapsed, setCollapsed] = useState(false) @@ -49,6 +49,21 @@ export function ConversationItem({ type, content, descName, onRetry }) { {!collapsed &&{content} } + {!collapsed && + (Array.isArray(images) ? images : []) + .filter( + (image) => + typeof image === 'string' && + /^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/]+=*$/.test(image), + ) + .map((image, index) => ( ++ ))} ) case 'answer': @@ -130,6 +145,7 @@ ConversationItem.propTypes = { content: PropTypes.string.isRequired, descName: PropTypes.string, onRetry: PropTypes.func, + images: PropTypes.arrayOf(PropTypes.string), } export default memo(ConversationItem) diff --git a/src/components/InputBox/images.mjs b/src/components/InputBox/images.mjs new file mode 100644 index 000000000..364eb3322 --- /dev/null +++ b/src/components/InputBox/images.mjs @@ -0,0 +1,118 @@ +export const IMAGE_MIME_TYPES = Object.freeze([ + 'image/png', + 'image/jpeg', + 'image/webp', + 'image/gif', +]) + +export const IMAGE_ACCEPT = IMAGE_MIME_TYPES.join(',') +export const MAX_IMAGE_COUNT = 4 +export const MAX_IMAGE_SIZE = 4 * 1024 * 1024 +export const MAX_TOTAL_IMAGE_SIZE = 12 * 1024 * 1024 + +export const IMAGE_FILE_ERROR = Object.freeze({ + INVALID: 'invalid', + TYPE: 'type', + FILE_SIZE: 'file-size', + COUNT: 'count', + TOTAL_SIZE: 'total-size', +}) + +function getFileSize(file) { + return typeof file?.size === 'number' && Number.isFinite(file.size) && file.size >= 0 + ? file.size + : null +} + +export function validateImageFile(file) { + if (!file || typeof file !== 'object') { + return { valid: false, reason: IMAGE_FILE_ERROR.INVALID } + } + + const type = typeof file.type === 'string' ? file.type.toLowerCase() : '' + const size = getFileSize(file) + if (size === null) return { valid: false, reason: IMAGE_FILE_ERROR.INVALID } + if (size === 0) return { valid: false, reason: IMAGE_FILE_ERROR.INVALID } + if (!IMAGE_MIME_TYPES.includes(type)) return { valid: false, reason: IMAGE_FILE_ERROR.TYPE } + if (size > MAX_IMAGE_SIZE) return { valid: false, reason: IMAGE_FILE_ERROR.FILE_SIZE } + + return { valid: true, type, size } +} + +function getExistingImageSize(files) { + return Array.from(files ?? []).reduce((total, file) => total + (getFileSize(file) ?? 0), 0) +} + +/** + * Select valid image files while enforcing the per-file, count, and total byte limits. + * The returned rejected entries retain their original file and a stable reason code so + * callers can present a localized message without exposing browser-specific errors. + */ +export function validateImageFiles(files, existingFiles = []) { + const accepted = [] + const rejected = [] + let totalSize = getExistingImageSize(existingFiles) + const existingCount = Array.from(existingFiles ?? []).length + + for (const file of Array.from(files ?? [])) { + if (existingCount + accepted.length >= MAX_IMAGE_COUNT) { + rejected.push({ file, reason: IMAGE_FILE_ERROR.COUNT }) + continue + } + + const validation = validateImageFile(file) + if (!validation.valid) { + rejected.push({ file, reason: validation.reason }) + continue + } + + if (totalSize + validation.size > MAX_TOTAL_IMAGE_SIZE) { + rejected.push({ file, reason: IMAGE_FILE_ERROR.TOTAL_SIZE }) + continue + } + + accepted.push(file) + totalSize += validation.size + } + + return { accepted, rejected, totalSize } +} + +function bytesToBase64(bytes) { + let binary = '' + const chunkSize = 0x8000 + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)) + } + + if (typeof btoa === 'function') return btoa(binary) + if (typeof globalThis.Buffer !== 'undefined') + return globalThis.Buffer.from(bytes).toString('base64') + throw new Error('No base64 encoder is available') +} + +/** Read a validated image file as a data URL for transport and previews. */ +export function readImageAsDataUrl(file) { + if (typeof FileReader !== 'undefined') { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + if (typeof reader.result === 'string') resolve(reader.result) + else reject(new Error('Image reader returned an invalid result')) + } + reader.onerror = () => reject(reader.error || new Error('Unable to read image')) + reader.onabort = () => reject(new Error('Image read was aborted')) + reader.readAsDataURL(file) + }) + } + + if (typeof file?.arrayBuffer === 'function') { + return file.arrayBuffer().then((buffer) => { + const type = + typeof file.type === 'string' && file.type ? file.type : 'application/octet-stream' + return `data:${type};base64,${bytesToBase64(new Uint8Array(buffer))}` + }) + } + + return Promise.reject(new Error('Unable to read image')) +} diff --git a/src/components/InputBox/index.jsx b/src/components/InputBox/index.jsx index 97eb1b90a..14bf459a5 100644 --- a/src/components/InputBox/index.jsx +++ b/src/components/InputBox/index.jsx @@ -12,11 +12,36 @@ import { MIN_INPUT_HEIGHT, } from './resize.mjs' import { shouldHandleInputAction } from './input-action.mjs' +import { + IMAGE_ACCEPT, + IMAGE_FILE_ERROR, + readImageAsDataUrl, + validateImageFiles, +} from './images.mjs' -export function InputBox({ onSubmit, enabled, postMessage, reverseResizeDir }) { +export function InputBox({ + onSubmit, + enabled, + postMessage, + reverseResizeDir, + imagesAllowed = false, + resetKey, +}) { const { t } = useTranslation() const [value, setValue] = useState('') + const [imageEntries, setImageEntries] = useState([]) + const [imageError, setImageError] = useState('') + const [isReadingImages, setIsReadingImages] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) const inputRef = useRef(null) + const fileInputRef = useRef(null) + const imageEntriesRef = useRef([]) + const readGenerationRef = useRef(0) + const pendingReadsRef = useRef(0) + const imageIdRef = useRef(0) + const mountedRef = useRef(true) + const draftRevisionRef = useRef(0) + const submittingRef = useRef(false) const resizedRef = useRef(false) const resizeHandleRef = useRef(null) const resizeStartRef = useRef(null) @@ -24,10 +49,213 @@ export function InputBox({ onSubmit, enabled, postMessage, reverseResizeDir }) { const [inputHeight, setInputHeight] = useState(DEFAULT_INPUT_HEIGHT) const [maxInputHeight, setMaxInputHeight] = useState(DEFAULT_INPUT_HEIGHT) + const replaceImageEntries = (nextEntries) => { + imageEntriesRef.current = nextEntries + setImageEntries(nextEntries) + } + + const bumpDraftRevision = () => { + draftRevisionRef.current += 1 + } + + const clearFileInput = () => { + if (fileInputRef.current) fileInputRef.current.value = '' + } + + const invalidateImageReads = () => { + readGenerationRef.current += 1 + pendingReadsRef.current = 0 + if (mountedRef.current) setIsReadingImages(false) + } + + const clearImages = () => { + invalidateImageReads() + replaceImageEntries([]) + bumpDraftRevision() + clearFileInput() + } + + const clearDraft = () => { + clearImages() + setValue('') + setImageError('') + submittingRef.current = false + if (mountedRef.current) setIsSubmitting(false) + } + + const getImageErrorMessage = (reason) => { + switch (reason) { + case IMAGE_FILE_ERROR.TYPE: + return t('Images must be PNG, JPEG, WEBP, or GIF.') + case IMAGE_FILE_ERROR.FILE_SIZE: + return t('Each image must be 4 MiB or smaller.') + case IMAGE_FILE_ERROR.COUNT: + return t('You can attach up to 4 images.') + case IMAGE_FILE_ERROR.TOTAL_SIZE: + return t('Total image size must be 12 MiB or smaller.') + default: + return t('This file is not a supported image.') + } + } + + const isCurrentRead = (generation) => + mountedRef.current && readGenerationRef.current === generation + + const addImageFiles = (files) => { + if (!files || Array.from(files).length === 0) return + + if (!imagesAllowed) { + setImageError(t('Images require an OpenAI-compatible vision API.')) + return + } + + const currentEntries = imageEntriesRef.current + const result = validateImageFiles( + files, + currentEntries.map((entry) => entry.file), + ) + if (result.rejected.length > 0) { + setImageError(getImageErrorMessage(result.rejected[0].reason)) + } else { + setImageError('') + } + if (result.accepted.length === 0) return + + const generation = readGenerationRef.current + const entries = result.accepted.map((file) => ({ + id: ++imageIdRef.current, + file, + dataUrl: null, + })) + replaceImageEntries([...currentEntries, ...entries]) + bumpDraftRevision() + pendingReadsRef.current += entries.length + setIsReadingImages(true) + + for (const entry of entries) { + Promise.resolve() + .then(() => readImageAsDataUrl(entry.file)) + .then((dataUrl) => { + if (!isCurrentRead(generation)) return + replaceImageEntries( + imageEntriesRef.current.map((currentEntry) => + currentEntry.id === entry.id ? { ...currentEntry, dataUrl } : currentEntry, + ), + ) + }) + .catch(() => { + if (!isCurrentRead(generation)) return + replaceImageEntries( + imageEntriesRef.current.filter((currentEntry) => currentEntry.id !== entry.id), + ) + bumpDraftRevision() + setImageError(t('Unable to read image.')) + }) + .finally(() => { + if (!isCurrentRead(generation)) return + pendingReadsRef.current = Math.max(0, pendingReadsRef.current - 1) + if (pendingReadsRef.current === 0) setIsReadingImages(false) + }) + } + } + + const removeImage = (id) => { + if (!imageEntriesRef.current.some((entry) => entry.id === id)) return + replaceImageEntries(imageEntriesRef.current.filter((entry) => entry.id !== id)) + bumpDraftRevision() + setImageError('') + } + + const handleImageInputChange = (e) => { + addImageFiles(e.currentTarget.files) + e.currentTarget.value = '' + } + + const handlePaste = (e) => { + const items = Array.from(e.clipboardData?.items ?? []) + const files = items + .filter((item) => item.kind === 'file') + .map((item) => item.getAsFile()) + .filter(Boolean) + if (files.length === 0) return + + e.preventDefault() + addImageFiles(files) + } + + const handleDragOver = (e) => { + e.preventDefault() + if (e.dataTransfer) e.dataTransfer.dropEffect = imagesAllowed ? 'copy' : 'none' + } + + const handleDrop = (e) => { + e.preventDefault() + addImageFiles(e.dataTransfer?.files) + } + + const handleKeyDownOrClick = (e) => { + e.stopPropagation() + if (!shouldHandleInputAction(e)) return + + e.preventDefault() + if (!enabled) { + postMessage({ stop: true }) + return + } + + if (isReadingImages || pendingReadsRef.current > 0 || submittingRef.current) return + + const submittedImages = imageEntriesRef.current + .map((entry) => entry.dataUrl) + .filter((dataUrl) => typeof dataUrl === 'string' && dataUrl.length > 0) + const question = value.trim() + ? value + : submittedImages.length > 0 + ? t('Describe these images') + : '' + if (!question) return + + const submittedRevision = draftRevisionRef.current + submittingRef.current = true + setIsSubmitting(true) + Promise.resolve() + .then(() => onSubmit(question, submittedImages)) + .then(() => { + if (!mountedRef.current || draftRevisionRef.current !== submittedRevision) return + clearDraft() + }) + .catch((error) => { + if (!mountedRef.current || draftRevisionRef.current !== submittedRevision) return + const message = error instanceof Error ? error.message : String(error || '') + setImageError(message ? t(message) : t('Unable to send images.')) + }) + .finally(() => { + submittingRef.current = false + if (mountedRef.current) setIsSubmitting(false) + }) + } + + useEffect(() => { + inputRef.current?.focus() + }, []) + useEffect(() => { - inputRef.current.focus() + return () => { + mountedRef.current = false + readGenerationRef.current += 1 + } }, []) + useEffect(() => { + clearDraft() + }, [resetKey]) + + useEffect(() => { + if (imagesAllowed || (imageEntriesRef.current.length === 0 && pendingReadsRef.current === 0)) + return + clearImages() + }, [imagesAllowed]) + useEffect(() => { if (hasTopResizeHandle) return @@ -92,20 +320,6 @@ export function InputBox({ onSubmit, enabled, postMessage, reverseResizeDir }) { }) }, [enabled]) - const handleKeyDownOrClick = (e) => { - e.stopPropagation() - if (shouldHandleInputAction(e)) { - e.preventDefault() - if (enabled) { - if (!value) return - onSubmit(value) - setValue('') - } else { - postMessage({ stop: true }) - } - } - } - const handleResizePointerDown = (e) => { if (!e.isPrimary || e.button !== 0) return @@ -154,8 +368,15 @@ export function InputBox({ onSubmit, enabled, postMessage, reverseResizeDir }) { ) } + const sendDisabled = enabled && (isReadingImages || isSubmitting) + return ( -
+{hasTopResizeHandle && ( )} + + {imagesAllowed ? ( ++ + + {t('Drop images here or paste a screenshot')} + ++ ) : ( ++ {t('Images require an OpenAI-compatible vision API.')} ++ )} + {imageEntries.length > 0 && ( ++ {imageEntries.map((entry, index) => ( ++ )} + {imageError && ( ++ {entry.dataUrl ? ( ++ ))} ++ ) : ( + + … + + )} + +
+ {imageError} ++ )}setValue(e.target.value)} + onChange={(e) => { + setValue(e.target.value) + bumpDraftRevision() + }} onKeyDown={handleKeyDownOrClick} />