From a02b8f4609071842aab052c8550647b1f5a25208 Mon Sep 17 00:00:00 2001 From: defi-failure <159208748+defi-failure@users.noreply.github.com> Date: Mon, 13 Oct 2025 16:57:20 +0800 Subject: [PATCH 01/39] chore: update SiliconFlow logo (#10684) --- .../src/assets/images/providers/silicon.png | Bin 7887 -> 1506 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/src/renderer/src/assets/images/providers/silicon.png b/src/renderer/src/assets/images/providers/silicon.png index 138f90309f645147e1f129b64f5b9eaf52be5e00..041d090eae70c8b31e2e04289e60530d0c28c193 100644 GIT binary patch literal 1506 zcmbVMX;2df6y9Kq2+~poEq1U*j;KH<0S^dN5rYJjOQ;-*eGd!VVZJLYaD%V=!&N-w z)nlbnj@Mn0$67%VCSU{!jM)uUTtb#UV$d>H5{pFc0GT_$$ekdQ1PUW$D^l#CNY2^? zHeQuu7%qK}CDWk5QG{%M25T=< zydOrB9ifa9aIzBwE@8tR%H|vL8a^Uo$_84MYzkPNigc7Kxci{48pU9%l6M4_PGgy; z;3#u&q*KYEfKg^(jR29u_yeK*r(y*H!YEncIax&#(pjmPo5I44 z!HIqpRp6HnLj#YLwP^^B;c--GauAjMh8418MbQX`;>FQO<2AYX0`jC*AsI?*KE z(54Le?Tbj>85x@lo-+qOyhZySDsQo6sV;D$J(O?|Du|T9E7=!hc?5PWJtS0(GB=)PgJ`wT^TrbZ)|ffb z4tm8Bq{1-o0XyAPLm!8-v!t`cG@oz=MeR<2zdNBby;Gapd2!tP)jYGc4Au9?#+;Iv>6JZirp^*wDbd=LY*pwAx#@`#mFNeQ3h<^+ej~CwT_-k1p5c z^!gHq8e($zcj|IlerU?}(FzaK4!YUl=hO4{+fnB#-km1(dbc3+D#pTA=Jet&>kH*u zeT?nTZXk#+BOwx18mZ^`IOR&yLlSNIO`KQD>aH+ZF4F{J+4Itf|tysWFQUg_Tb z<^JQ6-2N|q@mJ@MOivY%E=+Ih2uUg0GQ9sGX411Us~hdHkZ!)3O*%r#;)(KgjqWe! z2#?D=Z*S!3J{QnmmB-=TgX#Q(9He{HlxLrGX+lol1tGN`IkGUhn=a6$q(3~KOsh6(@+vc(>A z3fY`u=|#4pT3S*qtxd^fDw*6mb|e390wyaYEHv)l1-(f`)zbxb{8@n$#$>QLfy}6X ZYWD2?qew+VT&_w3I61h|YU~3p{|>f4*B}4@ literal 7887 zcmc&(Wm8;D)5aZw1qtr%!QI^?5cBYV0}BK3{bJfsjF_;!)y#a6 zkZ6DWH;_H2K{5y-N?l3bD6r@h5|mCe7zXc=hIjd!)a6Vk5+3ZN3(ynNXQ9L>cG#yU zS^*83PV`>ao@h`@#>J%BW-jxx{kF}E;kqm!ZPLaP5gEAKgZAYsvKH-3YY5WYL2%7a zFB=LDp9DU(H-8KJChhDWQNkJMK!}Qyvn$jK6;iRI?@MWm=()&X*JuT1Sc= z7Gx%jo|Z#lz{3CE3tJO@s0jse;&P?qdWTNcev${)C!fkcl=?=Y4n-QUDRw6JaS8g} z@JFKBQrr*BoKvh!QYdZ~6E^mcmHm^8%`3r~?vX9=a$t-a7kFY+Tw=t8m+aZCB=Y8d zxz=Mx%?TnHUB`24!KraI@+Y$o~~n{g|#M`6yvBYlz`Oe zeIqB)2^Ncwckkw;xXpIUoC{(!+EFKWb*7jIXp!}?=0EIijEVPxYv~WCwJMWH4|i22 zr>4J&$^658CE`70QYYraPe&GqCaB4gO|fWW)cY}&7A(Ee|6|wk0&A;?3D2H^pY?p2 zQC&Ra*K$jwBPb4;Fz)hR=UYbts4#Tx@{r*quwMKU7h+HGZNS%G|IkV7d6sdqqds!5 zPMRMh7Fl(W9HV&b3d(ATp=F9H<%+FVB8`fdE}*V5G`+b1UeY^?J>1}wTudBYVGcE zeNu$k%yyyr8mS8G{C+`3sZ$5xtQS@!#z5MaoW}Pc@tMx zM_fh-)9PLUi$DM(KAHJ}1~w>pS~~g&CA{QpJIvfN*IA#mo5N*;Pf95G8qcJKAK_5WlEXIfdozNHA2Pza)hBv zjAhD83YZa3X%N#0JLCmqnJU9k6ep{9>L(Xa2|tLKmCkSL)B= zzxtMu;M22CzB|eJ8FWiLm0fIo-Db@T>NIK^o2r#PPP~%E9fjM-;jnAWGXG)7oM4qg zkxY`A#*j3!-V)aBA?LYDYfKXs^0vH42o0Orf!kt#=ZOdK_SiqS98yDn>gNt21Y9N^ zcGtS8qfsc!ZJwq44wOe3A;o>y5x7adpQ;Y}>6AHpAkj3+SP>Iw<-}#BEBQ)x#IntC zHOh*iEYgx&A!;AhB(xf3impJ}!$%V-h)q~q?(IU}iBM&xu~3ll!A|yfX*;)2HqCT7 z!5>Y>()B;L%+@bKk06>ZNU-}czZv05b@$!+W#sx4`p?8%A#`kJ)H{}V9#Ig-K6ec( z)+kEgO8y7V;r0n|;sJOr^=xc*xB>Iz82|EsruEMo0d2&-y?bf?Ewp-?pXIg7=OTlj z6!$m}&-NH$;{{rdf&xcsU|*?t3t5Rs{t%KNIZ&NyGZm~L-AG-sJsd%!a|v+p=* zevD@1Dt{x7xs%_|CHQpJQ`>QVYzD7a8;uh8t7IgK*S!!Po`jab}3YSWc1G%#)Fot z{T@;ZyhNh3;@DXyvogu#J@r}I#lSj9D%1l18B?yA5n~fegT+S@~~NV!y2_o@|iW+IU5N`$kE9 zz%PeVz_93kv(<6Moz2ZuarFo%QDp|qb1}*k z!;p(F5RW2MTA|s=x{&J_aVSWQz20Y%nhw#va&$t=rr5pIpFaQX3U}xE^9SP~tR)d* zQ7Cq&5xf18Kt_Ou?WiKQe^e&;)wt6{gu_%I*>N=XwMG-ab>P=T_<6lLy5tf=)Ho8k zdt%8g(tJR;75#>exyhgQ-Jwmop1SzQ@75lzaTK`Njvo)LcY3+_l`>uk8b@te9s%%# zVWr)BxoITN9w1>?u}dZu0O6(yZmV7MiA1P&-w~k|4;-D~Jk9&BcK_Y!KY-tr;Shb4 zmJn{5Z{@0oN|y)JxK>lX&KevtkYd7u;kgx6QOmm7~PyC6wNH%wPJqo z>(#*+k2V<+g=BQ`v<~%7aPM~rL)(V&)GwwlW*^W5Q>jCs9~8vT)?q}z!aId=pPFI@ zj%>>CAk9C{6Hc#*hu=aSQziFC#Mjw7??MwMq18&aqe94SvKo|imkKBaD!C=U7;$#3 zH$1m^UOa+-@Nrxh(#^ZpJD{Y?Rb^yN@udW9r;hmKJ6#@Czq-=u`N#JPu|NdBaH~W6 z6hf%3A_#Hx@{*fVW5q%^*E@}M98?Mx_+6_s z{7Q}FyC#!{F(_y-4^|cX;7Sa*g4%&`cS0j7H3rR9;^vGyBp(hPz`qV=7V8lvvf{kP zP^+pLOPA)(Q6Vp^sL_8Cuv4R-i`^Bh=&Cz)>A0&`eK0koX=QShXT?ES!JduO5QM?3 z{quP?HDDMe9Jq~UZ;!AVdnvEc7G*fbJtu^veEpK>2Q9)qrAIAQ&XRtH8>^I53`3ZR z$KP}~&x%_b435ngV@{`3ayA}#%MW@eh{C|Z_u~UjH?Q>xz&j7j4|@3oNJr&r=Avwp z$mOu!P|MR$NKlu`3|v0A5k0(8I#{JC)Bg=r%|n}o!W{tRhdchnV>j|BM5eIC!Dt7o zdWpIIq+42vVCrlMN(~6GQA^^nni4uvhNbf)?c)75Uc5Gjy74^m;>u84jMwd3ks(X8 zqlG~g1Q0Q+mo5{OE=R~$;XQgUlUs6PcUwTqxlT}qm7uT@d~7IqsN2e@4s(#yks`0{ z8t0Eli~$us>4WlfFVx${j>!JyhUv=e^Z0B%2sr?c6sc3+sC=<5QT-28syEUHu`Ig} z-bPirNfq@}#yaT!;g6_^;ZHb!fgQgCX#jh~I`TeAylacknPBvW{*KDL#-rK$LODQ9 z(cI9`!Q;wlm@P_^wXs@n*v-oI`xyDcIfo^9pjy_ZAh(!Jig$CHnNbL9$!IX7z$9-ZBZ zEXvjE`V*|*&v}Cd0^BTc9bxJ%0Vl(8kh@$I9kG6RmTbdr)W7;Yd!8+EhI9kA{+uPy zRxCcP#Ga(fqWGIuBD-abZ>LhvY`Ns?yzBEIE5ed&{Xv$)XP8cXm-WJJLDlit&z$ey z_hvSqx&!jQ2IeH6uZblb$+am99>eAT!=4`tyS$GE(McsKKHknm`TP_X1gQYKOm(j~ z3P&3>&XY>iEoK2A`>#!J1&^y?)>hA~nw+_>8(BKzf6~LTsX{G%uMyyt$HrBVYur4l zgZ3-Xm&n5N=*NyR@0MUsrx^jlv3~QZk zYq=qJE1qn5EJS`}M($ajzibwBKTS3rY0(unp1U-b&JEX-sC6c9>&na}3Ohey9Xb3n zHiGc>krvgCj#@IT3$!(4aQJ8Bm@G6>sS30PbdxDNeVGLb*HVABG4F+Gzuf8Y4~}z2 zQ8&*HKK2N_;#v%;Ch$JAGFlS%gUFFf9Zxd!{{mO0Eui1P931yESXlf5o?Bk)TYWe( zwd!W}Ojg*c=Sm(jwsDHa6LAeSf>CZfAL{t=frv1IMOE8$_coXT#_)>c=F^L|M*spr zt{sDCI{id|Nh5%4$XHeAgC#jex!_#Mj=jAg(~ckVJk;E zq;iG;YNuLs7c;bhda$DWn#=_UXnO6R1MVi%52HDPke-mHn4sk5C7R}?GT_(y2Lf*E zQ*iLW+jR~t8r?nK8X0Bx(C{YdDnV$lS0CGCTE0wiJJlHlxaCA_>ae{7j12fDZYmG7 zRnNAc4j6DQ8Mj#(HwM0Z>f61ypk#EIa7{~Y;24yx{ZY}rJ6E@cW)Au5m7BV1_->)~dj5f4}9V@W)<(y}Cp#9E8 zjdn3S8XSLB$v zO`n`(7_6Qr+Bzl@mA>`R%e47rMcC=h2t;ne`z-M|iD!+$A_3u-MItNWDiPOurDY2#ab3XF7@+J6626ke8DlXZ~;;txz&A zkX)!!wycX4_v>7V$Ynf%p@q>tw3DE)EdjJ@E?2N{3g|^guAAYEy}o%=o=&%3t#yhYY(V678p_vHqi>DgI7EwbB9a z$%Vm1)R(qHD@s^|shRYM%o664!jsnH#s;B0gh?uR?m7u?U6XoTl2v3_UWhD33(p?j zepmqm^Z0lBhm$M_2y(rDXjtW|>(a9qyLq2L_s`9L5c!u9JzLRs>XG*$o%L;+8xvY# zWP!k^<-Q9<-ll!De%%wo(Oc}%cTmtz@L%Q}tg|Ze2NE4`?DX@YOu#IuIpy0(mXpfV zj`VW$PnWE^4g+$n@+JjQFTIRw`A;6SN!mBX61HDX(Dz(@7}AyNEMxnY=?whaiNj8B zXmrRc#*RR1fxx53@A`uz0ZRhjYP8B%Sb+{+v7&!e{sX?h*@8NLp#tiqCcHXh?Ij({ za;-}0!*+mG%Np;je-kJEHcnvFGy7D552orlY&Y8tJP8qA`$xAho{T!2DEAI>*uF+7 zw^8bDiM~!_&JCwP!U)|z$87Sg3qzue>n+|#UHg+Lk4Wt)&7$P3i}E4u3muuY$7-Xf zm!rIGWSUa%01SS);#NcA50B!!G5e@7VMP9(9y?3zOBbVaEzq3`1bcB{cBZBs6rDN- zm|K~}`|G>*(1d)lDgJ%Xu@045_TOK-w{_HBkI91XV=NE%R$4~d*3}{EROW8iZ#)+{ zxkRjg(6`lp@W%|iA7Krq^Vr!MWBBppf+|OV<}-fDD-l9nDLJhJSEp+vF{sJf2J7O3 zEvQ|?Z#RYO#o_QPl64@6U{x!(-p5r%7v3Zojr7O&=t-gxMxLJD~YWSI7Fh+5X*RY78RU^ZM~pQ2Gl3A{`@= ztIzI61p2*{#4;zg7LTdx==@DsWsOub^WiqfbF_xE9rJ4Ut9v(1qim0A~YgXB>Pp%-|3VxNOP26&+jd`+$9}Z2&+lH%$Ve?)g$-= z?4@(YNTy(zL$RA2mi@{*Cv-$HA+yl7*d%xS%wQ-V7FcoS*IoK1XED|9xIyTCb! zu#;&HMV-GAIG+prD`$t5W_>#8dt}O_1$hV?I@ZT# zi&=Zt{aaw=&PU;yEN)hZ7S^EaAH-vfNeri%HG4|J&FvTpXTvRW3U=w5d%+2FTCch(7^vo#NnTPzw6;sm2aVxxZnZFs0chW8A3 z4*`^8Cz|?N@C52A3%{~=0ht}>F>Q)r0+3FHJ@l)Ga|OUh!yJrO^(@u|2%kGjPlQL1 zfkOtDtU(`?WPLHnXon&VG-KsJwp1`mg}6m8kWyf6;>h}}M;54FC1>|^3G``J8fbES zqlvTZ9R!>=3dphr9Uk1Yj&VD21*Yx%%^)FRB1fS-pbBA|PmN%15rP);QehtRHaw3? zaa#+^Xzqyh#&acgoW#6#Rn5V+6#1;N=$R9z1qNDv`V0KHFGGpARL_`V7{=?u^HJNHBJjnY~O&jAT6j0I5_z0%pP|JA9q=RinrEw~qlNj2om z@e^oeG$(5GnCYQUn06-WMm-Vi&hcj9E+iP(RUu;)ug!{O9LnI!0)Pj}T>Qk{u8uoR zxXi+hLb^PnAGx}dkhRMFE(~ZNXlbfXskQXs1)`MJm)9MU@8VjFu@#2@u#7KRlj@wB zp91(WrH6fE_#;L4iuJ&t1?q9A!}b^=b-=B6Wwo?DXj%?|86((W*>2Z_dN^|?RTP5Y z<&$2c2JL5?W%{d3kvO7cmg4MrRHaoPH51iM;3FrHC#@FGEMfi1S;Im@D(?Kev8bf5 zD)6dMkW;?mZ}Md%FWD?)?j65<=aMy+p0q;wewKOznxJ3)?xmy~`@5ZDyQ9c5DyAXq z!z45*5ay*6P@_45~tH5)KiQ{sxiPe2Kwt$Qe3n9|oVP3q&l=!ej_jo(G6@Qi_ zi|-1tmV5^%gzKzboi$;Jx%qv{U=yM~tKE(J;BpfRMPRKTNP#WgGN4;*xtiF73+%Jga>hgVhC-MlGvCsO3*ms39{k1wP>0GCwJ+XX_!Jd@0!|_I=@n^qMNydGY8rto<7w35Ig9ZL@;cNW?(rV5~-S4Yt zb7dh8P_?qx{Mh40s<6<~aqsh`_{$qz*X_C*)+H3=NR`yx$ti*;)uz?bBO?)rP3sh? zTS7uq%llt8^_xTMT>ku0A-XiL!s~LomI{7ntl^>|h5xmW^s*L>bV^Or_{UlT)$w%W zm|e|0uMOiwZV^gncXJNrzo`5`RS?mB=WhQFE7o@_=QHZst*#;xRTFcS}UDBh>DBY`T<1J{&b5J|0w=MJcsjxRPiACA<-P2 zNPvNU@wB6NPS$NJLPmhysvsj@!YM}%$@Xs>QhS6Cp;tHA58y2&6Y!G`ccD(_C`wRa z6Xs>$%I2X+rJatuIBice7|}*iqpYK}UBpI9aBdC1{mB%#w~wDKVaCNvq(3o}iT+TF ztZbpR!ZpKklZ1CG4-WZmFeq(PmG4kBJScRyTPibt+0Tc!eeoTuaKl)sCYiw{U$X5z zW2q^Io~}BP>KW*EWJqe%3h&WB_U=p;Z>QJG6_i;JyY6jWGVdvb*%^);`Vnw!X$cC$ zMD(r*D^bBtW_J-Ni?QE~s-Z_Q;x0mA)7 z&tT$>P#`iy8qc=eX1<(Q^}*0s2SNAth1I2?28KVu!C$ohJL;*m)qG>o%9Y-tCVEEc zG3t7WF?dx#&ewxLOz;@t+nve~qqD5q=3_$CaCj=ukhd%x3@=>%BZs;gvHnT_GDXlC zJyY+*)-<3Y&5)Pf_IC|}&Z1aDQC!|iV7&FR-SuHv%^JzRWdI{WadF6}sC_z@t*yH4 zqPx`V@gj1ykCSnTrrfRr;=f$Tf&GgCrgc=Qo}H~6HwA6anBD+T6@!&VH2kVbV4Nt9 z`dgYX(AFdY(Hi{frK&cSx{pPifYLKOJ=6am*;45y?Z@l*|FqlKssVnO!H7M9d{~zPR3O)b; From f27b04c5b0c2c5412d2a7127d1da03db15c8d789 Mon Sep 17 00:00:00 2001 From: Chen Tao <70054568+eeee0717@users.noreply.github.com> Date: Mon, 13 Oct 2025 17:39:08 +0800 Subject: [PATCH 02/39] fix: support gemini-2.5-image-flash (#10683) --- src/renderer/src/config/models/vision.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/config/models/vision.ts b/src/renderer/src/config/models/vision.ts index a9730d25c..cd09e6e34 100644 --- a/src/renderer/src/config/models/vision.ts +++ b/src/renderer/src/config/models/vision.ts @@ -82,14 +82,14 @@ export const IMAGE_ENHANCEMENT_MODELS = [ 'grok-2-image(?:-[\\w-]+)?', 'qwen-image-edit', 'gpt-image-1', - 'gemini-2.5-flash-image-preview', + 'gemini-2.5-flash-image', 'gemini-2.0-flash-preview-image-generation' ] const IMAGE_ENHANCEMENT_MODELS_REGEX = new RegExp(IMAGE_ENHANCEMENT_MODELS.join('|'), 'i') // Models that should auto-enable image generation button when selected -export const AUTO_ENABLE_IMAGE_MODELS = ['gemini-2.5-flash-image-preview', ...DEDICATED_IMAGE_MODELS] +export const AUTO_ENABLE_IMAGE_MODELS = ['gemini-2.5-flash-image', ...DEDICATED_IMAGE_MODELS] export const OPENAI_TOOL_USE_IMAGE_GENERATION_MODELS = [ 'o3', @@ -107,7 +107,7 @@ export const GENERATE_IMAGE_MODELS = [ 'gemini-2.0-flash-exp', 'gemini-2.0-flash-exp-image-generation', 'gemini-2.0-flash-preview-image-generation', - 'gemini-2.5-flash-image-preview', + 'gemini-2.5-flash-image', ...DEDICATED_IMAGE_MODELS ] From de37e2355d39edcd29c37d5b1ab2edba77f917a3 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Mon, 13 Oct 2025 20:57:08 +0800 Subject: [PATCH 03/39] chore: update @ai-sdk/google to version 2.0.20 and add corresponding patch - Updated the @ai-sdk/google dependency to version 2.0.20 in package.json. - Added a new patch file for the updated version to address specific changes in the library. --- ...ch => @ai-sdk-google-npm-2.0.20-b9102f9d54.patch} | 4 ++-- package.json | 2 +- yarn.lock | 12 ++++++++++++ 3 files changed, 15 insertions(+), 3 deletions(-) rename .yarn/patches/{@ai-sdk-google-npm-2.0.17-fd88491de4.patch => @ai-sdk-google-npm-2.0.20-b9102f9d54.patch} (68%) diff --git a/.yarn/patches/@ai-sdk-google-npm-2.0.17-fd88491de4.patch b/.yarn/patches/@ai-sdk-google-npm-2.0.20-b9102f9d54.patch similarity index 68% rename from .yarn/patches/@ai-sdk-google-npm-2.0.17-fd88491de4.patch rename to .yarn/patches/@ai-sdk-google-npm-2.0.20-b9102f9d54.patch index 91ef37088..34babfe80 100644 --- a/.yarn/patches/@ai-sdk-google-npm-2.0.17-fd88491de4.patch +++ b/.yarn/patches/@ai-sdk-google-npm-2.0.20-b9102f9d54.patch @@ -1,8 +1,8 @@ diff --git a/dist/index.mjs b/dist/index.mjs -index b957cb824faa79cf01ba3a504f221870bd8e306a..4d71d30f655775d61537d9d8b73f6e17d41fa67e 100644 +index 69ab1599c76801dc1167551b6fa283dded123466..f0af43bba7ad1196fe05338817e65b4ebda40955 100644 --- a/dist/index.mjs +++ b/dist/index.mjs -@@ -452,7 +452,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) { +@@ -477,7 +477,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) { // src/get-model-path.ts function getModelPath(modelId) { diff --git a/package.json b/package.json index 01c3dd534..e5c8fd7f4 100644 --- a/package.json +++ b/package.json @@ -382,7 +382,7 @@ "undici": "6.21.2", "vite": "npm:rolldown-vite@latest", "tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch", - "@ai-sdk/google@npm:2.0.17": "patch:@ai-sdk/google@npm%3A2.0.17#~/.yarn/patches/@ai-sdk-google-npm-2.0.17-fd88491de4.patch" + "@ai-sdk/google@npm:2.0.20": "patch:@ai-sdk/google@npm%3A2.0.20#~/.yarn/patches/@ai-sdk-google-npm-2.0.20-b9102f9d54.patch" }, "packageManager": "yarn@4.9.1", "lint-staged": { diff --git a/yarn.lock b/yarn.lock index 04d8ecec2..4958b33fa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -168,6 +168,18 @@ __metadata: languageName: node linkType: hard +"@ai-sdk/google@patch:@ai-sdk/google@npm%3A2.0.20#~/.yarn/patches/@ai-sdk-google-npm-2.0.20-b9102f9d54.patch": + version: 2.0.20 + resolution: "@ai-sdk/google@patch:@ai-sdk/google@npm%3A2.0.20#~/.yarn/patches/@ai-sdk-google-npm-2.0.20-b9102f9d54.patch::version=2.0.20&hash=1f2ccb" + dependencies: + "@ai-sdk/provider": "npm:2.0.0" + "@ai-sdk/provider-utils": "npm:3.0.12" + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + checksum: 10c0/2d567361d533a4e2be83aa135cb5f01f09ea54c255d7751171855ef4244cfaeff73fe7b3c7b044b384a9c170e89d053160a26933176ad68dcaf03bd3c69c0be3 + languageName: node + linkType: hard + "@ai-sdk/mistral@npm:^2.0.19": version: 2.0.19 resolution: "@ai-sdk/mistral@npm:2.0.19" From 88f7e6a854f397c62e7cd4d396c3e672c61ee8fa Mon Sep 17 00:00:00 2001 From: Pleasure1234 <3196812536@qq.com> Date: Mon, 13 Oct 2025 14:44:31 +0100 Subject: [PATCH 04/39] fix: add esbuild and update tar-fs dependency (#10671) Added 'esbuild' with version ^0.25.0 and updated 'tar-fs' to ^2.1.4 in package.json. This also updates related entries in yarn.lock to ensure compatibility and resolve dependency issues. --- package.json | 2 + yarn.lock | 512 +-------------------------------------------------- 2 files changed, 7 insertions(+), 507 deletions(-) diff --git a/package.json b/package.json index e5c8fd7f4..3c5d89ffe 100644 --- a/package.json +++ b/package.json @@ -372,6 +372,7 @@ "app-builder-lib@npm:26.0.13": "patch:app-builder-lib@npm%3A26.0.13#~/.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch", "app-builder-lib@npm:26.0.15": "patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch", "atomically@npm:^1.7.0": "patch:atomically@npm%3A1.7.0#~/.yarn/patches/atomically-npm-1.7.0-e742e5293b.patch", + "esbuild": "^0.25.0", "file-stream-rotator@npm:^0.6.1": "patch:file-stream-rotator@npm%3A0.6.1#~/.yarn/patches/file-stream-rotator-npm-0.6.1-eab45fb13d.patch", "libsql@npm:^0.4.4": "patch:libsql@npm%3A0.4.7#~/.yarn/patches/libsql-npm-0.4.7-444e260fb1.patch", "node-abi": "4.12.0", @@ -379,6 +380,7 @@ "openai@npm:^4.87.3": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch", "pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch", "pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch", + "tar-fs": "^2.1.4", "undici": "6.21.2", "vite": "npm:rolldown-vite@latest", "tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch", diff --git a/yarn.lock b/yarn.lock index 4958b33fa..8befb6e5d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3552,20 +3552,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/aix-ppc64@npm:0.25.8": - version: 0.25.8 - resolution: "@esbuild/aix-ppc64@npm:0.25.8" - conditions: os=aix & cpu=ppc64 - languageName: node - linkType: hard - -"@esbuild/android-arm64@npm:0.18.20": - version: 0.18.20 - resolution: "@esbuild/android-arm64@npm:0.18.20" - conditions: os=android & cpu=arm64 - languageName: node - linkType: hard - "@esbuild/android-arm64@npm:0.25.10": version: 0.25.10 resolution: "@esbuild/android-arm64@npm:0.25.10" @@ -3573,20 +3559,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/android-arm64@npm:0.25.8": - version: 0.25.8 - resolution: "@esbuild/android-arm64@npm:0.25.8" - conditions: os=android & cpu=arm64 - languageName: node - linkType: hard - -"@esbuild/android-arm@npm:0.18.20": - version: 0.18.20 - resolution: "@esbuild/android-arm@npm:0.18.20" - conditions: os=android & cpu=arm - languageName: node - linkType: hard - "@esbuild/android-arm@npm:0.25.10": version: 0.25.10 resolution: "@esbuild/android-arm@npm:0.25.10" @@ -3594,20 +3566,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/android-arm@npm:0.25.8": - version: 0.25.8 - resolution: "@esbuild/android-arm@npm:0.25.8" - conditions: os=android & cpu=arm - languageName: node - linkType: hard - -"@esbuild/android-x64@npm:0.18.20": - version: 0.18.20 - resolution: "@esbuild/android-x64@npm:0.18.20" - conditions: os=android & cpu=x64 - languageName: node - linkType: hard - "@esbuild/android-x64@npm:0.25.10": version: 0.25.10 resolution: "@esbuild/android-x64@npm:0.25.10" @@ -3615,20 +3573,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/android-x64@npm:0.25.8": - version: 0.25.8 - resolution: "@esbuild/android-x64@npm:0.25.8" - conditions: os=android & cpu=x64 - languageName: node - linkType: hard - -"@esbuild/darwin-arm64@npm:0.18.20": - version: 0.18.20 - resolution: "@esbuild/darwin-arm64@npm:0.18.20" - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - "@esbuild/darwin-arm64@npm:0.25.10": version: 0.25.10 resolution: "@esbuild/darwin-arm64@npm:0.25.10" @@ -3636,20 +3580,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/darwin-arm64@npm:0.25.8": - version: 0.25.8 - resolution: "@esbuild/darwin-arm64@npm:0.25.8" - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - -"@esbuild/darwin-x64@npm:0.18.20": - version: 0.18.20 - resolution: "@esbuild/darwin-x64@npm:0.18.20" - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - "@esbuild/darwin-x64@npm:0.25.10": version: 0.25.10 resolution: "@esbuild/darwin-x64@npm:0.25.10" @@ -3657,20 +3587,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/darwin-x64@npm:0.25.8": - version: 0.25.8 - resolution: "@esbuild/darwin-x64@npm:0.25.8" - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - -"@esbuild/freebsd-arm64@npm:0.18.20": - version: 0.18.20 - resolution: "@esbuild/freebsd-arm64@npm:0.18.20" - conditions: os=freebsd & cpu=arm64 - languageName: node - linkType: hard - "@esbuild/freebsd-arm64@npm:0.25.10": version: 0.25.10 resolution: "@esbuild/freebsd-arm64@npm:0.25.10" @@ -3678,20 +3594,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/freebsd-arm64@npm:0.25.8": - version: 0.25.8 - resolution: "@esbuild/freebsd-arm64@npm:0.25.8" - conditions: os=freebsd & cpu=arm64 - languageName: node - linkType: hard - -"@esbuild/freebsd-x64@npm:0.18.20": - version: 0.18.20 - resolution: "@esbuild/freebsd-x64@npm:0.18.20" - conditions: os=freebsd & cpu=x64 - languageName: node - linkType: hard - "@esbuild/freebsd-x64@npm:0.25.10": version: 0.25.10 resolution: "@esbuild/freebsd-x64@npm:0.25.10" @@ -3699,20 +3601,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/freebsd-x64@npm:0.25.8": - version: 0.25.8 - resolution: "@esbuild/freebsd-x64@npm:0.25.8" - conditions: os=freebsd & cpu=x64 - languageName: node - linkType: hard - -"@esbuild/linux-arm64@npm:0.18.20": - version: 0.18.20 - resolution: "@esbuild/linux-arm64@npm:0.18.20" - conditions: os=linux & cpu=arm64 - languageName: node - linkType: hard - "@esbuild/linux-arm64@npm:0.25.10": version: 0.25.10 resolution: "@esbuild/linux-arm64@npm:0.25.10" @@ -3720,20 +3608,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-arm64@npm:0.25.8": - version: 0.25.8 - resolution: "@esbuild/linux-arm64@npm:0.25.8" - conditions: os=linux & cpu=arm64 - languageName: node - linkType: hard - -"@esbuild/linux-arm@npm:0.18.20": - version: 0.18.20 - resolution: "@esbuild/linux-arm@npm:0.18.20" - conditions: os=linux & cpu=arm - languageName: node - linkType: hard - "@esbuild/linux-arm@npm:0.25.10": version: 0.25.10 resolution: "@esbuild/linux-arm@npm:0.25.10" @@ -3741,20 +3615,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-arm@npm:0.25.8": - version: 0.25.8 - resolution: "@esbuild/linux-arm@npm:0.25.8" - conditions: os=linux & cpu=arm - languageName: node - linkType: hard - -"@esbuild/linux-ia32@npm:0.18.20": - version: 0.18.20 - resolution: "@esbuild/linux-ia32@npm:0.18.20" - conditions: os=linux & cpu=ia32 - languageName: node - linkType: hard - "@esbuild/linux-ia32@npm:0.25.10": version: 0.25.10 resolution: "@esbuild/linux-ia32@npm:0.25.10" @@ -3762,20 +3622,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-ia32@npm:0.25.8": - version: 0.25.8 - resolution: "@esbuild/linux-ia32@npm:0.25.8" - conditions: os=linux & cpu=ia32 - languageName: node - linkType: hard - -"@esbuild/linux-loong64@npm:0.18.20": - version: 0.18.20 - resolution: "@esbuild/linux-loong64@npm:0.18.20" - conditions: os=linux & cpu=loong64 - languageName: node - linkType: hard - "@esbuild/linux-loong64@npm:0.25.10": version: 0.25.10 resolution: "@esbuild/linux-loong64@npm:0.25.10" @@ -3783,20 +3629,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-loong64@npm:0.25.8": - version: 0.25.8 - resolution: "@esbuild/linux-loong64@npm:0.25.8" - conditions: os=linux & cpu=loong64 - languageName: node - linkType: hard - -"@esbuild/linux-mips64el@npm:0.18.20": - version: 0.18.20 - resolution: "@esbuild/linux-mips64el@npm:0.18.20" - conditions: os=linux & cpu=mips64el - languageName: node - linkType: hard - "@esbuild/linux-mips64el@npm:0.25.10": version: 0.25.10 resolution: "@esbuild/linux-mips64el@npm:0.25.10" @@ -3804,20 +3636,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-mips64el@npm:0.25.8": - version: 0.25.8 - resolution: "@esbuild/linux-mips64el@npm:0.25.8" - conditions: os=linux & cpu=mips64el - languageName: node - linkType: hard - -"@esbuild/linux-ppc64@npm:0.18.20": - version: 0.18.20 - resolution: "@esbuild/linux-ppc64@npm:0.18.20" - conditions: os=linux & cpu=ppc64 - languageName: node - linkType: hard - "@esbuild/linux-ppc64@npm:0.25.10": version: 0.25.10 resolution: "@esbuild/linux-ppc64@npm:0.25.10" @@ -3825,20 +3643,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-ppc64@npm:0.25.8": - version: 0.25.8 - resolution: "@esbuild/linux-ppc64@npm:0.25.8" - conditions: os=linux & cpu=ppc64 - languageName: node - linkType: hard - -"@esbuild/linux-riscv64@npm:0.18.20": - version: 0.18.20 - resolution: "@esbuild/linux-riscv64@npm:0.18.20" - conditions: os=linux & cpu=riscv64 - languageName: node - linkType: hard - "@esbuild/linux-riscv64@npm:0.25.10": version: 0.25.10 resolution: "@esbuild/linux-riscv64@npm:0.25.10" @@ -3846,20 +3650,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-riscv64@npm:0.25.8": - version: 0.25.8 - resolution: "@esbuild/linux-riscv64@npm:0.25.8" - conditions: os=linux & cpu=riscv64 - languageName: node - linkType: hard - -"@esbuild/linux-s390x@npm:0.18.20": - version: 0.18.20 - resolution: "@esbuild/linux-s390x@npm:0.18.20" - conditions: os=linux & cpu=s390x - languageName: node - linkType: hard - "@esbuild/linux-s390x@npm:0.25.10": version: 0.25.10 resolution: "@esbuild/linux-s390x@npm:0.25.10" @@ -3867,20 +3657,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-s390x@npm:0.25.8": - version: 0.25.8 - resolution: "@esbuild/linux-s390x@npm:0.25.8" - conditions: os=linux & cpu=s390x - languageName: node - linkType: hard - -"@esbuild/linux-x64@npm:0.18.20": - version: 0.18.20 - resolution: "@esbuild/linux-x64@npm:0.18.20" - conditions: os=linux & cpu=x64 - languageName: node - linkType: hard - "@esbuild/linux-x64@npm:0.25.10": version: 0.25.10 resolution: "@esbuild/linux-x64@npm:0.25.10" @@ -3888,13 +3664,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-x64@npm:0.25.8": - version: 0.25.8 - resolution: "@esbuild/linux-x64@npm:0.25.8" - conditions: os=linux & cpu=x64 - languageName: node - linkType: hard - "@esbuild/netbsd-arm64@npm:0.25.10": version: 0.25.10 resolution: "@esbuild/netbsd-arm64@npm:0.25.10" @@ -3902,20 +3671,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/netbsd-arm64@npm:0.25.8": - version: 0.25.8 - resolution: "@esbuild/netbsd-arm64@npm:0.25.8" - conditions: os=netbsd & cpu=arm64 - languageName: node - linkType: hard - -"@esbuild/netbsd-x64@npm:0.18.20": - version: 0.18.20 - resolution: "@esbuild/netbsd-x64@npm:0.18.20" - conditions: os=netbsd & cpu=x64 - languageName: node - linkType: hard - "@esbuild/netbsd-x64@npm:0.25.10": version: 0.25.10 resolution: "@esbuild/netbsd-x64@npm:0.25.10" @@ -3923,13 +3678,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/netbsd-x64@npm:0.25.8": - version: 0.25.8 - resolution: "@esbuild/netbsd-x64@npm:0.25.8" - conditions: os=netbsd & cpu=x64 - languageName: node - linkType: hard - "@esbuild/openbsd-arm64@npm:0.25.10": version: 0.25.10 resolution: "@esbuild/openbsd-arm64@npm:0.25.10" @@ -3937,20 +3685,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/openbsd-arm64@npm:0.25.8": - version: 0.25.8 - resolution: "@esbuild/openbsd-arm64@npm:0.25.8" - conditions: os=openbsd & cpu=arm64 - languageName: node - linkType: hard - -"@esbuild/openbsd-x64@npm:0.18.20": - version: 0.18.20 - resolution: "@esbuild/openbsd-x64@npm:0.18.20" - conditions: os=openbsd & cpu=x64 - languageName: node - linkType: hard - "@esbuild/openbsd-x64@npm:0.25.10": version: 0.25.10 resolution: "@esbuild/openbsd-x64@npm:0.25.10" @@ -3958,13 +3692,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/openbsd-x64@npm:0.25.8": - version: 0.25.8 - resolution: "@esbuild/openbsd-x64@npm:0.25.8" - conditions: os=openbsd & cpu=x64 - languageName: node - linkType: hard - "@esbuild/openharmony-arm64@npm:0.25.10": version: 0.25.10 resolution: "@esbuild/openharmony-arm64@npm:0.25.10" @@ -3972,20 +3699,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/openharmony-arm64@npm:0.25.8": - version: 0.25.8 - resolution: "@esbuild/openharmony-arm64@npm:0.25.8" - conditions: os=openharmony & cpu=arm64 - languageName: node - linkType: hard - -"@esbuild/sunos-x64@npm:0.18.20": - version: 0.18.20 - resolution: "@esbuild/sunos-x64@npm:0.18.20" - conditions: os=sunos & cpu=x64 - languageName: node - linkType: hard - "@esbuild/sunos-x64@npm:0.25.10": version: 0.25.10 resolution: "@esbuild/sunos-x64@npm:0.25.10" @@ -3993,20 +3706,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/sunos-x64@npm:0.25.8": - version: 0.25.8 - resolution: "@esbuild/sunos-x64@npm:0.25.8" - conditions: os=sunos & cpu=x64 - languageName: node - linkType: hard - -"@esbuild/win32-arm64@npm:0.18.20": - version: 0.18.20 - resolution: "@esbuild/win32-arm64@npm:0.18.20" - conditions: os=win32 & cpu=arm64 - languageName: node - linkType: hard - "@esbuild/win32-arm64@npm:0.25.10": version: 0.25.10 resolution: "@esbuild/win32-arm64@npm:0.25.10" @@ -4014,20 +3713,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/win32-arm64@npm:0.25.8": - version: 0.25.8 - resolution: "@esbuild/win32-arm64@npm:0.25.8" - conditions: os=win32 & cpu=arm64 - languageName: node - linkType: hard - -"@esbuild/win32-ia32@npm:0.18.20": - version: 0.18.20 - resolution: "@esbuild/win32-ia32@npm:0.18.20" - conditions: os=win32 & cpu=ia32 - languageName: node - linkType: hard - "@esbuild/win32-ia32@npm:0.25.10": version: 0.25.10 resolution: "@esbuild/win32-ia32@npm:0.25.10" @@ -4035,20 +3720,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/win32-ia32@npm:0.25.8": - version: 0.25.8 - resolution: "@esbuild/win32-ia32@npm:0.25.8" - conditions: os=win32 & cpu=ia32 - languageName: node - linkType: hard - -"@esbuild/win32-x64@npm:0.18.20": - version: 0.18.20 - resolution: "@esbuild/win32-x64@npm:0.18.20" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - "@esbuild/win32-x64@npm:0.25.10": version: 0.25.10 resolution: "@esbuild/win32-x64@npm:0.25.10" @@ -4056,13 +3727,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/win32-x64@npm:0.25.8": - version: 0.25.8 - resolution: "@esbuild/win32-x64@npm:0.25.8" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - "@eslint-community/eslint-utils@npm:^4.2.0, @eslint-community/eslint-utils@npm:^4.4.0": version: 4.6.0 resolution: "@eslint-community/eslint-utils@npm:4.6.0" @@ -18302,7 +17966,7 @@ __metadata: languageName: node linkType: hard -"esbuild@npm:^0.25.4": +"esbuild@npm:^0.25.0": version: 0.25.10 resolution: "esbuild@npm:0.25.10" dependencies: @@ -18391,172 +18055,6 @@ __metadata: languageName: node linkType: hard -"esbuild@npm:^0.25.5, esbuild@npm:~0.25.0": - version: 0.25.8 - resolution: "esbuild@npm:0.25.8" - dependencies: - "@esbuild/aix-ppc64": "npm:0.25.8" - "@esbuild/android-arm": "npm:0.25.8" - "@esbuild/android-arm64": "npm:0.25.8" - "@esbuild/android-x64": "npm:0.25.8" - "@esbuild/darwin-arm64": "npm:0.25.8" - "@esbuild/darwin-x64": "npm:0.25.8" - "@esbuild/freebsd-arm64": "npm:0.25.8" - "@esbuild/freebsd-x64": "npm:0.25.8" - "@esbuild/linux-arm": "npm:0.25.8" - "@esbuild/linux-arm64": "npm:0.25.8" - "@esbuild/linux-ia32": "npm:0.25.8" - "@esbuild/linux-loong64": "npm:0.25.8" - "@esbuild/linux-mips64el": "npm:0.25.8" - "@esbuild/linux-ppc64": "npm:0.25.8" - "@esbuild/linux-riscv64": "npm:0.25.8" - "@esbuild/linux-s390x": "npm:0.25.8" - "@esbuild/linux-x64": "npm:0.25.8" - "@esbuild/netbsd-arm64": "npm:0.25.8" - "@esbuild/netbsd-x64": "npm:0.25.8" - "@esbuild/openbsd-arm64": "npm:0.25.8" - "@esbuild/openbsd-x64": "npm:0.25.8" - "@esbuild/openharmony-arm64": "npm:0.25.8" - "@esbuild/sunos-x64": "npm:0.25.8" - "@esbuild/win32-arm64": "npm:0.25.8" - "@esbuild/win32-ia32": "npm:0.25.8" - "@esbuild/win32-x64": "npm:0.25.8" - dependenciesMeta: - "@esbuild/aix-ppc64": - optional: true - "@esbuild/android-arm": - optional: true - "@esbuild/android-arm64": - optional: true - "@esbuild/android-x64": - optional: true - "@esbuild/darwin-arm64": - optional: true - "@esbuild/darwin-x64": - optional: true - "@esbuild/freebsd-arm64": - optional: true - "@esbuild/freebsd-x64": - optional: true - "@esbuild/linux-arm": - optional: true - "@esbuild/linux-arm64": - optional: true - "@esbuild/linux-ia32": - optional: true - "@esbuild/linux-loong64": - optional: true - "@esbuild/linux-mips64el": - optional: true - "@esbuild/linux-ppc64": - optional: true - "@esbuild/linux-riscv64": - optional: true - "@esbuild/linux-s390x": - optional: true - "@esbuild/linux-x64": - optional: true - "@esbuild/netbsd-arm64": - optional: true - "@esbuild/netbsd-x64": - optional: true - "@esbuild/openbsd-arm64": - optional: true - "@esbuild/openbsd-x64": - optional: true - "@esbuild/openharmony-arm64": - optional: true - "@esbuild/sunos-x64": - optional: true - "@esbuild/win32-arm64": - optional: true - "@esbuild/win32-ia32": - optional: true - "@esbuild/win32-x64": - optional: true - bin: - esbuild: bin/esbuild - checksum: 10c0/43747a25e120d5dd9ce75c82f57306580d715647c8db4f4a0a84e73b04cf16c27572d3937d3cfb95d5ac3266a4d1bbd3913e3d76ae719693516289fc86f8a5fd - languageName: node - linkType: hard - -"esbuild@npm:~0.18.20": - version: 0.18.20 - resolution: "esbuild@npm:0.18.20" - dependencies: - "@esbuild/android-arm": "npm:0.18.20" - "@esbuild/android-arm64": "npm:0.18.20" - "@esbuild/android-x64": "npm:0.18.20" - "@esbuild/darwin-arm64": "npm:0.18.20" - "@esbuild/darwin-x64": "npm:0.18.20" - "@esbuild/freebsd-arm64": "npm:0.18.20" - "@esbuild/freebsd-x64": "npm:0.18.20" - "@esbuild/linux-arm": "npm:0.18.20" - "@esbuild/linux-arm64": "npm:0.18.20" - "@esbuild/linux-ia32": "npm:0.18.20" - "@esbuild/linux-loong64": "npm:0.18.20" - "@esbuild/linux-mips64el": "npm:0.18.20" - "@esbuild/linux-ppc64": "npm:0.18.20" - "@esbuild/linux-riscv64": "npm:0.18.20" - "@esbuild/linux-s390x": "npm:0.18.20" - "@esbuild/linux-x64": "npm:0.18.20" - "@esbuild/netbsd-x64": "npm:0.18.20" - "@esbuild/openbsd-x64": "npm:0.18.20" - "@esbuild/sunos-x64": "npm:0.18.20" - "@esbuild/win32-arm64": "npm:0.18.20" - "@esbuild/win32-ia32": "npm:0.18.20" - "@esbuild/win32-x64": "npm:0.18.20" - dependenciesMeta: - "@esbuild/android-arm": - optional: true - "@esbuild/android-arm64": - optional: true - "@esbuild/android-x64": - optional: true - "@esbuild/darwin-arm64": - optional: true - "@esbuild/darwin-x64": - optional: true - "@esbuild/freebsd-arm64": - optional: true - "@esbuild/freebsd-x64": - optional: true - "@esbuild/linux-arm": - optional: true - "@esbuild/linux-arm64": - optional: true - "@esbuild/linux-ia32": - optional: true - "@esbuild/linux-loong64": - optional: true - "@esbuild/linux-mips64el": - optional: true - "@esbuild/linux-ppc64": - optional: true - "@esbuild/linux-riscv64": - optional: true - "@esbuild/linux-s390x": - optional: true - "@esbuild/linux-x64": - optional: true - "@esbuild/netbsd-x64": - optional: true - "@esbuild/openbsd-x64": - optional: true - "@esbuild/sunos-x64": - optional: true - "@esbuild/win32-arm64": - optional: true - "@esbuild/win32-ia32": - optional: true - "@esbuild/win32-x64": - optional: true - bin: - esbuild: bin/esbuild - checksum: 10c0/473b1d92842f50a303cf948a11ebd5f69581cd254d599dd9d62f9989858e0533f64e83b723b5e1398a5b488c0f5fd088795b4235f65ecaf4f007d4b79f04bc88 - languageName: node - linkType: hard - "escalade@npm:^3.1.1, escalade@npm:^3.2.0": version: 3.2.0 resolution: "escalade@npm:3.2.0" @@ -28588,15 +28086,15 @@ __metadata: languageName: node linkType: hard -"tar-fs@npm:^2.0.0": - version: 2.1.3 - resolution: "tar-fs@npm:2.1.3" +"tar-fs@npm:^2.1.4": + version: 2.1.4 + resolution: "tar-fs@npm:2.1.4" dependencies: chownr: "npm:^1.1.1" mkdirp-classic: "npm:^0.5.2" pump: "npm:^3.0.0" tar-stream: "npm:^2.1.4" - checksum: 10c0/472ee0c3c862605165163113ab6924f411c07506a1fb24c51a1a80085f0d4d381d86d2fd6b189236c8d932d1cd97b69cce35016767ceb658a35f7584fe77f305 + checksum: 10c0/decb25acdc6839182c06ec83cba6136205bda1db984e120c8ffd0d80182bc5baa1d916f9b6c5c663ea3f9975b4dd49e3c6bb7b1707cbcdaba4e76042f43ec84c languageName: node linkType: hard From 0d760ffa2eff1e9029ec0ae1dc7a9bdb2b05be8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=A2=E5=A5=8B=E7=8C=AB?= Date: Mon, 13 Oct 2025 22:34:27 +0800 Subject: [PATCH 05/39] feat: add AgentSettingsTab component and integrate into HomeTabs (#10668) - Introduced the AgentSettingsTab component for managing agent settings. - Integrated AgentSettingsTab into HomeTabs, allowing access to agent settings based on the active session or topic. - Updated AgentEssentialSettings to conditionally render the ModelSetting based on props. - Adjusted styles in various components for consistency and improved layout. --- .../src/pages/home/Tabs/AgentSettingsTab.tsx | 40 +++++++++++++++++++ .../src/pages/home/Tabs/AssistantsTab.tsx | 4 +- .../pages/home/Tabs/components/AddButton.tsx | 2 +- .../pages/home/Tabs/components/AgentItem.tsx | 11 +---- src/renderer/src/pages/home/Tabs/index.tsx | 34 +++++++--------- .../AgentSettings/AccessibleDirsSetting.tsx | 4 +- .../AgentSettings/AgentEssentialSettings.tsx | 5 ++- .../settings/AgentSettings/NameSetting.tsx | 1 + 8 files changed, 66 insertions(+), 35 deletions(-) create mode 100644 src/renderer/src/pages/home/Tabs/AgentSettingsTab.tsx diff --git a/src/renderer/src/pages/home/Tabs/AgentSettingsTab.tsx b/src/renderer/src/pages/home/Tabs/AgentSettingsTab.tsx new file mode 100644 index 000000000..7aab18816 --- /dev/null +++ b/src/renderer/src/pages/home/Tabs/AgentSettingsTab.tsx @@ -0,0 +1,40 @@ +import { Button, Divider } from '@heroui/react' +import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent' +import { AgentSettingsPopup } from '@renderer/pages/settings/AgentSettings' +import AdvancedSettings from '@renderer/pages/settings/AgentSettings/AdvancedSettings' +import AgentEssentialSettings from '@renderer/pages/settings/AgentSettings/AgentEssentialSettings' +import { GetAgentResponse } from '@renderer/types/agent' +import { FC } from 'react' +import { useTranslation } from 'react-i18next' + +interface Props { + agent: GetAgentResponse | undefined | null + update: ReturnType['updateAgent'] +} + +const AgentSettingsTab: FC = ({ agent, update }) => { + const { t } = useTranslation() + + const onMoreSetting = () => { + if (agent?.id) { + AgentSettingsPopup.show({ agentId: agent.id! }) + } + } + + if (!agent) { + return null + } + + return ( +
+ + + + +
+ ) +} + +export default AgentSettingsTab diff --git a/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx b/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx index 3d871d76e..2eaa2ef1e 100644 --- a/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx @@ -116,8 +116,6 @@ const AssistantsTab: FC = (props) => { /> )} - - {agentsLoading && } {apiServer.enabled && agentsError && } @@ -165,6 +163,8 @@ const AssistantsTab: FC = (props) => { /> )} + + {!dragging &&
} ) diff --git a/src/renderer/src/pages/home/Tabs/components/AddButton.tsx b/src/renderer/src/pages/home/Tabs/components/AddButton.tsx index 1195710ae..99868861b 100644 --- a/src/renderer/src/pages/home/Tabs/components/AddButton.tsx +++ b/src/renderer/src/pages/home/Tabs/components/AddButton.tsx @@ -12,7 +12,7 @@ const AddButton: FC = ({ children, className, ...props }) => { {...props} onPress={props.onPress} className={cn( - 'h-9 w-[calc(var(--assistants-width)-20px)] justify-start rounded-full bg-transparent px-3 text-[13px] text-[var(--color-text-2)] hover:bg-[var(--color-list-item)]', + 'h-9 w-[calc(var(--assistants-width)-20px)] justify-start rounded-lg bg-transparent px-3 text-[13px] text-[var(--color-text-2)] hover:bg-[var(--color-list-item)]', className )} startContent={}> diff --git a/src/renderer/src/pages/home/Tabs/components/AgentItem.tsx b/src/renderer/src/pages/home/Tabs/components/AgentItem.tsx index 80c4b6db9..f31242080 100644 --- a/src/renderer/src/pages/home/Tabs/components/AgentItem.tsx +++ b/src/renderer/src/pages/home/Tabs/components/AgentItem.tsx @@ -45,19 +45,12 @@ const AgentItem: FC = ({ agent, isActive, onDelete, onPress }) = - {isActive ? {sessions.length} : } + {isActive ? {sessions.length} : } - { - // onOpen() - await AgentSettingsPopup.show({ - agentId: agent.id - }) - }}> + AgentSettingsPopup.show({ agentId: agent.id })}> {t('common.edit')} diff --git a/src/renderer/src/pages/home/Tabs/index.tsx b/src/renderer/src/pages/home/Tabs/index.tsx index f1adf9e4f..1e2559c08 100644 --- a/src/renderer/src/pages/home/Tabs/index.tsx +++ b/src/renderer/src/pages/home/Tabs/index.tsx @@ -1,4 +1,6 @@ import AddAssistantPopup from '@renderer/components/Popups/AddAssistantPopup' +import { useAgent } from '@renderer/hooks/agents/useAgent' +import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent' import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant' import { useRuntime } from '@renderer/hooks/useRuntime' import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings' @@ -11,6 +13,7 @@ import { FC, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' +import AgentSettingsTab from './AgentSettingsTab' import Assistants from './AssistantsTab' import Settings from './SettingsTab' import Topics from './TopicsTab' @@ -41,11 +44,12 @@ const HomeTabs: FC = ({ const { defaultAssistant } = useDefaultAssistant() const { toggleShowTopics } = useShowTopics() const { isLeftNavbar } = useNavbarPosition() - const { t } = useTranslation() - const { chat } = useRuntime() - const { activeTopicOrSession } = chat + const { activeTopicOrSession, activeAgentId } = chat + const { agent } = useAgent(activeAgentId) + const { updateAgent } = useUpdateAgent() + const isSessionView = activeTopicOrSession === 'session' const isTopicView = activeTopicOrSession === 'topic' @@ -61,7 +65,6 @@ const HomeTabs: FC = ({ } const showTab = position === 'left' && topicPosition === 'left' - const shouldShowSettingsTab = !isSessionView const onCreateAssistant = async () => { const assistant = await AddAssistantPopup.show() @@ -104,12 +107,6 @@ const HomeTabs: FC = ({ } }, [position, tab, topicPosition, forceToSeeAllTab]) - useEffect(() => { - if (activeTopicOrSession === 'session' && tab === 'settings') { - setTab('topic') - } - }, [activeTopicOrSession, tab]) - return ( = ({ {t('assistants.abbr')} setTab('topic')}> - {isTopicView ? t('common.topics') : t('agent.session.label_other')} + {t('common.topics')} + + setTab('settings')}> + {t('settings.title')} - {shouldShowSettingsTab && ( - setTab('settings')}> - {t('settings.title')} - - )} )} @@ -158,7 +153,8 @@ const HomeTabs: FC = ({ position={position} /> )} - {tab === 'settings' && shouldShowSettingsTab && } + {tab === 'settings' && isTopicView && } + {tab === 'settings' && isSessionView && } ) @@ -214,7 +210,7 @@ const CustomTabs = styled.div` const TabItem = styled.button<{ active: boolean }>` flex: 1; - height: 32px; + height: 30px; border: none; background: transparent; color: ${(props) => (props.active ? 'var(--color-text)' : 'var(--color-text-secondary)')}; @@ -239,7 +235,7 @@ const TabItem = styled.button<{ active: boolean }>` &::after { content: ''; position: absolute; - bottom: -9px; + bottom: -8px; left: 50%; transform: translateX(-50%); width: ${(props) => (props.active ? '30px' : '0')}; diff --git a/src/renderer/src/pages/settings/AgentSettings/AccessibleDirsSetting.tsx b/src/renderer/src/pages/settings/AgentSettings/AccessibleDirsSetting.tsx index 2a19b5441..ad228d227 100644 --- a/src/renderer/src/pages/settings/AgentSettings/AccessibleDirsSetting.tsx +++ b/src/renderer/src/pages/settings/AgentSettings/AccessibleDirsSetting.tsx @@ -71,11 +71,11 @@ export const AccessibleDirsSetting: React.FC = ({ ba }> {t('agent.session.accessible_paths.label')} -
    +
      {base.accessible_paths.map((path) => (
    • + className="flex items-center justify-between gap-2 rounded-medium border border-default-200 px-2 py-1"> {path} diff --git a/src/renderer/src/pages/settings/AgentSettings/AgentEssentialSettings.tsx b/src/renderer/src/pages/settings/AgentSettings/AgentEssentialSettings.tsx index af9dcfd75..e0329404d 100644 --- a/src/renderer/src/pages/settings/AgentSettings/AgentEssentialSettings.tsx +++ b/src/renderer/src/pages/settings/AgentSettings/AgentEssentialSettings.tsx @@ -18,9 +18,10 @@ import { SettingsContainer, SettingsItem, SettingsTitle } from './shared' interface AgentEssentialSettingsProps { agent: GetAgentResponse | undefined | null update: ReturnType['updateAgent'] + showModelSetting?: boolean } -const AgentEssentialSettings: FC = ({ agent, update }) => { +const AgentEssentialSettings: FC = ({ agent, update, showModelSetting = true }) => { const { t } = useTranslation() if (!agent) return null @@ -36,7 +37,7 @@ const AgentEssentialSettings: FC = ({ agent, update - + {showModelSetting && } diff --git a/src/renderer/src/pages/settings/AgentSettings/NameSetting.tsx b/src/renderer/src/pages/settings/AgentSettings/NameSetting.tsx index 364e464b5..26346bc9a 100644 --- a/src/renderer/src/pages/settings/AgentSettings/NameSetting.tsx +++ b/src/renderer/src/pages/settings/AgentSettings/NameSetting.tsx @@ -25,6 +25,7 @@ export const NameSetting: React.FC = ({ base, update }) => { setName(value)} onBlur={() => { if (name !== base.name) { From 80e1784777a674e184d4181d9094e958f5c14da7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?George=C2=B7Dong?= <98630204+GeorgeDong32@users.noreply.github.com> Date: Tue, 14 Oct 2025 01:23:34 +0800 Subject: [PATCH 06/39] chore(ci): switch Claude action to custom endpoint (#10701) --- .github/workflows/claude-translator.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/claude-translator.yml b/.github/workflows/claude-translator.yml index 356e10d0f..23f359021 100644 --- a/.github/workflows/claude-translator.yml +++ b/.github/workflows/claude-translator.yml @@ -45,7 +45,7 @@ jobs: # See: https://github.com/anthropics/claude-code-action/blob/main/docs/security.md github_token: ${{ secrets.TOKEN_GITHUB_WRITE }} allowed_non_write_users: "*" - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + anthropic_api_key: ${{ secrets.CLAUDE_TRANSLATOR_APIKEY }} claude_args: "--allowed-tools Bash(gh issue:*),Bash(gh api:repos/*/issues:*),Bash(gh api:repos/*/pulls/*/reviews/*),Bash(gh api:repos/*/pulls/comments/*)" prompt: | 你是一个多语言翻译助手。你需要响应 GitHub Webhooks 中的以下四种事件: @@ -108,3 +108,5 @@ jobs: 使用以下命令获取完整信息: gh issue view ${{ github.event.issue.number }} --json title,body,comments + env: + ANTHROPIC_BASE_URL: ${{ secrets.CLAUDE_TRANSLATOR_BASEURL }} From 866e8e8734d8fba81e7963cb9bbf343566db8ad8 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Tue, 14 Oct 2025 14:04:57 +0800 Subject: [PATCH 07/39] fix: guard webview search against destroyed webviews (#10704) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🐛 fix: guard webview search against destroyed webviews * delete code * delete code --- .../minapps/components/WebviewSearch.tsx | 83 +++++++++++++++---- .../__tests__/WebviewSearch.test.tsx | 55 ++++++++++++ 2 files changed, 120 insertions(+), 18 deletions(-) diff --git a/src/renderer/src/pages/minapps/components/WebviewSearch.tsx b/src/renderer/src/pages/minapps/components/WebviewSearch.tsx index bff5605c1..a5340ee2c 100644 --- a/src/renderer/src/pages/minapps/components/WebviewSearch.tsx +++ b/src/renderer/src/pages/minapps/components/WebviewSearch.tsx @@ -46,15 +46,58 @@ const WebviewSearch: FC = ({ webviewRef, isWebviewReady, app setActiveIndex(0) }, []) - const stopSearch = useCallback(() => { - const target = webviewRef.current ?? attachedWebviewRef.current - if (!target) return - try { - target.stopFindInPage('clearSelection') - } catch (error) { - logger.error('stopFindInPage failed', { error }) + const ensureWebviewReady = useCallback( + (candidate: WebviewTag | null) => { + if (!candidate) return null + try { + const webContentsId = candidate.getWebContentsId?.() + if (!webContentsId) { + logger.debug('WebviewSearch: missing webContentsId before action', { appId }) + return null + } + } catch (error) { + logger.debug('WebviewSearch: getWebContentsId failed before action', { appId, error }) + return null + } + + return candidate + }, + [appId] + ) + + const stopFindOnWebview = useCallback( + (webview: WebviewTag | null) => { + const usable = ensureWebviewReady(webview) + if (!usable) return false + try { + usable.stopFindInPage('clearSelection') + return true + } catch (error) { + logger.debug('stopFindInPage failed', { appId, error }) + return false + } + }, + [appId, ensureWebviewReady] + ) + + const getUsableWebview = useCallback(() => { + const candidates = [webviewRef.current, attachedWebviewRef.current] + + for (const candidate of candidates) { + const usable = ensureWebviewReady(candidate) + if (usable) { + return usable + } } - }, [webviewRef]) + + return null + }, [ensureWebviewReady, webviewRef]) + + const stopSearch = useCallback(() => { + const target = getUsableWebview() + if (!target) return + stopFindOnWebview(target) + }, [getUsableWebview, stopFindOnWebview]) const closeSearch = useCallback(() => { setIsVisible(false) @@ -64,7 +107,7 @@ const WebviewSearch: FC = ({ webviewRef, isWebviewReady, app const performSearch = useCallback( (text: string, options?: Electron.FindInPageOptions) => { - const target = webviewRef.current ?? attachedWebviewRef.current + const target = getUsableWebview() if (!target) { logger.debug('Skip performSearch: webview not attached') return @@ -81,7 +124,7 @@ const WebviewSearch: FC = ({ webviewRef, isWebviewReady, app window.toast?.error(t('common.error')) } }, - [resetSearchState, stopSearch, t, webviewRef] + [getUsableWebview, resetSearchState, stopSearch, t] ) const handleFoundInPage = useCallback((event: Event & { result?: FoundInPageResult }) => { @@ -129,22 +172,26 @@ const WebviewSearch: FC = ({ webviewRef, isWebviewReady, app return () => { activeWebview.removeEventListener('found-in-page', handle) if (attachedWebviewRef.current === activeWebview) { - try { - activeWebview.stopFindInPage('clearSelection') - } catch (error) { - logger.error('stopFindInPage failed', { error }) - } + stopFindOnWebview(activeWebview) attachedWebviewRef.current = null } } - }, [activeWebview, handleFoundInPage]) + }, [activeWebview, handleFoundInPage, stopFindOnWebview]) useEffect(() => { if (!activeWebview) return + if (!isWebviewReady) return const onFindShortcut = window.api?.webview?.onFindShortcut if (!onFindShortcut) return - const webContentsId = activeWebview.getWebContentsId?.() + let webContentsId: number | undefined + try { + webContentsId = activeWebview.getWebContentsId?.() + } catch (error) { + logger.debug('WebviewSearch: getWebContentsId failed', { appId, error }) + return + } + if (!webContentsId) { logger.warn('WebviewSearch: missing webContentsId', { appId }) return @@ -177,7 +224,7 @@ const WebviewSearch: FC = ({ webviewRef, isWebviewReady, app return () => { unsubscribe?.() } - }, [appId, activeWebview, closeSearch, goToNext, goToPrevious, isVisible, openSearch]) + }, [appId, activeWebview, closeSearch, goToNext, goToPrevious, isVisible, isWebviewReady, openSearch]) useEffect(() => { if (!isVisible) return diff --git a/src/renderer/src/pages/minapps/components/__tests__/WebviewSearch.test.tsx b/src/renderer/src/pages/minapps/components/__tests__/WebviewSearch.test.tsx index 50e8c60ca..4deee62ad 100644 --- a/src/renderer/src/pages/minapps/components/__tests__/WebviewSearch.test.tsx +++ b/src/renderer/src/pages/minapps/components/__tests__/WebviewSearch.test.tsx @@ -164,6 +164,61 @@ describe('WebviewSearch', () => { }) }) + it('skips shortcut wiring when getWebContentsId throws', async () => { + const { webview } = createWebviewMock() + const error = new Error('not ready') + ;(webview as any).getWebContentsId = vi.fn(() => { + throw error + }) + const webviewRef = { current: webview } as React.RefObject + + const getWebContentsIdMock = vi.fn(() => { + throw error + }) + ;(webview as any).getWebContentsId = getWebContentsIdMock + const { rerender } = render() + + await waitFor(() => { + expect(getWebContentsIdMock).toHaveBeenCalled() + }) + expect(onFindShortcutMock).not.toHaveBeenCalled() + + ;(webview as any).getWebContentsId = vi.fn(() => 1) + + rerender() + rerender() + + await waitFor(() => { + expect(onFindShortcutMock).toHaveBeenCalled() + }) + }) + + it('does not call stopFindInPage when webview is not ready', async () => { + const { stopFindInPageMock, webview } = createWebviewMock() + const error = new Error('loading') + const getWebContentsIdMock = vi.fn(() => { + throw error + }) + ;(webview as any).getWebContentsId = getWebContentsIdMock + const webviewRef = { current: webview } as React.RefObject + + const { rerender, unmount } = render() + + await waitFor(() => { + expect(getWebContentsIdMock).toHaveBeenCalled() + }) + + stopFindInPageMock.mockImplementation(() => { + throw new Error('should not be called') + }) + + rerender() + expect(stopFindInPageMock).not.toHaveBeenCalled() + + unmount() + expect(stopFindInPageMock).not.toHaveBeenCalled() + }) + it('closes the search overlay when escape is forwarded from the webview', async () => { const { webview } = createWebviewMock() const webviewRef = { current: webview } as React.RefObject From 7cf57adceb0520415cb7bae8a95d80b3948c4afd Mon Sep 17 00:00:00 2001 From: Kejiang Ma Date: Tue, 14 Oct 2025 20:01:56 +0800 Subject: [PATCH 08/39] feat: new middleware to add 'no_think' (#10675) * new middleware to add 'no_think' Signed-off-by: Kejiang Ma * translate comments to English Signed-off-by: Kejiang Ma --------- Signed-off-by: Kejiang Ma --- .../middleware/AiSdkMiddlewareBuilder.ts | 10 ++++ .../aiCore/middleware/noThinkMiddleware.ts | 52 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 src/renderer/src/aiCore/middleware/noThinkMiddleware.ts diff --git a/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts b/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts index 20b89cf2e..6034a8858 100644 --- a/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts +++ b/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts @@ -4,6 +4,8 @@ import type { MCPTool, Message, Model, Provider } from '@renderer/types' import type { Chunk } from '@renderer/types/chunk' import { extractReasoningMiddleware, LanguageModelMiddleware, simulateStreamingMiddleware } from 'ai' +import { noThinkMiddleware } from './noThinkMiddleware' + const logger = loggerService.withContext('AiSdkMiddlewareBuilder') /** @@ -186,6 +188,14 @@ function addProviderSpecificMiddlewares(builder: AiSdkMiddlewareBuilder, config: // 其他provider的通用处理 break } + + // OVMS+MCP's specific middleware + if (config.provider.id === 'ovms' && config.mcpTools && config.mcpTools.length > 0) { + builder.add({ + name: 'no-think', + middleware: noThinkMiddleware() + }) + } } /** diff --git a/src/renderer/src/aiCore/middleware/noThinkMiddleware.ts b/src/renderer/src/aiCore/middleware/noThinkMiddleware.ts new file mode 100644 index 000000000..9d7d933bc --- /dev/null +++ b/src/renderer/src/aiCore/middleware/noThinkMiddleware.ts @@ -0,0 +1,52 @@ +import { loggerService } from '@logger' +import { LanguageModelMiddleware } from 'ai' + +const logger = loggerService.withContext('noThinkMiddleware') + +/** + * No Think Middleware + * Automatically appends ' /no_think' string to the end of user messages for the provider + * This prevents the model from generating unnecessary thinking process and returns results directly + * @returns LanguageModelMiddleware + */ +export function noThinkMiddleware(): LanguageModelMiddleware { + return { + middlewareVersion: 'v2', + + transformParams: async ({ params }) => { + const transformedParams = { ...params } + // Process messages in prompt + if (transformedParams.prompt && Array.isArray(transformedParams.prompt)) { + transformedParams.prompt = transformedParams.prompt.map((message) => { + // Only process user messages + if (message.role === 'user') { + // Process content array + if (Array.isArray(message.content)) { + const lastContent = message.content[message.content.length - 1] + // If the last content is text type, append ' /no_think' + if (lastContent && lastContent.type === 'text' && typeof lastContent.text === 'string') { + // Avoid duplicate additions + if (!lastContent.text.endsWith('/no_think')) { + logger.debug('Adding /no_think to user message') + return { + ...message, + content: [ + ...message.content.slice(0, -1), + { + ...lastContent, + text: lastContent.text + ' /no_think' + } + ] + } + } + } + } + } + return message + }) + } + + return transformedParams + } + } +} From 004d6d8201efe1447b691dce2533303b1756f5aa Mon Sep 17 00:00:00 2001 From: defi-failure <159208748+defi-failure@users.noreply.github.com> Date: Tue, 14 Oct 2025 20:56:22 +0800 Subject: [PATCH 09/39] fix: move newly created agent session to top (#10711) --- src/renderer/src/hooks/agents/useSessions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/hooks/agents/useSessions.ts b/src/renderer/src/hooks/agents/useSessions.ts index 3307d0353..fce29b6bf 100644 --- a/src/renderer/src/hooks/agents/useSessions.ts +++ b/src/renderer/src/hooks/agents/useSessions.ts @@ -21,7 +21,7 @@ export const useSessions = (agentId: string) => { async (form: CreateSessionForm) => { try { const result = await client.createSession(agentId, form) - await mutate((prev) => [...(prev ?? []), result], { revalidate: false }) + await mutate((prev) => [result, ...(prev ?? [])], { revalidate: false }) return result } catch (error) { window.toast.error(formatErrorMessageWithPrefix(error, t('agent.session.create.error.failed'))) From 7b3b73d390d665582cc2cdfab1fd673f56c4b7bd Mon Sep 17 00:00:00 2001 From: defi-failure <159208748+defi-failure@users.noreply.github.com> Date: Tue, 14 Oct 2025 20:59:07 +0800 Subject: [PATCH 10/39] fix: make anthropic model provided by cherryin visible to agent (#10695) --- src/main/apiServer/services/models.ts | 14 ++++++++++--- src/main/apiServer/utils/index.ts | 11 +++++++--- .../agents/services/claudecode/index.ts | 20 ++++++++++++++----- .../components/Popups/agent/AgentModal.tsx | 2 +- src/renderer/src/types/apiModels.ts | 1 + src/renderer/src/utils/agentSession.ts | 2 +- 6 files changed, 37 insertions(+), 13 deletions(-) diff --git a/src/main/apiServer/services/models.ts b/src/main/apiServer/services/models.ts index 684d7f10a..6c0056b27 100644 --- a/src/main/apiServer/services/models.ts +++ b/src/main/apiServer/services/models.ts @@ -1,3 +1,5 @@ +import { isEmpty } from 'lodash' + import { ApiModel, ApiModelsFilter, ApiModelsResponse } from '../../../renderer/src/types/apiModels' import { loggerService } from '../../services/LoggerService' import { getAvailableProviders, listAllAvailableModels, transformModelToOpenAI } from '../utils' @@ -8,6 +10,10 @@ const logger = loggerService.withContext('ModelsService') export type ModelsFilter = ApiModelsFilter +const isAnthropicProvider = (provider: { type: string; anthropicApiHost?: string }) => { + return provider.type === 'anthropic' || !isEmpty(provider.anthropicApiHost?.trim()) +} + export class ModelsService { async getModels(filter: ModelsFilter): Promise { try { @@ -16,9 +22,7 @@ export class ModelsService { let providers = await getAvailableProviders() if (filter.providerType === 'anthropic') { - providers = providers.filter( - (p) => p.type === 'anthropic' || (p.anthropicApiHost !== undefined && p.anthropicApiHost.trim() !== '') - ) + providers = providers.filter(isAnthropicProvider) } const models = await listAllAvailableModels(providers) @@ -41,6 +45,10 @@ export class ModelsService { continue } + if (filter.supportAnthropic && model.endpoint_type !== 'anthropic' && !isAnthropicProvider(provider)) { + continue + } + const openAIModel = transformModelToOpenAI(model, provider) const fullModelId = openAIModel.id // This is already in format "provider:model_id" diff --git a/src/main/apiServer/utils/index.ts b/src/main/apiServer/utils/index.ts index bd80c73f5..865f961db 100644 --- a/src/main/apiServer/utils/index.ts +++ b/src/main/apiServer/utils/index.ts @@ -1,7 +1,7 @@ import { CacheService } from '@main/services/CacheService' import { loggerService } from '@main/services/LoggerService' import { reduxService } from '@main/services/ReduxService' -import { ApiModel, Model, Provider } from '@types' +import { ApiModel, EndpointType, Model, Provider } from '@types' const logger = loggerService.withContext('ApiServerUtils') @@ -114,6 +114,7 @@ export async function validateModelId(model: string): Promise<{ error?: ModelValidationError provider?: Provider modelId?: string + modelEndpointType?: EndpointType }> { try { if (!model || typeof model !== 'string') { @@ -166,7 +167,8 @@ export async function validateModelId(model: string): Promise<{ } // Check if model exists in provider - const modelExists = provider.models?.some((m) => m.id === modelId) + const modelInProvider = provider.models?.find((m) => m.id === modelId) + const modelExists = !!modelInProvider if (!modelExists) { const availableModels = provider.models?.map((m) => m.id).join(', ') || 'none' return { @@ -179,10 +181,13 @@ export async function validateModelId(model: string): Promise<{ } } + const modelEndpointType = modelInProvider?.endpoint_type + return { valid: true, provider, - modelId + modelId, + modelEndpointType } } catch (error: any) { logger.error('Error validating model ID', { error, model }) diff --git a/src/main/services/agents/services/claudecode/index.ts b/src/main/services/agents/services/claudecode/index.ts index 7b2f119af..7dae2f9e9 100644 --- a/src/main/services/agents/services/claudecode/index.ts +++ b/src/main/services/agents/services/claudecode/index.ts @@ -8,6 +8,7 @@ import { config as apiConfigService } from '@main/apiServer/config' import { validateModelId } from '@main/apiServer/utils' import getLoginShellEnvironment from '@main/utils/shell-env' import { app } from 'electron' +import { isEmpty } from 'lodash' import { GetAgentSessionResponse } from '../..' import { AgentServiceInterface, AgentStream, AgentStreamEvent } from '../../interfaces/AgentStreamInterface' @@ -60,11 +61,20 @@ class ClaudeCodeService implements AgentServiceInterface { }) return aiStream } - if ( - (modelInfo.provider?.type !== 'anthropic' && - (modelInfo.provider?.anthropicApiHost === undefined || modelInfo.provider.anthropicApiHost.trim() === '')) || - modelInfo.provider.apiKey === '' - ) { + + const validateModelInfo: (m: typeof modelInfo) => boolean = (m) => { + const { provider, modelEndpointType } = m + if (!provider) return false + if (isEmpty(provider.apiKey?.trim())) return false + + const isAnthropicType = provider.type === 'anthropic' + const isAnthropicEndpoint = modelEndpointType === 'anthropic' + const hasValidApiHost = !isEmpty(provider.anthropicApiHost?.trim()) + + return !(!isAnthropicType && !isAnthropicEndpoint && !hasValidApiHost) + } + + if (!modelInfo.provider || !validateModelInfo(modelInfo)) { logger.error('Anthropic provider configuration is missing', { modelInfo }) diff --git a/src/renderer/src/components/Popups/agent/AgentModal.tsx b/src/renderer/src/components/Popups/agent/AgentModal.tsx index fa76e0e33..63b614944 100644 --- a/src/renderer/src/components/Popups/agent/AgentModal.tsx +++ b/src/renderer/src/components/Popups/agent/AgentModal.tsx @@ -100,7 +100,7 @@ export const AgentModal: React.FC = ({ agent, trigger, isOpen: _isOpen, o const { addAgent } = useAgents() const { updateAgent } = useUpdateAgent() // hard-coded. We only support anthropic for now. - const { models } = useApiModels({ providerType: 'anthropic' }) + const { models } = useApiModels({ supportAnthropic: true }) const isEditing = (agent?: AgentWithTools) => agent !== undefined const [form, setForm] = useState(() => buildAgentForm(agent)) diff --git a/src/renderer/src/types/apiModels.ts b/src/renderer/src/types/apiModels.ts index 68141bf68..7b4ec96c9 100644 --- a/src/renderer/src/types/apiModels.ts +++ b/src/renderer/src/types/apiModels.ts @@ -6,6 +6,7 @@ import { ProviderTypeSchema } from './provider' // Request schema for /v1/models export const ApiModelsFilterSchema = z.object({ providerType: ProviderTypeSchema.optional(), + supportAnthropic: z.coerce.boolean().optional(), offset: z.coerce.number().min(0).default(0).optional(), limit: z.coerce.number().min(1).default(20).optional() }) diff --git a/src/renderer/src/utils/agentSession.ts b/src/renderer/src/utils/agentSession.ts index df3441364..b2cf14d17 100644 --- a/src/renderer/src/utils/agentSession.ts +++ b/src/renderer/src/utils/agentSession.ts @@ -18,7 +18,7 @@ export const getModelFilterByAgentType = (type: AgentType): ApiModelsFilter => { switch (type) { case 'claude-code': return { - providerType: 'anthropic' + supportAnthropic: true } default: return {} From 011b6f2df16218b98102f5acf1c8b62150b0be42 Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Wed, 15 Oct 2025 13:03:12 +0800 Subject: [PATCH 11/39] build: update react and react-dom to v19.2.0 (#10710) Update dependencies to latest stable versions to benefit from bug fixes and performance improvements --- package.json | 4 ++-- yarn.lock | 32 ++++++++++++++++---------------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/package.json b/package.json index 3c5d89ffe..46e6e005e 100644 --- a/package.json +++ b/package.json @@ -303,8 +303,8 @@ "pdf-parse": "^1.1.1", "playwright": "^1.52.0", "proxy-agent": "^6.5.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", "react-error-boundary": "^6.0.0", "react-hotkeys-hook": "^4.6.1", "react-i18next": "^14.1.2", diff --git a/yarn.lock b/yarn.lock index 8befb6e5d..4fcea693b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14151,8 +14151,8 @@ __metadata: pdf-parse: "npm:^1.1.1" playwright: "npm:^1.52.0" proxy-agent: "npm:^6.5.0" - react: "npm:^19.0.0" - react-dom: "npm:^19.0.0" + react: "npm:^19.2.0" + react-dom: "npm:^19.2.0" react-error-boundary: "npm:^6.0.0" react-hotkeys-hook: "npm:^4.6.1" react-i18next: "npm:^14.1.2" @@ -25881,14 +25881,14 @@ __metadata: languageName: node linkType: hard -"react-dom@npm:^19.0.0": - version: 19.1.0 - resolution: "react-dom@npm:19.1.0" +"react-dom@npm:^19.2.0": + version: 19.2.0 + resolution: "react-dom@npm:19.2.0" dependencies: - scheduler: "npm:^0.26.0" + scheduler: "npm:^0.27.0" peerDependencies: - react: ^19.1.0 - checksum: 10c0/3e26e89bb6c67c9a6aa86cb888c7a7f8258f2e347a6d2a15299c17eb16e04c19194e3452bc3255bd34000a61e45e2cb51e46292392340432f133e5a5d2dfb5fc + react: ^19.2.0 + checksum: 10c0/fa2cae05248d01288e91523b590ce4e7635b1e13f1344e225f850d722a8da037bf0782f63b1c1d46353334e0c696909b82e582f8cad607948fde6f7646cc18d9 languageName: node linkType: hard @@ -26161,10 +26161,10 @@ __metadata: languageName: node linkType: hard -"react@npm:^19.0.0": - version: 19.1.0 - resolution: "react@npm:19.1.0" - checksum: 10c0/530fb9a62237d54137a13d2cfb67a7db6a2156faed43eecc423f4713d9b20c6f2728b026b45e28fcd72e8eadb9e9ed4b089e99f5e295d2f0ad3134251bdd3698 +"react@npm:^19.2.0": + version: 19.2.0 + resolution: "react@npm:19.2.0" + checksum: 10c0/1b6d64eacb9324725bfe1e7860cb7a6b8a34bc89a482920765ebff5c10578eb487e6b46b2f0df263bd27a25edbdae2c45e5ea5d81ae61404301c1a7192c38330 languageName: node linkType: hard @@ -27023,10 +27023,10 @@ __metadata: languageName: node linkType: hard -"scheduler@npm:^0.26.0": - version: 0.26.0 - resolution: "scheduler@npm:0.26.0" - checksum: 10c0/5b8d5bfddaae3513410eda54f2268e98a376a429931921a81b5c3a2873aab7ca4d775a8caac5498f8cbc7d0daeab947cf923dbd8e215d61671f9f4e392d34356 +"scheduler@npm:^0.27.0": + version: 0.27.0 + resolution: "scheduler@npm:0.27.0" + checksum: 10c0/4f03048cb05a3c8fddc45813052251eca00688f413a3cee236d984a161da28db28ba71bd11e7a3dd02f7af84ab28d39fb311431d3b3772fed557945beb00c452 languageName: node linkType: hard From 4028b26c1d424c4bd6064db404d034e4b9580998 Mon Sep 17 00:00:00 2001 From: defi-failure <159208748+defi-failure@users.noreply.github.com> Date: Wed, 15 Oct 2025 15:53:30 +0800 Subject: [PATCH 12/39] fix: remove agent session input trigger placeholder (#10729) --- src/renderer/src/pages/home/Inputbar/AgentSessionInputbar.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/pages/home/Inputbar/AgentSessionInputbar.tsx b/src/renderer/src/pages/home/Inputbar/AgentSessionInputbar.tsx index 320aad44a..3c87462c6 100644 --- a/src/renderer/src/pages/home/Inputbar/AgentSessionInputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/AgentSessionInputbar.tsx @@ -262,7 +262,9 @@ const AgentSessionInputbar: FC = ({ agentId, sessionId }) => { value={text} onChange={onChange} onKeyDown={handleKeyDown} - placeholder={t('chat.input.placeholder', { key: getSendMessageShortcutLabel(sendMessageShortcut) })} + placeholder={t('chat.input.placeholder_without_triggers', { + key: getSendMessageShortcutLabel(sendMessageShortcut) + })} autoFocus variant="borderless" spellCheck={enableSpellCheck} From f27a481c3c61311ff7a7288541906c6a3aaf4350 Mon Sep 17 00:00:00 2001 From: SuYao Date: Wed, 15 Oct 2025 16:47:45 +0800 Subject: [PATCH 13/39] Fix/aisdk error (#10563) * Add syntax highlighting to AI SDK error cause display - Parse and format error cause as JSON with syntax highlighting - Use CodeStyleProvider context for consistent code styling - Maintain plain text fallback for non-JSON content * fix patch * chore: yarn lock * feat: provider-specific-error * chore * chore * fix: handle JSON parsing errors in AiSdkErrorBase component * fix: improve error message formatting in AiSdkToChunkAdapter * fix: remove unused MarkdownContainer and update AiSdkErrorBase to use styled div --- .../src/aiCore/chunk/AiSdkToChunkAdapter.ts | 8 ++++- .../pages/home/Messages/Blocks/ErrorBlock.tsx | 27 +++++++++++++++-- src/renderer/src/types/error.ts | 16 +++++++++- .../src/types/provider-specific-error.ts | 29 +++++++++++++++++++ src/renderer/src/utils/error.ts | 1 + 5 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 src/renderer/src/types/provider-specific-error.ts diff --git a/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts b/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts index 5ea1ddf96..f4894a203 100644 --- a/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts +++ b/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts @@ -6,6 +6,8 @@ import { loggerService } from '@logger' import { AISDKWebSearchResult, MCPTool, WebSearchResults, WebSearchSource } from '@renderer/types' import { Chunk, ChunkType } from '@renderer/types/chunk' +import { ProviderSpecificError } from '@renderer/types/provider-specific-error' +import { formatErrorMessage } from '@renderer/utils/error' import { convertLinks, flushLinkConverterBuffer } from '@renderer/utils/linkConverter' import type { ClaudeCodeRawValue } from '@shared/agents/claudecode/types' import type { TextStreamPart, ToolSet } from 'ai' @@ -355,7 +357,11 @@ export class AiSdkToChunkAdapter { case 'error': this.onChunk({ type: ChunkType.ERROR, - error: chunk.error as Record + error: new ProviderSpecificError({ + message: formatErrorMessage(chunk.error), + provider: 'unknown', + cause: chunk.error + }) }) break diff --git a/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx index 2cf85a3d6..243bec8cc 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx @@ -1,5 +1,6 @@ import { Button } from '@heroui/button' import CodeViewer from '@renderer/components/CodeViewer' +import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { useTimer } from '@renderer/hooks/useTimer' import { getHttpMessageLabel, getProviderLabel } from '@renderer/i18n/label' import { getProviderById } from '@renderer/services/ProviderService' @@ -35,7 +36,7 @@ import { import type { ErrorMessageBlock, Message } from '@renderer/types/newMessage' import { formatAiSdkError, formatError, safeToString } from '@renderer/utils/error' import { Alert as AntdAlert, Modal } from 'antd' -import React, { useState } from 'react' +import React, { useEffect, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import { Link } from 'react-router-dom' import styled from 'styled-components' @@ -305,14 +306,36 @@ const BuiltinError = ({ error }: { error: SerializedError }) => { // 作为 base,渲染公共字段,应当在 ErrorDetailList 中渲染 const AiSdkErrorBase = ({ error }: { error: SerializedAiSdkError }) => { const { t } = useTranslation() + const { highlightCode } = useCodeStyle() + const [highlightedString, setHighlightedString] = useState('') const cause = error.cause + + useEffect(() => { + const highlight = async () => { + try { + const result = await highlightCode(JSON.stringify(JSON.parse(cause || '{}'), null, 2), 'json') + setHighlightedString(result) + } catch { + setHighlightedString(cause || '') + } + } + const timer = setTimeout(highlight, 0) + + return () => clearTimeout(timer) + }, [highlightCode, cause]) + return ( <> {cause && ( {t('error.cause')}: - {error.cause} + +
      + )} diff --git a/src/renderer/src/types/error.ts b/src/renderer/src/types/error.ts index c260c9c09..78bfe0a52 100644 --- a/src/renderer/src/types/error.ts +++ b/src/renderer/src/types/error.ts @@ -20,6 +20,7 @@ import { UnsupportedFunctionalityError } from 'ai' +import { ProviderSpecificError } from './provider-specific-error' import { Serializable } from './serialize' export interface SerializedError { @@ -80,7 +81,7 @@ export interface SerializedAiSdkInvalidArgumentError extends SerializedAiSdkErro export const isSerializedAiSdkInvalidArgumentError = ( error: SerializedError ): error is SerializedAiSdkInvalidArgumentError => { - return isSerializedAiSdkError(error) && 'parameter' in error && 'value' in error + return isSerializedAiSdkError(error) && 'message' in error && error.name === 'AI_InvalidArgumentError' } export interface SerializedAiSdkInvalidDataContentError extends SerializedAiSdkError { @@ -198,10 +199,20 @@ export interface SerializedAiSdkNoSuchToolError extends SerializedAiSdkError { readonly availableTools: string[] | null } +export interface SerializedAiSdkProviderSpecificError extends SerializedAiSdkError { + readonly provider: string +} + export const isSerializedAiSdkNoSuchToolError = (error: SerializedError): error is SerializedAiSdkNoSuchToolError => { return isSerializedAiSdkError(error) && 'toolName' in error && 'availableTools' in error } +export const isSerializedAiSdkProviderSpecificError = ( + error: SerializedError +): error is SerializedAiSdkProviderSpecificError => { + return isSerializedAiSdkError(error) && 'provider' in error +} + export interface SerializedAiSdkRetryError extends SerializedAiSdkError { readonly reason: string readonly lastError: Serializable @@ -277,6 +288,7 @@ export type AiSdkErrorUnion = | NoSuchModelError | NoSuchProviderError | NoSuchToolError + | ProviderSpecificError | RetryError | ToolCallRepairError | TypeValidationError @@ -297,6 +309,7 @@ export type SerializedAiSdkErrorUnion = | SerializedAiSdkNoSuchModelError | SerializedAiSdkNoSuchProviderError | SerializedAiSdkNoSuchToolError + | SerializedAiSdkProviderSpecificError | SerializedAiSdkRetryError | SerializedAiSdkToolCallRepairError | SerializedAiSdkTypeValidationError @@ -317,6 +330,7 @@ export const isSerializedAiSdkErrorUnion = (error: SerializedError): error is Se isSerializedAiSdkNoSuchModelError(error) || isSerializedAiSdkNoSuchProviderError(error) || isSerializedAiSdkNoSuchToolError(error) || + isSerializedAiSdkProviderSpecificError(error) || isSerializedAiSdkRetryError(error) || isSerializedAiSdkToolCallRepairError(error) || isSerializedAiSdkTypeValidationError(error) || diff --git a/src/renderer/src/types/provider-specific-error.ts b/src/renderer/src/types/provider-specific-error.ts new file mode 100644 index 000000000..7d624b3eb --- /dev/null +++ b/src/renderer/src/types/provider-specific-error.ts @@ -0,0 +1,29 @@ +import { AISDKError } from 'ai' + +const name = 'AI_ProviderSpecificError' +const marker = `vercel.ai.error.${name}` +const symbol = Symbol.for(marker) + +export class ProviderSpecificError extends AISDKError { + // @ts-ignore + private readonly [symbol] = true // used in isInstance + + readonly provider: string + + constructor({ + message, + provider, + cause + }: { + message: string + provider: string + cause?: unknown + }) { + super({ name, message, cause }) + this.provider = provider + } + + static isInstance(error: unknown): error is ProviderSpecificError { + return AISDKError.hasMarker(error, marker) + } +} diff --git a/src/renderer/src/utils/error.ts b/src/renderer/src/utils/error.ts index 737baf411..2e7f10964 100644 --- a/src/renderer/src/utils/error.ts +++ b/src/renderer/src/utils/error.ts @@ -199,6 +199,7 @@ export const serializeError = (error: AiSdkErrorUnion): SerializedError => { ? serializeInvalidToolInputError(error.originalError) : serializeNoSuchToolError(error.originalError) if ('functionality' in error) serializedError.functionality = error.functionality + if ('provider' in error) serializedError.provider = error.provider return serializedError } From b74655651d029efdb0345b415e395b89285b8b43 Mon Sep 17 00:00:00 2001 From: defi-failure <159208748+defi-failure@users.noreply.github.com> Date: Wed, 15 Oct 2025 17:24:25 +0800 Subject: [PATCH 14/39] fix: swagger ui can't open (#10732) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 46e6e005e..29cb40b0a 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "selection-hook": "^1.0.12", "sharp": "^0.34.3", "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1", "tesseract.js": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch", "turndown": "7.2.0" }, @@ -336,7 +337,6 @@ "string-width": "^7.2.0", "striptags": "^3.2.0", "styled-components": "^6.1.11", - "swagger-ui-express": "^5.0.1", "swr": "^2.3.6", "tailwindcss": "^4.1.13", "tar": "^7.4.3", From c457d4a86826a66037434d74205494f72fbef58e Mon Sep 17 00:00:00 2001 From: ABucket Date: Wed, 15 Oct 2025 18:48:30 +0800 Subject: [PATCH 15/39] fix: Duplicate dialog when clearing messages (#10721) --- src/renderer/src/pages/home/Tabs/components/Topics.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/renderer/src/pages/home/Tabs/components/Topics.tsx b/src/renderer/src/pages/home/Tabs/components/Topics.tsx index d7214ff45..ce2cd25d3 100644 --- a/src/renderer/src/pages/home/Tabs/components/Topics.tsx +++ b/src/renderer/src/pages/home/Tabs/components/Topics.tsx @@ -288,13 +288,7 @@ export const Topics: React.FC = ({ assistant: _assistant, activeTopic, se label: t('chat.topics.clear.title'), key: 'clear-messages', icon: , - async onClick() { - window.modal.confirm({ - title: t('chat.input.clear.content'), - centered: true, - onOk: () => onClearMessages(topic) - }) - } + onClick: () => onClearMessages(topic) }, { label: t('settings.topic.position.label'), From 2e173631a074af1e23be0de779359f88df7a99f4 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Wed, 15 Oct 2025 19:46:36 +0800 Subject: [PATCH 16/39] chore: update release notes for v1.7.0-beta.1 - Major features introduced: Agent System, Agent Management, and Unified UI. - Added detailed agent features and UI/UX improvements. - Included bug fixes and technical updates, such as React upgrade and enhanced Claude Code service. - Updated version in package.json to 1.7.0-beta.1. --- electron-builder.yml | 116 ++++++++++++++++++++++++++++++++++++++----- package.json | 4 +- 2 files changed, 106 insertions(+), 14 deletions(-) diff --git a/electron-builder.yml b/electron-builder.yml index e1e29eb11..8577a307a 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -125,21 +125,113 @@ afterSign: scripts/notarize.js artifactBuildCompleted: scripts/artifact-build-completed.js releaseInfo: releaseNotes: | - What's New in v1.6.3 + + What's New in v1.7.0-beta.1 - Features: - - Notes: Add spell-check control, automatic table line wrapping, export functionality, and LLM-based renaming - - UI: Expand topic rename clickable area, add middle-click tab closing, remove redundant scrollbars, fix message menubar overflow - - Editor: Add read-only extension support, make TextFilePreview read-only but copyable - - Models: Update support for DeepSeek v3.2, Claude 4.5, GLM 4.6, Gemini regex, and vision models - - Code Tools: Add GitHub Copilot CLI integration + Major Features: + - Agent System: Introducing intelligent Agent capabilities alongside Assistants. Agents can autonomously solve complex problems using Claude Code SDK with tool calling, file operations, and multi-turn reasoning + - Agent Management: Create, configure, and manage agents with custom settings including model selection, tool permissions, accessible paths, and MCP server integrations + - Agent Sessions: Dedicated session management for agent interactions with persistent message history and context tracking + - Unified UI: Streamlined interface combining Assistants and Agents tabs with improved navigation and settings management + + Agent Features: + - Tool Support: Web search, file operations, bash commands, and custom MCP tools + - Advanced Configuration: Max turns, temperature, token limits + - Permission Control: Configurable tool approval modes (manual, automatic, none) + - Session Persistence: Automatic message saving with optimized streaming and database integration + - Model Selection: API-based model filtering with provider-specific support + + UI/UX Improvements: + - Unified assistant/agent tabs with smooth animations + - In-place session name editing + - Virtual list rendering for improved performance + - Session count indicators for active agents + - Enhanced settings popup with tabbed interface + - Webview keyboard shortcut interception for search functionality + + API & Infrastructure: + - RESTful API for agent and session management + - Drizzle ORM integration for agent database + - OAuth support for Claude Code authentication + - Express validator for request validation + - Comprehensive error handling with Zod schemas + + Model Updates: + - Gemini 2.5 Image Flash support + - Grok 4 Fast with reasoning capabilities + - Qwen3-omni and Qwen3-vl thinking models + - DeepSeek, Claude 4.5, GLM 4.6 support + - GitHub Copilot CLI integration with gpt-5-codex Bug Fixes: + - Fix Swagger UI accessibility issues + - Fix AI SDK error display with syntax highlighting + - Fix webview search shortcut handling + - Fix agent model visibility for CherryIn provider + - Fix session message ordering and persistence + - Fix anthropic model visibility in agent configuration + - Fix knowledge base deletion and web search RAG errors - Fix migration for missing providers - - Fix forked topic retaining old name after rename - - Restore first token latency reporting in metrics - - Fix UI scrollbar and overflow issues Technical Updates: - - Upgrade to Electron 37.6.0 - - Update dependencies across packages + - React 19.2.0 upgrade + - Enhanced Claude Code service with streaming support + - Improved message transformation and streaming lifecycle + - Database migration system with automatic schema sync + - Optimized bundle size and dependency management + + + v1.7.0-beta.1 新特性 + + 核心功能: + - Agent 系统:引入智能 Agent 能力,与助手(Assistant)并存。Agent 基于 Claude Code SDK 构建,具备工具调用、文件操作和多轮推理能力,可自主解决复杂问题 + - Agent 管理:创建、配置和管理 Agent,支持自定义模型选择、工具权限、可访问路径和 MCP 服务器集成 + - Agent 会话:专属会话管理系统,支持持久化消息历史和上下文追踪 + - 统一界面:精简的助手和 Agent 标签页界面,改进导航和设置管理体验 + + Agent 功能特性: + - 工具支持:网页搜索、文件操作、Bash 命令执行和自定义 MCP 工具 + - 高级配置:最大轮次、温度、Token 限制 + - 权限控制:可配置的工具批准模式(手动、自动、无需批准) + - 会话持久化:自动消息保存,优化的流式传输和数据库集成 + - 模型选择:基于 API 的模型过滤,支持特定提供商 + + 界面与交互优化: + - 统一的助手/Agent 标签页,带有流畅动画效果 + - 会话名称原地编辑功能 + - 虚拟列表渲染,提升性能表现 + - 活跃 Agent 的会话计数指示器 + - 增强的设置弹窗,采用标签页界面 + - Webview 键盘快捷键拦截,支持搜索功能 + + API 与基础设施: + - RESTful API 用于 Agent 和会话管理 + - 集成 Drizzle ORM 管理 Agent 数据库 + - Claude Code OAuth 认证支持 + - Express validator 请求验证 + - 基于 Zod 模式的完善错误处理 + + 模型更新: + - 支持 Gemini 2.5 Image Flash + - Grok 4 Fast 推理能力 + - Qwen3-omni 和 Qwen3-vl 思考模型 + - DeepSeek、Claude 4.5、GLM 4.6 支持 + - GitHub Copilot CLI 集成 gpt-5-codex + + 问题修复: + - 修复 Swagger UI 无法打开 + - 修复 AI SDK 错误显示,添加语法高亮 + - 修复 Webview 搜索快捷键处理 + - 修复 CherryIn 提供商的 Agent 模型可见性 + - 修复会话消息排序和持久化 + - 修复 Anthropic 模型在 Agent 配置中的可见性 + - 修复知识库删除和网页搜索 RAG 错误 + - 修复缺失提供商的迁移问题 + + 技术更新: + - 升级至 React 19.2.0 + - 增强 Claude Code 服务流式传输支持 + - 改进消息转换和流式生命周期 + - 数据库迁移系统,支持自动模式同步 + - 优化打包大小和依赖管理 + diff --git a/package.json b/package.json index 29cb40b0a..e82153922 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "1.7.0-alpha.5", + "version": "1.7.0-beta.1", "private": true, "description": "A powerful AI assistant for producer.", "main": "./out/main/index.js", @@ -83,6 +83,7 @@ "@libsql/win32-x64-msvc": "^0.4.7", "@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch", "@strongtz/win32-arm64-msvc": "^0.4.7", + "express": "^5.1.0", "font-list": "^2.0.0", "graceful-fs": "^4.2.11", "jsdom": "26.1.0", @@ -263,7 +264,6 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-unused-imports": "^4.1.4", - "express": "^5.1.0", "express-validator": "^7.2.1", "fast-diff": "^1.3.0", "fast-xml-parser": "^5.2.0", From 1a972ac0e0d96ef2ccb7bb3b649254401749e326 Mon Sep 17 00:00:00 2001 From: Phantom Date: Thu, 16 Oct 2025 12:49:31 +0800 Subject: [PATCH 17/39] fix: api server status (#10734) * refactor(apiServer): move api server types to dedicated module Restructure api server type definitions by moving them from index.ts to a dedicated apiServer.ts file. This improves code organization and maintainability by grouping related types together. * feat(api-server): add api server management hooks and integration Extract api server management logic into reusable hook and integrate with settings page * feat(api-server): improve api server status handling and error messages - add new error messages for api server status - optimize initial state and loading in useApiServer hook - centralize api server enabled check via useApiServer hook - update components to use new api server status handling * fix(agents): update error message key for agent server not running * fix(i18n): update api server status messages across locales Remove redundant 'notRunning' message in en-us locale Add consistent 'not_running' error message in all locales Add missing 'notEnabled' message in several locales * refactor: update api server type imports to use @types Move api server related type imports from renderer/src/types to @types package for better code organization and maintainability * docs(IpcChannel): add comment about unused api-server:get-config Add TODO comment about data inconsistency in useApiServer hook * refactor(assistants): pass apiServerEnabled as prop instead of using hook Move apiServerEnabled from being fetched via useApiServer hook to being passed as a prop through component hierarchy. This improves maintainability by making dependencies more explicit and reducing hook usage in child components. * style(AssistantsTab): add consistent margin-bottom to alert components * feat(useAgent): add api server status checks before fetching agent Ensure api server is enabled and running before attempting to fetch agent data --- packages/shared/IpcChannel.ts | 1 + src/main/services/ApiServerService.ts | 16 ++- src/preload/index.ts | 10 ++ src/renderer/src/hooks/agents/useAgent.ts | 12 +- src/renderer/src/hooks/agents/useAgents.ts | 10 +- src/renderer/src/hooks/useApiServer.ts | 112 ++++++++++++++++++ src/renderer/src/i18n/locales/en-us.json | 6 + src/renderer/src/i18n/locales/zh-cn.json | 6 + src/renderer/src/i18n/locales/zh-tw.json | 6 + src/renderer/src/i18n/translate/el-gr.json | 6 + src/renderer/src/i18n/translate/es-es.json | 6 + src/renderer/src/i18n/translate/fr-fr.json | 6 + src/renderer/src/i18n/translate/ja-jp.json | 6 + src/renderer/src/i18n/translate/pt-pt.json | 6 + src/renderer/src/i18n/translate/ru-ru.json | 6 + .../src/pages/home/Tabs/AssistantsTab.tsx | 29 +++-- .../ApiServerSettings/ApiServerSettings.tsx | 62 ++-------- src/renderer/src/types/apiServer.ts | 38 ++++++ src/renderer/src/types/index.ts | 8 +- 19 files changed, 277 insertions(+), 75 deletions(-) create mode 100644 src/renderer/src/hooks/useApiServer.ts create mode 100644 src/renderer/src/types/apiServer.ts diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 720d1ec4b..93679f5fa 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -317,6 +317,7 @@ export enum IpcChannel { ApiServer_Stop = 'api-server:stop', ApiServer_Restart = 'api-server:restart', ApiServer_GetStatus = 'api-server:get-status', + // NOTE: This api is not be used. ApiServer_GetConfig = 'api-server:get-config', // Anthropic OAuth diff --git a/src/main/services/ApiServerService.ts b/src/main/services/ApiServerService.ts index 9a7bfad7e..76a5eca61 100644 --- a/src/main/services/ApiServerService.ts +++ b/src/main/services/ApiServerService.ts @@ -1,5 +1,11 @@ import { IpcChannel } from '@shared/IpcChannel' -import { ApiServerConfig } from '@types' +import { + ApiServerConfig, + GetApiServerStatusResult, + RestartApiServerStatusResult, + StartApiServerStatusResult, + StopApiServerStatusResult +} from '@types' import { ipcMain } from 'electron' import { apiServer } from '../apiServer' @@ -52,7 +58,7 @@ export class ApiServerService { registerIpcHandlers(): void { // API Server - ipcMain.handle(IpcChannel.ApiServer_Start, async () => { + ipcMain.handle(IpcChannel.ApiServer_Start, async (): Promise => { try { await this.start() return { success: true } @@ -61,7 +67,7 @@ export class ApiServerService { } }) - ipcMain.handle(IpcChannel.ApiServer_Stop, async () => { + ipcMain.handle(IpcChannel.ApiServer_Stop, async (): Promise => { try { await this.stop() return { success: true } @@ -70,7 +76,7 @@ export class ApiServerService { } }) - ipcMain.handle(IpcChannel.ApiServer_Restart, async () => { + ipcMain.handle(IpcChannel.ApiServer_Restart, async (): Promise => { try { await this.restart() return { success: true } @@ -79,7 +85,7 @@ export class ApiServerService { } }) - ipcMain.handle(IpcChannel.ApiServer_GetStatus, async () => { + ipcMain.handle(IpcChannel.ApiServer_GetStatus, async (): Promise => { try { const config = await this.getCurrentConfig() return { diff --git a/src/preload/index.ts b/src/preload/index.ts index a90174baa..34656092b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -12,6 +12,7 @@ import { FileListResponse, FileMetadata, FileUploadResponse, + GetApiServerStatusResult, KnowledgeBaseParams, KnowledgeItem, KnowledgeSearchResult, @@ -22,8 +23,11 @@ import { OcrProvider, OcrResult, Provider, + RestartApiServerStatusResult, S3Config, Shortcut, + StartApiServerStatusResult, + StopApiServerStatusResult, SupportedOcrFile, ThemeMode, WebDavConfig @@ -496,6 +500,12 @@ const api = { ipcRenderer.removeListener(channel, listener) } } + }, + apiServer: { + getStatus: (): Promise => ipcRenderer.invoke(IpcChannel.ApiServer_GetStatus), + start: (): Promise => ipcRenderer.invoke(IpcChannel.ApiServer_Start), + restart: (): Promise => ipcRenderer.invoke(IpcChannel.ApiServer_Restart), + stop: (): Promise => ipcRenderer.invoke(IpcChannel.ApiServer_Stop) } } diff --git a/src/renderer/src/hooks/agents/useAgent.ts b/src/renderer/src/hooks/agents/useAgent.ts index 62d239361..a6755a429 100644 --- a/src/renderer/src/hooks/agents/useAgent.ts +++ b/src/renderer/src/hooks/agents/useAgent.ts @@ -1,18 +1,28 @@ import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' import useSWR from 'swr' +import { useApiServer } from '../useApiServer' import { useAgentClient } from './useAgentClient' export const useAgent = (id: string | null) => { + const { t } = useTranslation() const client = useAgentClient() const key = id ? client.agentPaths.withId(id) : null + const { apiServerConfig, apiServerRunning } = useApiServer() const fetcher = useCallback(async () => { if (!id || id === 'fake') { return null } + if (!apiServerConfig.enabled) { + throw new Error(t('apiServer.messages.notEnabled')) + } + if (!apiServerRunning) { + throw new Error(t('agent.server.error.not_running')) + } const result = await client.getAgent(id) return result - }, [client, id]) + }, [apiServerConfig.enabled, apiServerRunning, client, id, t]) const { data, error, isLoading } = useSWR(key, id ? fetcher : null) return { diff --git a/src/renderer/src/hooks/agents/useAgents.ts b/src/renderer/src/hooks/agents/useAgents.ts index c49cf46d4..6af38228c 100644 --- a/src/renderer/src/hooks/agents/useAgents.ts +++ b/src/renderer/src/hooks/agents/useAgents.ts @@ -6,6 +6,7 @@ import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import useSWR from 'swr' +import { useApiServer } from '../useApiServer' import { useRuntime } from '../useRuntime' import { useAgentClient } from './useAgentClient' @@ -23,11 +24,18 @@ export const useAgents = () => { const { t } = useTranslation() const client = useAgentClient() const key = client.agentPaths.base + const { apiServerConfig, apiServerRunning } = useApiServer() const fetcher = useCallback(async () => { + if (!apiServerConfig.enabled) { + throw new Error(t('apiServer.messages.notEnabled')) + } + if (!apiServerRunning) { + throw new Error(t('agent.server.error.not_running')) + } const result = await client.listAgents() // NOTE: We only use the array for now. useUpdateAgent depends on this behavior. return result.data - }, [client]) + }, [apiServerConfig.enabled, apiServerRunning, client, t]) const { data, error, isLoading, mutate } = useSWR(key, fetcher) const { chat } = useRuntime() const { activeAgentId } = chat diff --git a/src/renderer/src/hooks/useApiServer.ts b/src/renderer/src/hooks/useApiServer.ts new file mode 100644 index 000000000..ae418f6cc --- /dev/null +++ b/src/renderer/src/hooks/useApiServer.ts @@ -0,0 +1,112 @@ +import { loggerService } from '@logger' +import { useAppDispatch, useAppSelector } from '@renderer/store' +import { setApiServerEnabled as setApiServerEnabledAction } from '@renderer/store/settings' +import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' + +const logger = loggerService.withContext('useApiServer') + +export const useApiServer = () => { + const { t } = useTranslation() + // FIXME: We currently store two copies of the config data in both the renderer and the main processes, + // which carries the risk of data inconsistency. This should be modified so that the main process stores + // the data, and the renderer retrieves it. + const apiServerConfig = useAppSelector((state) => state.settings.apiServer) + const dispatch = useAppDispatch() + + // Optimistic initial state. + const [apiServerRunning, setApiServerRunning] = useState(apiServerConfig.enabled) + const [apiServerLoading, setApiServerLoading] = useState(true) + + const setApiServerEnabled = useCallback( + (enabled: boolean) => { + dispatch(setApiServerEnabledAction(enabled)) + }, + [dispatch] + ) + + // API Server functions + const checkApiServerStatus = useCallback(async () => { + setApiServerLoading(true) + try { + const status = await window.api.apiServer.getStatus() + setApiServerRunning(status.running) + } catch (error: any) { + logger.error('Failed to check API server status:', error) + } finally { + setApiServerLoading(false) + } + }, []) + + const startApiServer = useCallback(async () => { + if (apiServerLoading) return + + setApiServerLoading(true) + try { + const result = await window.api.apiServer.start() + if (result.success) { + setApiServerRunning(true) + window.toast.success(t('apiServer.messages.startSuccess')) + } else { + window.toast.error(t('apiServer.messages.startError') + result.error) + } + } catch (error: any) { + window.toast.error(t('apiServer.messages.startError') + (error.message || error)) + } finally { + setApiServerLoading(false) + } + }, [apiServerLoading, t]) + + const stopApiServer = useCallback(async () => { + if (apiServerLoading) return + + setApiServerLoading(true) + try { + const result = await window.api.apiServer.stop() + if (result.success) { + setApiServerRunning(false) + window.toast.success(t('apiServer.messages.stopSuccess')) + } else { + window.toast.error(t('apiServer.messages.stopError') + result.error) + } + } catch (error: any) { + window.toast.error(t('apiServer.messages.stopError') + (error.message || error)) + } finally { + setApiServerLoading(false) + } + }, [apiServerLoading, t]) + + const restartApiServer = useCallback(async () => { + if (apiServerLoading) return + + setApiServerLoading(true) + try { + const result = await window.api.apiServer.restart() + if (result.success) { + await checkApiServerStatus() + window.toast.success(t('apiServer.messages.restartSuccess')) + } else { + window.toast.error(t('apiServer.messages.restartError') + result.error) + } + } catch (error) { + window.toast.error(t('apiServer.messages.restartFailed') + (error as Error).message) + } finally { + setApiServerLoading(false) + } + }, [apiServerLoading, checkApiServerStatus, t]) + + useEffect(() => { + checkApiServerStatus() + }, [checkApiServerStatus]) + + return { + apiServerConfig, + apiServerRunning, + apiServerLoading, + startApiServer, + stopApiServer, + restartApiServer, + checkApiServerStatus, + setApiServerEnabled + } +} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 0d4639076..288f0964b 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -30,6 +30,11 @@ "failed": "Failed to list agents." } }, + "server": { + "error": { + "not_running": "The API server is enabled but not running properly." + } + }, "session": { "accessible_paths": { "add": "Add directory", @@ -237,6 +242,7 @@ "messages": { "apiKeyCopied": "API Key copied to clipboard", "apiKeyRegenerated": "API Key regenerated", + "notEnabled": "The API Server is not enabled.", "operationFailed": "API Server operation failed: ", "restartError": "Failed to restart API Server: ", "restartFailed": "API Server restart failed: ", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 2ae391419..3e6bac470 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -30,6 +30,11 @@ "failed": "获取智能体列表失败" } }, + "server": { + "error": { + "not_running": "API 服务器已启用但未正常运行。" + } + }, "session": { "accessible_paths": { "add": "添加目录", @@ -237,6 +242,7 @@ "messages": { "apiKeyCopied": "API 密钥已复制到剪贴板", "apiKeyRegenerated": "API 密钥已重新生成", + "notEnabled": "API 服务器未启用。", "operationFailed": "API 服务器操作失败:", "restartError": "重启 API 服务器失败:", "restartFailed": "API 服务器重启失败:", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 3e9f321f1..b87e5e325 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -30,6 +30,11 @@ "failed": "無法列出代理程式。" } }, + "server": { + "error": { + "not_running": "API 伺服器已啟用,但運行不正常。" + } + }, "session": { "accessible_paths": { "add": "新增目錄", @@ -237,6 +242,7 @@ "messages": { "apiKeyCopied": "API 金鑰已複製到剪貼簿", "apiKeyRegenerated": "API 金鑰已重新生成", + "notEnabled": "API 伺服器未啟用。", "operationFailed": "API 伺服器操作失敗:", "restartError": "重新啟動 API 伺服器失敗:", "restartFailed": "API 伺服器重新啟動失敗:", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index ed2ae1aee..a39c429d1 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -30,6 +30,11 @@ "failed": "Αποτυχία καταχώρησης πρακτόρων." } }, + "server": { + "error": { + "not_running": "Ο διακομιστής API είναι ενεργοποιημένος αλλά δεν λειτουργεί σωστά." + } + }, "session": { "accessible_paths": { "add": "Προσθήκη καταλόγου", @@ -237,6 +242,7 @@ "messages": { "apiKeyCopied": "Το κλειδί API αντιγράφηκε στο πρόχειρο", "apiKeyRegenerated": "Το κλειδί API αναδημιουργήθηκε", + "notEnabled": "Ο διακομιστής API δεν είναι ενεργοποιημένος.", "operationFailed": "Η λειτουργία του Διακομιστή API απέτυχε: ", "restartError": "Αποτυχία επανεκκίνησης του Διακομιστή API: ", "restartFailed": "Η επανεκκίνηση του Διακομιστή API απέτυχε: ", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index eeb943c39..219f80918 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -30,6 +30,11 @@ "failed": "Error al listar agentes." } }, + "server": { + "error": { + "not_running": "El servidor de API está habilitado pero no funciona correctamente." + } + }, "session": { "accessible_paths": { "add": "Agregar directorio", @@ -237,6 +242,7 @@ "messages": { "apiKeyCopied": "Clave API copiada al portapapeles", "apiKeyRegenerated": "Clave API regenerada", + "notEnabled": "El servidor de API no está habilitado.", "operationFailed": "Falló la operación del Servidor API: ", "restartError": "Error al reiniciar el Servidor API: ", "restartFailed": "Falló el reinicio del Servidor API: ", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index b0e4a1afe..72899c3c1 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -30,6 +30,11 @@ "failed": "Échec de la liste des agents." } }, + "server": { + "error": { + "not_running": "Le serveur API est activé mais ne fonctionne pas correctement." + } + }, "session": { "accessible_paths": { "add": "Ajouter un répertoire", @@ -237,6 +242,7 @@ "messages": { "apiKeyCopied": "Clé API copiée dans le presse-papiers", "apiKeyRegenerated": "Clé API régénérée", + "notEnabled": "Le serveur API n'est pas activé.", "operationFailed": "Opération du Serveur API échouée : ", "restartError": "Échec du redémarrage du Serveur API : ", "restartFailed": "Redémarrage du Serveur API échoué : ", diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index 7d0798827..5c7e77400 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -30,6 +30,11 @@ "failed": "エージェントの一覧取得に失敗しました。" } }, + "server": { + "error": { + "not_running": "APIサーバーは有効になっていますが、正常に動作していません。" + } + }, "session": { "accessible_paths": { "add": "ディレクトリを追加", @@ -237,6 +242,7 @@ "messages": { "apiKeyCopied": "API キーがクリップボードにコピーされました", "apiKeyRegenerated": "API キーが再生成されました", + "notEnabled": "APIサーバーが有効になっていません。", "operationFailed": "API サーバーの操作に失敗しました:", "restartError": "API サーバーの再起動に失敗しました:", "restartFailed": "API サーバーの再起動に失敗しました:", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index e4707b846..f10aea90d 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -30,6 +30,11 @@ "failed": "Falha ao listar agentes." } }, + "server": { + "error": { + "not_running": "O servidor de API está habilitado, mas não está funcionando corretamente." + } + }, "session": { "accessible_paths": { "add": "Adicionar diretório", @@ -237,6 +242,7 @@ "messages": { "apiKeyCopied": "Chave API copiada para a área de transferência", "apiKeyRegenerated": "Chave API regenerada", + "notEnabled": "O Servidor de API não está habilitado.", "operationFailed": "Operação do Servidor API falhou: ", "restartError": "Falha ao reiniciar o Servidor API: ", "restartFailed": "Reinício do Servidor API falhou: ", diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index d4c631d81..bf4533a4a 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -30,6 +30,11 @@ "failed": "Не удалось получить список агентов." } }, + "server": { + "error": { + "not_running": "API-сервер включен, но работает неправильно." + } + }, "session": { "accessible_paths": { "add": "Добавить каталог", @@ -237,6 +242,7 @@ "messages": { "apiKeyCopied": "API ключ скопирован в буфер обмена", "apiKeyRegenerated": "API ключ перегенерирован", + "notEnabled": "API-сервер не включен.", "operationFailed": "Операция API сервера не удалась: ", "restartError": "Не удалось перезапустить API сервер: ", "restartFailed": "Перезапуск API сервера не удался: ", diff --git a/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx b/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx index 2eaa2ef1e..f4588f60a 100644 --- a/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx @@ -1,15 +1,16 @@ import { Alert, Spinner } from '@heroui/react' import Scrollbar from '@renderer/components/Scrollbar' import { useAgents } from '@renderer/hooks/agents/useAgents' +import { useApiServer } from '@renderer/hooks/useApiServer' import { useAssistants } from '@renderer/hooks/useAssistant' import { useAssistantPresets } from '@renderer/hooks/useAssistantPresets' import { useRuntime } from '@renderer/hooks/useRuntime' -import { useSettings } from '@renderer/hooks/useSettings' import { useAssistantsTabSortType } from '@renderer/hooks/useStore' import { useTags } from '@renderer/hooks/useTags' import { useAppDispatch } from '@renderer/store' import { addIknowAction } from '@renderer/store/runtime' import { Assistant, AssistantsSortType } from '@renderer/types' +import { getErrorMessage } from '@renderer/utils' import { FC, useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -35,7 +36,8 @@ const AssistantsTab: FC = (props) => { const { activeAssistant, setActiveAssistant, onCreateAssistant, onCreateDefaultAssistant } = props const containerRef = useRef(null) const { t } = useTranslation() - const { apiServer } = useSettings() + const { apiServerConfig, apiServerRunning } = useApiServer() + const apiServerEnabled = apiServerConfig.enabled const { iknow, chat } = useRuntime() const dispatch = useAppDispatch() @@ -55,7 +57,7 @@ const AssistantsTab: FC = (props) => { const { unifiedItems, handleUnifiedListReorder } = useUnifiedItems({ agents, assistants, - apiServerEnabled: apiServer.enabled, + apiServerEnabled, agentsLoading, agentsError, updateAssistants @@ -72,17 +74,17 @@ const AssistantsTab: FC = (props) => { unifiedItems, assistants, agents, - apiServerEnabled: apiServer.enabled, + apiServerEnabled, agentsLoading, agentsError, updateAssistants }) useEffect(() => { - if (!agentsLoading && agents.length > 0 && !activeAgentId && apiServer.enabled) { + if (!agentsLoading && agents.length > 0 && !activeAgentId && apiServerConfig.enabled) { setActiveAgentId(agents[0].id) } - }, [agentsLoading, agents, activeAgentId, setActiveAgentId, apiServer.enabled]) + }, [agentsLoading, agents, activeAgentId, setActiveAgentId, apiServerConfig.enabled]) const onDeleteAssistant = useCallback( (assistant: Assistant) => { @@ -105,7 +107,7 @@ const AssistantsTab: FC = (props) => { return ( - {!apiServer.enabled && !iknow[ALERT_KEY] && ( + {!apiServerConfig.enabled && !iknow[ALERT_KEY] && ( = (props) => { onClose={() => { dispatch(addIknowAction(ALERT_KEY)) }} + className="mb-2" /> )} {agentsLoading && } - {apiServer.enabled && agentsError && } + {apiServerConfig.enabled && !apiServerRunning && ( + + )} + {apiServerConfig.enabled && apiServerRunning && agentsError && ( + + )} {assistantsTabSortType === 'tags' ? ( { @@ -25,67 +23,25 @@ const ApiServerSettings: FC = () => { // API Server state with proper defaults const apiServerConfig = useSelector((state: RootState) => state.settings.apiServer) - - const [apiServerRunning, setApiServerRunning] = useState(false) - const [apiServerLoading, setApiServerLoading] = useState(false) - - // API Server functions - const checkApiServerStatus = async () => { - try { - const status = await window.electron.ipcRenderer.invoke(IpcChannel.ApiServer_GetStatus) - setApiServerRunning(status.running) - } catch (error: any) { - logger.error('Failed to check API server status:', error) - } - } - - useEffect(() => { - checkApiServerStatus() - }, []) + const { apiServerRunning, apiServerLoading, startApiServer, stopApiServer, restartApiServer, setApiServerEnabled } = + useApiServer() const handleApiServerToggle = async (enabled: boolean) => { - setApiServerLoading(true) try { if (enabled) { - const result = await window.electron.ipcRenderer.invoke(IpcChannel.ApiServer_Start) - if (result.success) { - setApiServerRunning(true) - window.toast.success(t('apiServer.messages.startSuccess')) - } else { - window.toast.error(t('apiServer.messages.startError') + result.error) - } + await startApiServer() } else { - const result = await window.electron.ipcRenderer.invoke(IpcChannel.ApiServer_Stop) - if (result.success) { - setApiServerRunning(false) - window.toast.success(t('apiServer.messages.stopSuccess')) - } else { - window.toast.error(t('apiServer.messages.stopError') + result.error) - } + await stopApiServer() } } catch (error) { window.toast.error(t('apiServer.messages.operationFailed') + formatErrorMessage(error)) } finally { - dispatch(setApiServerEnabled(enabled)) - setApiServerLoading(false) + setApiServerEnabled(enabled) } } const handleApiServerRestart = async () => { - setApiServerLoading(true) - try { - const result = await window.electron.ipcRenderer.invoke(IpcChannel.ApiServer_Restart) - if (result.success) { - await checkApiServerStatus() - window.toast.success(t('apiServer.messages.restartSuccess')) - } else { - window.toast.error(t('apiServer.messages.restartError') + result.error) - } - } catch (error) { - window.toast.error(t('apiServer.messages.restartFailed') + (error as Error).message) - } finally { - setApiServerLoading(false) - } + await restartApiServer() } const copyApiKey = () => { diff --git a/src/renderer/src/types/apiServer.ts b/src/renderer/src/types/apiServer.ts new file mode 100644 index 000000000..316b8fe63 --- /dev/null +++ b/src/renderer/src/types/apiServer.ts @@ -0,0 +1,38 @@ +export type ApiServerConfig = { + enabled: boolean + host: string + port: number + apiKey: string +} + +export type GetApiServerStatusResult = { + running: boolean + config: ApiServerConfig | null +} + +export type StartApiServerStatusResult = + | { + success: true + } + | { + success: false + error: string + } + +export type RestartApiServerStatusResult = + | { + success: true + } + | { + success: false + error: string + } + +export type StopApiServerStatusResult = + | { + success: true + } + | { + success: false + error: string + } diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 2f201b3fc..96bbdbd4e 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -17,6 +17,7 @@ import type { BaseTool, MCPTool } from './tool' export * from './agent' export * from './apiModels' +export * from './apiServer' export * from './knowledge' export * from './mcp' export * from './notification' @@ -848,13 +849,6 @@ export type S3Config = { } export type { Message } from './newMessage' - -export interface ApiServerConfig { - enabled: boolean - host: string - port: number - apiKey: string -} export * from './tool' // Memory Service Types From 96ce6450642eaaa0fc7d94b0fe2a7d3e37b8a2fc Mon Sep 17 00:00:00 2001 From: Calcium-Ion <61247483+Calcium-Ion@users.noreply.github.com> Date: Thu, 16 Oct 2025 13:07:28 +0800 Subject: [PATCH 18/39] feat: support NewAPI as a generic provider type (#10696) * feat: add support for New API providerType * feat: support New API as a generic painting provider * refactor: update styling in painting pages to use Tailwind classes - Replaced inline styles with Tailwind CSS classes for margin adjustments in AihubmixPage, DmxapiPage, SiliconPage, TokenFluxPage, and ZhipuPage. - Enhanced consistency and maintainability of the codebase by standardizing styling approach across components. - Minor refactor in ProviderSelect component to support className prop for better styling flexibility. --- .../src/aiCore/provider/providerConfig.ts | 7 +- src/renderer/src/config/providers.ts | 4 +- .../src/pages/paintings/AihubmixPage.tsx | 39 ++------ .../src/pages/paintings/DmxapiPage.tsx | 72 +++++--------- .../src/pages/paintings/NewApiPage.tsx | 82 ++++++--------- .../pages/paintings/PaintingsRoutePage.tsx | 21 +++- .../src/pages/paintings/SiliconPage.tsx | 46 ++------- .../src/pages/paintings/TokenFluxPage.tsx | 38 +------ .../src/pages/paintings/ZhipuPage.tsx | 34 +------ .../paintings/components/ProviderSelect.tsx | 99 +++++++++++++++++++ .../ProviderSettings/AddProviderPopup.tsx | 3 +- src/renderer/src/types/index.ts | 2 + src/renderer/src/types/provider.ts | 3 +- 13 files changed, 196 insertions(+), 254 deletions(-) create mode 100644 src/renderer/src/pages/paintings/components/ProviderSelect.tsx diff --git a/src/renderer/src/aiCore/provider/providerConfig.ts b/src/renderer/src/aiCore/provider/providerConfig.ts index 1fa34e5a1..0a3bbc7b5 100644 --- a/src/renderer/src/aiCore/provider/providerConfig.ts +++ b/src/renderer/src/aiCore/provider/providerConfig.ts @@ -63,13 +63,14 @@ function handleSpecialProviders(model: Model, provider: Provider): Provider { // return createVertexProvider(provider) // } + if (isNewApiProvider(provider)) { + return newApiResolverCreator(model, provider) + } + if (isSystemProvider(provider)) { if (provider.id === 'aihubmix') { return aihubmixProviderCreator(model, provider) } - if (isNewApiProvider(provider)) { - return newApiResolverCreator(model, provider) - } if (provider.id === 'vertexai') { return vertexAnthropicProviderCreator(model, provider) } diff --git a/src/renderer/src/config/providers.ts b/src/renderer/src/config/providers.ts index aa734260f..22faf0fb0 100644 --- a/src/renderer/src/config/providers.ts +++ b/src/renderer/src/config/providers.ts @@ -289,7 +289,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record = 'new-api': { id: 'new-api', name: 'New API', - type: 'openai', + type: 'new-api', apiKey: '', apiHost: 'http://localhost:3000', anthropicApiHost: 'http://localhost:3000', @@ -1432,5 +1432,5 @@ export const isGeminiWebSearchProvider = (provider: Provider) => { } export const isNewApiProvider = (provider: Provider) => { - return ['new-api', 'cherryin'].includes(provider.id) + return ['new-api', 'cherryin'].includes(provider.id) || provider.type === 'new-api' } diff --git a/src/renderer/src/pages/paintings/AihubmixPage.tsx b/src/renderer/src/pages/paintings/AihubmixPage.tsx index cf6a264a5..507554f1c 100644 --- a/src/renderer/src/pages/paintings/AihubmixPage.tsx +++ b/src/renderer/src/pages/paintings/AihubmixPage.tsx @@ -14,7 +14,6 @@ import { usePaintings } from '@renderer/hooks/usePaintings' import { useAllProviders } from '@renderer/hooks/useProvider' import { useRuntime } from '@renderer/hooks/useRuntime' import { useSettings } from '@renderer/hooks/useSettings' -import { getProviderLabel } from '@renderer/i18n/label' import FileManager from '@renderer/services/FileManager' import { translateText } from '@renderer/services/TranslateService' import { useAppDispatch } from '@renderer/store' @@ -35,6 +34,7 @@ import SendMessageButton from '../home/Inputbar/SendMessageButton' import { SettingHelpLink, SettingTitle } from '../settings' import Artboard from './components/Artboard' import PaintingsList from './components/PaintingsList' +import ProviderSelect from './components/ProviderSelect' import { type ConfigItem, createModeConfigs, DEFAULT_PAINTING } from './config/aihubmixConfig' import { checkProviderEnabled } from './utils' @@ -76,20 +76,6 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => { const { t } = useTranslation() const { theme } = useTheme() const providers = useAllProviders() - const providerOptions = Options.map((option) => { - const provider = providers.find((p) => p.id === option) - if (provider) { - return { - label: getProviderLabel(provider.id), - value: provider.id - } - } else { - return { - label: 'Unknown Provider', - value: undefined - } - } - }) const dispatch = useAppDispatch() const { generating } = useRuntime() const navigate = useNavigate() @@ -849,17 +835,12 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => { /> - - + {/* 使用JSON配置渲染设置项 */} {modeConfigs[mode].filter((item) => (item.condition ? item.condition(painting) : true)).map(renderConfigItem)} @@ -1034,12 +1015,6 @@ const ModeSegmentedContainer = styled.div` padding-top: 24px; ` -const SelectOptionContainer = styled.div` - display: flex; - align-items: center; - gap: 8px; -` - // 添加新的样式组件 const ProviderTitleContainer = styled.div` display: flex; diff --git a/src/renderer/src/pages/paintings/DmxapiPage.tsx b/src/renderer/src/pages/paintings/DmxapiPage.tsx index 2ffc9fd95..976dc4f24 100644 --- a/src/renderer/src/pages/paintings/DmxapiPage.tsx +++ b/src/renderer/src/pages/paintings/DmxapiPage.tsx @@ -8,7 +8,6 @@ import { getProviderLogo } from '@renderer/config/providers' import { usePaintings } from '@renderer/hooks/usePaintings' import { useAllProviders } from '@renderer/hooks/useProvider' import { useRuntime } from '@renderer/hooks/useRuntime' -import { getProviderLabel } from '@renderer/i18n/label' import FileManager from '@renderer/services/FileManager' import { useAppDispatch } from '@renderer/store' import { setGenerating } from '@renderer/store/runtime' @@ -29,6 +28,7 @@ import { SettingHelpLink, SettingTitle } from '../settings' import Artboard from './components/Artboard' import ImageUploader from './components/ImageUploader' import PaintingsList from './components/PaintingsList' +import ProviderSelect from './components/ProviderSelect' import { COURSE_URL, DEFAULT_PAINTING, @@ -46,20 +46,6 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { const [painting, setPainting] = useState(dmxapi_paintings?.[0] || DEFAULT_PAINTING) const { t } = useTranslation() const providers = useAllProviders() - const providerOptions = Options.map((option) => { - const provider = providers.find((p) => p.id === option) - if (provider) { - return { - label: getProviderLabel(provider.id), - value: provider.id - } - } else { - return { - label: 'Unknown Provider', - value: undefined - } - } - }) const dmxapiProvider = providers.find((p) => p.id === 'dmxapi')! @@ -785,9 +771,9 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { return ( - {t('paintings.title')} + {t('paintings.title')} {isMac && ( - + @@ -797,7 +783,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { - {t('common.provider')} + {t('common.provider')}
      {t('paintings.paint_course')} @@ -805,28 +791,19 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { {t('paintings.top_up')} - +
      - + {painting.generationMode && [generationModeType.EDIT, generationModeType.MERGE].includes(painting.generationMode) && ( <> - 参考图 + 参考图 = ({ Options }) => { )} - + {t('common.model')} {painting.priceModel !== '0' ? painting.priceModel : ''} - {t('paintings.image.size')} + {t('paintings.image.size')} p.value === 'new-api')?.value} - onChange={handleProviderChange} - style={{ width: '100%' }}> - {providerOptions.map((provider) => ( - - - - {provider.label} - - - ))} - + {/* 当没有可用的 Image Generation 模型时,提示用户先去新增 */} {modelOptions.length === 0 && ( @@ -792,20 +778,12 @@ const ProviderLogo = styled(Avatar)` border: 0.5px solid var(--color-border); ` -// 添加新的样式组件 const ModeSegmentedContainer = styled.div` display: flex; justify-content: center; padding-top: 24px; ` -const SelectOptionContainer = styled.div` - display: flex; - align-items: center; - gap: 8px; -` - -// 添加新的样式组件 const ProviderTitleContainer = styled.div` display: flex; justify-content: space-between; diff --git a/src/renderer/src/pages/paintings/PaintingsRoutePage.tsx b/src/renderer/src/pages/paintings/PaintingsRoutePage.tsx index 9c0ec8a2a..a817334f6 100644 --- a/src/renderer/src/pages/paintings/PaintingsRoutePage.tsx +++ b/src/renderer/src/pages/paintings/PaintingsRoutePage.tsx @@ -1,8 +1,10 @@ import { loggerService } from '@logger' +import { isNewApiProvider } from '@renderer/config/providers' +import { useAllProviders } from '@renderer/hooks/useProvider' import { useAppDispatch } from '@renderer/store' import { setDefaultPaintingProvider } from '@renderer/store/settings' -import { PaintingProvider } from '@renderer/types' -import { FC, useEffect } from 'react' +import { PaintingProvider, SystemProviderId } from '@renderer/types' +import { FC, useEffect, useMemo } from 'react' import { Route, Routes, useParams } from 'react-router-dom' import AihubmixPage from './AihubmixPage' @@ -14,19 +16,23 @@ import ZhipuPage from './ZhipuPage' const logger = loggerService.withContext('PaintingsRoutePage') -const Options = ['zhipu', 'aihubmix', 'silicon', 'dmxapi', 'tokenflux', 'new-api'] +const BASE_OPTIONS: SystemProviderId[] = ['zhipu', 'aihubmix', 'silicon', 'dmxapi', 'tokenflux'] const PaintingsRoutePage: FC = () => { const params = useParams() const provider = params['*'] const dispatch = useAppDispatch() + const providers = useAllProviders() + const Options = useMemo(() => { + return [...BASE_OPTIONS, ...providers.filter((p) => isNewApiProvider(p)).map((p) => p.id)] + }, [providers]) useEffect(() => { logger.debug(`defaultPaintingProvider: ${provider}`) if (provider && Options.includes(provider)) { dispatch(setDefaultPaintingProvider(provider as PaintingProvider)) } - }, [provider, dispatch]) + }, [provider, dispatch, Options]) return ( @@ -36,7 +42,12 @@ const PaintingsRoutePage: FC = () => { } /> } /> } /> - } /> + {/* new-api family providers are mounted dynamically below */} + {providers + .filter((p) => isNewApiProvider(p)) + .map((p) => ( + } /> + ))} ) } diff --git a/src/renderer/src/pages/paintings/SiliconPage.tsx b/src/renderer/src/pages/paintings/SiliconPage.tsx index 54f413f42..dac2577a4 100644 --- a/src/renderer/src/pages/paintings/SiliconPage.tsx +++ b/src/renderer/src/pages/paintings/SiliconPage.tsx @@ -12,14 +12,12 @@ import { HStack, VStack } from '@renderer/components/Layout' import Scrollbar from '@renderer/components/Scrollbar' import TranslateButton from '@renderer/components/TranslateButton' import { isMac } from '@renderer/config/constant' -import { getProviderLogo } from '@renderer/config/providers' import { LanguagesEnum } from '@renderer/config/translate' import { useTheme } from '@renderer/context/ThemeProvider' import { usePaintings } from '@renderer/hooks/usePaintings' import { useAllProviders } from '@renderer/hooks/useProvider' import { useRuntime } from '@renderer/hooks/useRuntime' import { useSettings } from '@renderer/hooks/useSettings' -import { getProviderLabel } from '@renderer/i18n/label' import { getProviderByModel } from '@renderer/services/AssistantService' import FileManager from '@renderer/services/FileManager' import { translateText } from '@renderer/services/TranslateService' @@ -27,7 +25,7 @@ import { useAppDispatch } from '@renderer/store' import { setGenerating } from '@renderer/store/runtime' import type { FileMetadata, Painting } from '@renderer/types' import { getErrorMessage, uuid } from '@renderer/utils' -import { Avatar, Button, Input, InputNumber, Radio, Select, Slider, Switch, Tooltip } from 'antd' +import { Button, Input, InputNumber, Radio, Select, Slider, Switch, Tooltip } from 'antd' import TextArea from 'antd/es/input/TextArea' import { Info } from 'lucide-react' import type { FC } from 'react' @@ -40,6 +38,7 @@ import SendMessageButton from '../home/Inputbar/SendMessageButton' import { SettingTitle } from '../settings' import Artboard from './components/Artboard' import PaintingsList from './components/PaintingsList' +import ProviderSelect from './components/ProviderSelect' import { checkProviderEnabled } from './utils' export const TEXT_TO_IMAGES_MODELS = [ @@ -115,22 +114,8 @@ const SiliconPage: FC<{ Options: string[] }> = ({ Options }) => { const [painting, setPainting] = useState(siliconflow_paintings[0] || DEFAULT_PAINTING) const { theme } = useTheme() const providers = useAllProviders() - const providerOptions = Options.map((option) => { - const provider = providers.find((p) => p.id === option) - if (provider) { - return { - label: getProviderLabel(provider.id), - value: provider.id - } - } else { - return { - label: 'Unknown Provider', - value: undefined - } - } - }) - const siliconflowProvider = providers.find((p) => p.id === 'silicon') + const siliconFlowProvider = providers.find((p) => p.id === 'silicon')! const [currentImageIndex, setCurrentImageIndex] = useState(0) const [isLoading, setIsLoading] = useState(false) @@ -170,7 +155,7 @@ const SiliconPage: FC<{ Options: string[] }> = ({ Options }) => { } const onGenerate = async () => { - await checkProviderEnabled(siliconflowProvider!, t) + await checkProviderEnabled(siliconFlowProvider!, t) if (painting.files.length > 0) { const confirmed = await window.modal.confirm({ @@ -389,17 +374,8 @@ const SiliconPage: FC<{ Options: string[] }> = ({ Options }) => { {t('common.provider')} - - {t('common.model')} + + {t('common.model')} p.value === 'tokenflux')?.value} - onChange={handleProviderChange} - style={{ width: '100%' }}> - {providerOptions.map((provider) => ( - - - - {provider.label} - - - ))} - + {/* Model & Pricing Section */} = ({ Options }) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [painting?.id]) // 只在painting的id改变时执行,避免无限循环 - const providerOptions = Options.map((option) => { - const provider = providers.find((p) => p.id === option) - if (provider) { - return { - label: getProviderLabel(provider.id), - value: provider.id - } - } else { - return { - label: 'Unknown Provider', - value: undefined - } - } - }) - const zhipuProvider = providers.find((p) => p.id === 'zhipu')! const [currentImageIndex, setCurrentImageIndex] = useState(0) @@ -370,16 +355,7 @@ const ZhipuPage: FC<{ Options: string[] }> = ({ Options }) => { />
      - + {t('common.model')} { + const selectedKey = Array.from(keys)[0] as string + onChange(selectedKey) + }} + style={style} + className={`w-full ${className || ''}`} + renderValue={(items) => { + return items.map((item) => ( +
      +
      + +
      + {item.textValue} +
      + )) + }}> + {providerOptions.map((providerOption) => ( + + + + }> + {providerOption.label} + + ))} + + ) +} + +export default ProviderSelect diff --git a/src/renderer/src/pages/settings/ProviderSettings/AddProviderPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/AddProviderPopup.tsx index f02485c45..5a2027959 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/AddProviderPopup.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/AddProviderPopup.tsx @@ -252,7 +252,8 @@ const PopupContainer: React.FC = ({ provider, resolve }) => { { label: 'OpenAI-Response', value: 'openai-response' }, { label: 'Gemini', value: 'gemini' }, { label: 'Anthropic', value: 'anthropic' }, - { label: 'Azure OpenAI', value: 'azure-openai' } + { label: 'Azure OpenAI', value: 'azure-openai' }, + { label: 'New API', value: 'new-api' } ]} /> diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 96bbdbd4e..7bdf186e6 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -273,6 +273,8 @@ export type PaintingParams = { id: string urls: string[] files: FileMetadata[] + // provider that this painting belongs to (for new-api family separation) + providerId?: string } export type PaintingProvider = 'zhipu' | 'aihubmix' | 'silicon' | 'dmxapi' | 'new-api' diff --git a/src/renderer/src/types/provider.ts b/src/renderer/src/types/provider.ts index e5233c196..782d8e98f 100644 --- a/src/renderer/src/types/provider.ts +++ b/src/renderer/src/types/provider.ts @@ -11,7 +11,8 @@ export const ProviderTypeSchema = z.enum([ 'vertexai', 'mistral', 'aws-bedrock', - 'vertex-anthropic' + 'vertex-anthropic', + 'new-api' ]) export type ProviderType = z.infer From f943f05cb12b45a41152459894782c2f854bdaf5 Mon Sep 17 00:00:00 2001 From: Phantom Date: Thu, 16 Oct 2025 19:23:09 +0800 Subject: [PATCH 19/39] fix(translate): auto copy failed (#10745) * fix(translate): auto copy failed Because translatedContent may be stale * refactor(translate): improve copy functionality dependency handling Update copy callback dependencies to include setCopied and ensure proper memoization Fix onCopy and translateText dependencies to include copy function --- .../src/pages/translate/TranslatePage.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/pages/translate/TranslatePage.tsx b/src/renderer/src/pages/translate/TranslatePage.tsx index e883955eb..76e50ce4b 100644 --- a/src/renderer/src/pages/translate/TranslatePage.tsx +++ b/src/renderer/src/pages/translate/TranslatePage.tsx @@ -134,15 +134,22 @@ const TranslatePage: FC = () => { ) // 控制复制行为 + const copy = useCallback( + async (text: string) => { + await navigator.clipboard.writeText(text) + setCopied(true) + }, + [setCopied] + ) + const onCopy = useCallback(async () => { try { - await navigator.clipboard.writeText(translatedContent) - setCopied(true) + await copy(translatedContent) } catch (error) { logger.error('Failed to copy text to clipboard:', error as Error) window.toast.error(t('common.copy_failed')) } - }, [setCopied, t, translatedContent]) + }, [copy, t, translatedContent]) /** * 翻译文本并保存历史记录,包含完整的异常处理,不会抛出异常 @@ -183,7 +190,7 @@ const TranslatePage: FC = () => { setTimeoutTimer( 'auto-copy', async () => { - await onCopy() + await copy(translated) }, 100 ) @@ -200,7 +207,7 @@ const TranslatePage: FC = () => { window.toast.error(t('translate.error.unknown') + ': ' + formatErrorMessage(e)) } }, - [autoCopy, dispatch, onCopy, setTimeoutTimer, setTranslatedContent, setTranslating, t, translating] + [autoCopy, copy, dispatch, setTimeoutTimer, setTranslatedContent, setTranslating, t, translating] ) // 控制翻译按钮是否可用 From 5eb2772d53e3e7a4ecda51ef3d00cdd9fc794fd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?George=C2=B7Dong?= <98630204+GeorgeDong32@users.noreply.github.com> Date: Thu, 16 Oct 2025 21:35:37 +0800 Subject: [PATCH 20/39] fix(minapps): can't open links in external broswer when using tab navigation (#10669) * fix(minapps): can't open links in external broswer when using tab navigation * fix(minapps): stabilize webview navigation and add logging * fix(minapps): debounce nav updates and robust webview attach --- .../components/MinApp/WebviewContainer.tsx | 20 +- .../minapps/components/MinimalToolbar.tsx | 174 ++++++++++++++++-- 2 files changed, 181 insertions(+), 13 deletions(-) diff --git a/src/renderer/src/components/MinApp/WebviewContainer.tsx b/src/renderer/src/components/MinApp/WebviewContainer.tsx index dcae087f6..545772ef0 100644 --- a/src/renderer/src/components/MinApp/WebviewContainer.tsx +++ b/src/renderer/src/components/MinApp/WebviewContainer.tsx @@ -25,7 +25,7 @@ const WebviewContainer = memo( onNavigateCallback: (appid: string, url: string) => void }) => { const webviewRef = useRef(null) - const { enableSpellCheck } = useSettings() + const { enableSpellCheck, minappsOpenLinkExternal } = useSettings() const setRef = (appid: string) => { onSetRefCallback(appid, null) @@ -76,6 +76,8 @@ const WebviewContainer = memo( const webviewId = webviewRef.current?.getWebContentsId() if (webviewId) { window.api?.webview?.setSpellCheckEnabled?.(webviewId, enableSpellCheck) + // Set link opening behavior for this webview + window.api?.webview?.setOpenLinkExternal?.(webviewId, minappsOpenLinkExternal) } } @@ -104,6 +106,22 @@ const WebviewContainer = memo( // eslint-disable-next-line react-hooks/exhaustive-deps }, [appid, url]) + // Update webview settings when they change + useEffect(() => { + if (!webviewRef.current) return + + try { + const webviewId = webviewRef.current.getWebContentsId() + if (webviewId) { + window.api?.webview?.setSpellCheckEnabled?.(webviewId, enableSpellCheck) + window.api?.webview?.setOpenLinkExternal?.(webviewId, minappsOpenLinkExternal) + } + } catch (error) { + // WebView may not be ready yet, settings will be applied in dom-ready event + logger.debug(`WebView ${appid} not ready for settings update`) + } + }, [appid, minappsOpenLinkExternal, enableSpellCheck]) + const WebviewStyle: React.CSSProperties = { width: '100%', height: '100%', diff --git a/src/renderer/src/pages/minapps/components/MinimalToolbar.tsx b/src/renderer/src/pages/minapps/components/MinimalToolbar.tsx index dcbe7adef..ef89fe640 100644 --- a/src/renderer/src/pages/minapps/components/MinimalToolbar.tsx +++ b/src/renderer/src/pages/minapps/components/MinimalToolbar.tsx @@ -8,6 +8,7 @@ import { PushpinOutlined, ReloadOutlined } from '@ant-design/icons' +import { loggerService } from '@logger' import { isDev } from '@renderer/config/constant' import { DEFAULT_MIN_APPS } from '@renderer/config/minapps' import { useMinapps } from '@renderer/hooks/useMinapps' @@ -17,11 +18,21 @@ import { setMinappsOpenLinkExternal } from '@renderer/store/settings' import { MinAppType } from '@renderer/types' import { Tooltip } from 'antd' import { WebviewTag } from 'electron' -import { FC, useCallback, useState } from 'react' +import { FC, useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router-dom' import styled from 'styled-components' +const logger = loggerService.withContext('MinimalToolbar') + +// Constants for timing delays +const WEBVIEW_CHECK_INITIAL_MS = 100 // Initial check interval +const WEBVIEW_CHECK_MAX_MS = 1000 // Maximum check interval (1 second) +const WEBVIEW_CHECK_MULTIPLIER = 2 // Exponential backoff multiplier +const WEBVIEW_CHECK_MAX_ATTEMPTS = 30 // Stop after ~30 seconds total +const NAVIGATION_UPDATE_DELAY_MS = 50 +const NAVIGATION_COMPLETE_DELAY_MS = 100 + interface Props { app: MinAppType webviewRef: React.RefObject @@ -42,27 +53,166 @@ const MinimalToolbar: FC = ({ app, webviewRef, currentUrl, onReload, onOp const isPinned = pinned.some((item) => item.id === app.id) const canOpenExternalLink = app.url.startsWith('http://') || app.url.startsWith('https://') + // Ref to track navigation update timeout + const navigationUpdateTimeoutRef = useRef(null) + // Update navigation state const updateNavigationState = useCallback(() => { if (webviewRef.current) { - setCanGoBack(webviewRef.current.canGoBack()) - setCanGoForward(webviewRef.current.canGoForward()) + try { + setCanGoBack(webviewRef.current.canGoBack()) + setCanGoForward(webviewRef.current.canGoForward()) + } catch (error) { + logger.debug('WebView not ready for navigation state update', { appId: app.id }) + setCanGoBack(false) + setCanGoForward(false) + } + } else { + setCanGoBack(false) + setCanGoForward(false) } - }, [webviewRef]) + }, [app.id, webviewRef]) + + // Schedule navigation state update with debouncing + const scheduleNavigationUpdate = useCallback( + (delay: number) => { + if (navigationUpdateTimeoutRef.current) { + clearTimeout(navigationUpdateTimeoutRef.current) + } + navigationUpdateTimeoutRef.current = setTimeout(() => { + updateNavigationState() + navigationUpdateTimeoutRef.current = null + }, delay) + }, + [updateNavigationState] + ) + + // Cleanup navigation timeout on unmount + useEffect(() => { + return () => { + if (navigationUpdateTimeoutRef.current) { + clearTimeout(navigationUpdateTimeoutRef.current) + } + } + }, []) + + // Monitor webviewRef changes and update navigation state + useEffect(() => { + let checkTimeout: NodeJS.Timeout | null = null + let navigationListener: (() => void) | null = null + let listenersAttached = false + let currentInterval = WEBVIEW_CHECK_INITIAL_MS + let attemptCount = 0 + + const attachListeners = () => { + if (webviewRef.current && !listenersAttached) { + // Update state immediately + updateNavigationState() + + // Add navigation event listeners + const handleNavigation = () => { + scheduleNavigationUpdate(NAVIGATION_UPDATE_DELAY_MS) + } + + webviewRef.current.addEventListener('did-navigate', handleNavigation) + webviewRef.current.addEventListener('did-navigate-in-page', handleNavigation) + listenersAttached = true + + navigationListener = () => { + if (webviewRef.current) { + webviewRef.current.removeEventListener('did-navigate', handleNavigation) + webviewRef.current.removeEventListener('did-navigate-in-page', handleNavigation) + } + listenersAttached = false + } + + if (checkTimeout) { + clearTimeout(checkTimeout) + checkTimeout = null + } + + logger.debug('Navigation listeners attached', { appId: app.id, attempts: attemptCount }) + return true + } + return false + } + + const scheduleCheck = () => { + checkTimeout = setTimeout(() => { + // Use requestAnimationFrame to avoid blocking the main thread + requestAnimationFrame(() => { + attemptCount++ + if (!attachListeners()) { + // Stop checking after max attempts to prevent infinite loops + if (attemptCount >= WEBVIEW_CHECK_MAX_ATTEMPTS) { + logger.warn('WebView attachment timeout', { + appId: app.id, + attempts: attemptCount, + totalTimeMs: currentInterval * attemptCount + }) + return + } + + // Exponential backoff: double the interval up to the maximum + currentInterval = Math.min(currentInterval * WEBVIEW_CHECK_MULTIPLIER, WEBVIEW_CHECK_MAX_MS) + + // Log only on first few attempts or when interval changes significantly + if (attemptCount <= 3 || attemptCount % 10 === 0) { + logger.debug('WebView not ready, scheduling next check', { + appId: app.id, + nextCheckMs: currentInterval, + attempt: attemptCount + }) + } + + scheduleCheck() + } + }) + }, currentInterval) + } + + // Check for webview attachment + if (!webviewRef.current) { + scheduleCheck() + } else { + attachListeners() + } + + // Cleanup + return () => { + if (checkTimeout) clearTimeout(checkTimeout) + if (navigationListener) navigationListener() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [app.id, updateNavigationState, scheduleNavigationUpdate]) // webviewRef excluded as it's a ref object const handleGoBack = useCallback(() => { - if (webviewRef.current && webviewRef.current.canGoBack()) { - webviewRef.current.goBack() - updateNavigationState() + if (webviewRef.current) { + try { + if (webviewRef.current.canGoBack()) { + webviewRef.current.goBack() + // Delay update to ensure navigation completes + scheduleNavigationUpdate(NAVIGATION_COMPLETE_DELAY_MS) + } + } catch (error) { + logger.debug('WebView not ready for navigation', { appId: app.id, action: 'goBack' }) + } } - }, [webviewRef, updateNavigationState]) + }, [app.id, webviewRef, scheduleNavigationUpdate]) const handleGoForward = useCallback(() => { - if (webviewRef.current && webviewRef.current.canGoForward()) { - webviewRef.current.goForward() - updateNavigationState() + if (webviewRef.current) { + try { + if (webviewRef.current.canGoForward()) { + webviewRef.current.goForward() + // Delay update to ensure navigation completes + scheduleNavigationUpdate(NAVIGATION_COMPLETE_DELAY_MS) + } + } catch (error) { + logger.debug('WebView not ready for navigation', { appId: app.id, action: 'goForward' }) + } } - }, [webviewRef, updateNavigationState]) + }, [app.id, webviewRef, scheduleNavigationUpdate]) const handleMinimize = useCallback(() => { navigate('/apps') From 9e0ee24fd795d5a813403303160343b546fd2a17 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Thu, 16 Oct 2025 22:45:41 +0800 Subject: [PATCH 21/39] fix: update Aihubmix auth URL to use console domain --- src/renderer/src/utils/oauth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/utils/oauth.ts b/src/renderer/src/utils/oauth.ts index 5d57547f6..00c549334 100644 --- a/src/renderer/src/utils/oauth.ts +++ b/src/renderer/src/utils/oauth.ts @@ -26,7 +26,7 @@ export const oauthWithSiliconFlow = async (setKey) => { } export const oauthWithAihubmix = async (setKey) => { - const authUrl = ` https://aihubmix.com/token?client_id=cherry_studio_oauth&lang=${getLanguageCode()}&aff=SJyh` + const authUrl = ` https://console.aihubmix.com/token?client_id=cherry_studio_oauth&lang=${getLanguageCode()}&aff=SJyh` const popup = window.open( authUrl, From 76271cbf77731297636a5589b7981d9f0e9950e6 Mon Sep 17 00:00:00 2001 From: Pleasure1234 <3196812536@qq.com> Date: Thu, 16 Oct 2025 17:20:33 +0100 Subject: [PATCH 22/39] fix: ensure API key rotation for each request (#10776) Updated ModernAiProvider to regenerate config on every request, ensuring API key rotation is effective. Refactored BaseApiClient to use an API key getter for dynamic key retrieval, supporting key rotation when multiple keys are configured. --- src/renderer/src/aiCore/index_new.ts | 6 ++---- .../src/aiCore/legacy/clients/BaseApiClient.ts | 10 ++++++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/aiCore/index_new.ts b/src/renderer/src/aiCore/index_new.ts index 81c4aa1df..b748e3c83 100644 --- a/src/renderer/src/aiCore/index_new.ts +++ b/src/renderer/src/aiCore/index_new.ts @@ -83,10 +83,8 @@ export default class ModernAiProvider { throw new Error('Model is required for completions. Please use constructor with model parameter.') } - // 确保配置存在 - if (!this.config) { - this.config = providerToAiSdkConfig(this.actualProvider, this.model) - } + // 每次请求时重新生成配置以确保API key轮换生效 + this.config = providerToAiSdkConfig(this.actualProvider, this.model) // 准备特殊配置 await prepareSpecialProviderConfig(this.actualProvider, this.config) diff --git a/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts b/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts index 9fb687a26..4a41db7c9 100644 --- a/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts @@ -70,13 +70,19 @@ export abstract class BaseApiClient< { public provider: Provider protected host: string - protected apiKey: string protected sdkInstance?: TSdkInstance constructor(provider: Provider) { this.provider = provider this.host = this.getBaseURL() - this.apiKey = this.getApiKey() + } + + /** + * Get the current API key with rotation support + * This getter ensures API keys rotate on each access when multiple keys are configured + */ + protected get apiKey(): string { + return this.getApiKey() } /** From 79c697c34d0d48f216279747e59babca1116946e Mon Sep 17 00:00:00 2001 From: Shemol Date: Fri, 17 Oct 2025 00:50:09 +0800 Subject: [PATCH 23/39] fix: preserve spaces in API keys; update i18n tips to use commas or newlines (#10751) fix(api): preserve spaces in API keys; i18n: clarify tips Tips now say "Use commas to separate multiple keys." Full-width commas are auto-normalized. --- src/renderer/src/i18n/locales/en-us.json | 2 +- src/renderer/src/i18n/locales/zh-cn.json | 2 +- src/renderer/src/i18n/locales/zh-tw.json | 2 +- src/renderer/src/utils/api.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 288f0964b..1f7ec61e2 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -4079,7 +4079,7 @@ "api_host_tooltip": "Override only when your provider requires a custom OpenAI-compatible endpoint.", "api_key": { "label": "API Key", - "tip": "Multiple keys separated by commas or spaces" + "tip": "Use commas to separate multiple keys" }, "api_version": "API Version", "aws-bedrock": { diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 3e6bac470..7926e7396 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -4079,7 +4079,7 @@ "api_host_tooltip": "仅在服务商需要自定义的 OpenAI 兼容地址时覆盖。", "api_key": { "label": "API 密钥", - "tip": "多个密钥使用逗号或空格分隔" + "tip": "多个密钥使用逗号分隔" }, "api_version": "API 版本", "aws-bedrock": { diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index b87e5e325..ab96bff08 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -4079,7 +4079,7 @@ "api_host_tooltip": "僅在服務商需要自訂的 OpenAI 相容端點時才覆蓋。", "api_key": { "label": "API 金鑰", - "tip": "多個金鑰使用逗號或空格分隔" + "tip": "多個金鑰使用逗號分隔" }, "api_version": "API 版本", "aws-bedrock": { diff --git a/src/renderer/src/utils/api.ts b/src/renderer/src/utils/api.ts index 62d0db562..550de8c7d 100644 --- a/src/renderer/src/utils/api.ts +++ b/src/renderer/src/utils/api.ts @@ -5,7 +5,7 @@ * @returns {string} 格式化后的 API key 字符串。 */ export function formatApiKeys(value: string): string { - return value.replaceAll(',', ',').replaceAll(' ', ',').replaceAll('\n', ',') + return value.replaceAll(',', ',').replaceAll('\n', ',') } /** From a290ee7f39e322a40826b931ab4ba38b555834eb Mon Sep 17 00:00:00 2001 From: Pleasure1234 <3196812536@qq.com> Date: Fri, 17 Oct 2025 02:40:41 +0100 Subject: [PATCH 24/39] fix: add array checks for knowledge and memories in citations (#10778) Updated formatCitationsFromBlock to verify that 'knowledge' and 'memories' are arrays before accessing their length and mapping over them. This prevents potential runtime errors if these properties are not arrays. --- src/renderer/src/store/messageBlock.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/store/messageBlock.ts b/src/renderer/src/store/messageBlock.ts index 524889bb3..458956e38 100644 --- a/src/renderer/src/store/messageBlock.ts +++ b/src/renderer/src/store/messageBlock.ts @@ -242,7 +242,7 @@ export const formatCitationsFromBlock = (block: CitationMessageBlock | undefined } } // 3. Handle Knowledge Base References - if (block.knowledge && block.knowledge.length > 0) { + if (block.knowledge && Array.isArray(block.knowledge) && block.knowledge.length > 0) { formattedCitations.push( ...block.knowledge.map((result, index) => { const filePattern = /\[(.*?)]\(http:\/\/file\/(.*?)\)/ @@ -270,7 +270,7 @@ export const formatCitationsFromBlock = (block: CitationMessageBlock | undefined ) } - if (block.memories && block.memories.length > 0) { + if (block.memories && Array.isArray(block.memories) && block.memories.length > 0) { // 5. Handle Memory References formattedCitations.push( ...block.memories.map((memory, index) => ({ From 1c2ce7e0aacc129ffbdd7590de19a12ca94a9370 Mon Sep 17 00:00:00 2001 From: defi-failure <159208748+defi-failure@users.noreply.github.com> Date: Fri, 17 Oct 2025 10:19:48 +0800 Subject: [PATCH 25/39] fix: agents show ChatNavbar in both LeftNavbar and TopNavbar layouts (#10718) * fix: show ChatNavbar in both LeftNavbar and TopNavbar layouts * Revert "fix: show ChatNavbar in both LeftNavbar and TopNavbar layouts" This reverts commit 7f205bf24122682cba78175b1152b85eafe03e96. * refactor: extract ChatNavBarContent from ChatNavBar * fix: add navbar content to top nav in left nav mode * fix: add nodrag to navbar container * fix: lint error * fix: ChatNavbarContainer layout * fix: adjust NavbarLeftContainer min-width for macOS compatibility --------- Co-authored-by: kangfenmao --- src/renderer/src/components/app/Navbar.tsx | 2 +- src/renderer/src/pages/home/ChatNavbar.tsx | 132 +---------------- src/renderer/src/pages/home/HomePage.tsx | 1 + src/renderer/src/pages/home/Navbar.tsx | 51 +++++-- .../home/components/ChatNavbarContent.tsx | 136 ++++++++++++++++++ 5 files changed, 186 insertions(+), 136 deletions(-) create mode 100644 src/renderer/src/pages/home/components/ChatNavbarContent.tsx diff --git a/src/renderer/src/components/app/Navbar.tsx b/src/renderer/src/components/app/Navbar.tsx index 1578e73a7..5ab2c486b 100644 --- a/src/renderer/src/components/app/Navbar.tsx +++ b/src/renderer/src/components/app/Navbar.tsx @@ -74,7 +74,7 @@ const NavbarContainer = styled.div<{ $isFullScreen: boolean }>` ` const NavbarLeftContainer = styled.div` - min-width: var(--assistants-width); + min-width: ${isMac ? 'calc(var(--assistants-width) - 20px)' : 'var(--assistants-width)'}; padding: 0 10px; display: flex; flex-direction: row; diff --git a/src/renderer/src/pages/home/ChatNavbar.tsx b/src/renderer/src/pages/home/ChatNavbar.tsx index f232aab91..093475413 100644 --- a/src/renderer/src/pages/home/ChatNavbar.tsx +++ b/src/renderer/src/pages/home/ChatNavbar.tsx @@ -1,34 +1,24 @@ -import { BreadcrumbItem, Breadcrumbs, Chip, cn } from '@heroui/react' import { NavbarHeader } from '@renderer/components/app/Navbar' -import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer' import { HStack } from '@renderer/components/Layout' import SearchPopup from '@renderer/components/Popups/SearchPopup' -import { permissionModeCards } from '@renderer/constants/permissionModes' -import { useAgent } from '@renderer/hooks/agents/useAgent' -import { useSession } from '@renderer/hooks/agents/useSession' -import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent' import { useAssistant } from '@renderer/hooks/useAssistant' -import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime' +import { modelGenerating } from '@renderer/hooks/useRuntime' import { useSettings } from '@renderer/hooks/useSettings' import { useShortcut } from '@renderer/hooks/useShortcuts' import { useShowAssistants, useShowTopics } from '@renderer/hooks/useStore' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { useAppDispatch } from '@renderer/store' import { setNarrowMode } from '@renderer/store/settings' -import { ApiModel, Assistant, PermissionMode, Topic } from '@renderer/types' -import { formatErrorMessageWithPrefix } from '@renderer/utils/error' +import { Assistant, Topic } from '@renderer/types' import { Tooltip } from 'antd' import { t } from 'i18next' import { Menu, PanelLeftClose, PanelRightClose, Search } from 'lucide-react' import { AnimatePresence, motion } from 'motion/react' -import React, { FC, ReactNode, useCallback } from 'react' +import { FC } from 'react' import styled from 'styled-components' -import { AgentSettingsPopup } from '../settings/AgentSettings' -import { AgentLabel } from '../settings/AgentSettings/shared' import AssistantsDrawer from './components/AssistantsDrawer' -import SelectAgentModelButton from './components/SelectAgentModelButton' -import SelectModelButton from './components/SelectModelButton' +import ChatNavbarContent from './components/ChatNavbarContent' import UpdateAppButton from './components/UpdateAppButton' interface Props { @@ -45,11 +35,6 @@ const HeaderNavbar: FC = ({ activeAssistant, setActiveAssistant, activeTo const { topicPosition, narrowMode } = useSettings() const { showTopics, toggleShowTopics } = useShowTopics() const dispatch = useAppDispatch() - const { chat } = useRuntime() - const { activeTopicOrSession, activeAgentId } = chat - const sessionId = activeAgentId ? (chat.activeSessionId[activeAgentId] ?? null) : null - const { agent } = useAgent(activeAgentId) - const { updateModel } = useUpdateAgent() useShortcut('toggle_show_assistants', toggleShowAssistants) @@ -79,14 +64,6 @@ const HeaderNavbar: FC = ({ activeAssistant, setActiveAssistant, activeTo }) } - const handleUpdateModel = useCallback( - async (model: ApiModel) => { - if (!agent) return - return updateModel(agent.id, model.id, { showSuccessToast: false }) - }, - [agent, updateModel] - ) - return (
      @@ -117,38 +94,7 @@ const HeaderNavbar: FC = ({ activeAssistant, setActiveAssistant, activeTo )} - {activeTopicOrSession === 'topic' && } - {activeTopicOrSession === 'session' && agent && ( - - - AgentSettingsPopup.show({ agentId: agent.id })} - classNames={{ - base: 'self-stretch', - item: 'h-full' - }}> - - - - - - - - {activeAgentId && sessionId && ( - - - - )} - - - )} +
      @@ -181,74 +127,6 @@ const HeaderNavbar: FC = ({ activeAssistant, setActiveAssistant, activeTo ) } -const SessionWorkspaceMeta: FC<{ agentId: string; sessionId: string }> = ({ agentId, sessionId }) => { - const { agent } = useAgent(agentId) - const { session } = useSession(agentId, sessionId) - if (!session || !agent) { - return null - } - - const firstAccessiblePath = session.accessible_paths?.[0] - const permissionMode = (session.configuration?.permission_mode ?? 'default') as PermissionMode - const permissionModeCard = permissionModeCards.find((card) => card.mode === permissionMode) - const permissionModeLabel = permissionModeCard - ? t(permissionModeCard.titleKey, permissionModeCard.titleFallback) - : permissionMode - - const infoItems: ReactNode[] = [] - - const InfoTag = ({ - text, - className, - onClick - }: { - text: string - className?: string - classNames?: {} - onClick?: (e: React.MouseEvent) => void - }) => ( -
      - {text} -
      - ) - - // infoItems.push() - - if (firstAccessiblePath) { - infoItems.push( - { - window.api.file - .openPath(firstAccessiblePath) - .catch((e) => - window.toast.error( - formatErrorMessageWithPrefix(e, t('files.error.open_path', { path: firstAccessiblePath })) - ) - ) - }} - /> - ) - } - - infoItems.push() - - if (infoItems.length === 0) { - return null - } - - return
      {infoItems}
      -} - export const NavbarIcon = styled.div` -webkit-app-region: none; border-radius: 8px; diff --git a/src/renderer/src/pages/home/HomePage.tsx b/src/renderer/src/pages/home/HomePage.tsx index cd18552a3..32f1ec397 100644 --- a/src/renderer/src/pages/home/HomePage.tsx +++ b/src/renderer/src/pages/home/HomePage.tsx @@ -130,6 +130,7 @@ const HomePage: FC = () => { setActiveTopic={setActiveTopic} setActiveAssistant={setActiveAssistant} position="left" + activeTopicOrSession={activeTopicOrSession} /> )} diff --git a/src/renderer/src/pages/home/Navbar.tsx b/src/renderer/src/pages/home/Navbar.tsx index 43217d7ca..6d9ccc228 100644 --- a/src/renderer/src/pages/home/Navbar.tsx +++ b/src/renderer/src/pages/home/Navbar.tsx @@ -1,4 +1,4 @@ -import { Navbar, NavbarLeft, NavbarRight } from '@renderer/components/app/Navbar' +import { Navbar, NavbarCenter, NavbarLeft, NavbarRight } from '@renderer/components/app/Navbar' import { HStack } from '@renderer/components/Layout' import SearchPopup from '@renderer/components/Popups/SearchPopup' import { isLinux, isMac, isWin } from '@renderer/config/constant' @@ -7,6 +7,8 @@ import { modelGenerating } from '@renderer/hooks/useRuntime' import { useSettings } from '@renderer/hooks/useSettings' import { useShortcut } from '@renderer/hooks/useShortcuts' import { useShowAssistants, useShowTopics } from '@renderer/hooks/useStore' +import { useChatMaxWidth } from '@renderer/pages/home/Chat' +import ChatNavbarContent from '@renderer/pages/home/components/ChatNavbarContent' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { useAppDispatch } from '@renderer/store' import { setNarrowMode } from '@renderer/store/settings' @@ -15,7 +17,7 @@ import { Tooltip } from 'antd' import { t } from 'i18next' import { Menu, PanelLeftClose, PanelRightClose, Search } from 'lucide-react' import { AnimatePresence, motion } from 'motion/react' -import { FC } from 'react' +import React, { FC } from 'react' import styled from 'styled-components' import AssistantsDrawer from './components/AssistantsDrawer' @@ -28,13 +30,21 @@ interface Props { setActiveTopic: (topic: Topic) => void setActiveAssistant: (assistant: Assistant) => void position: 'left' | 'right' + activeTopicOrSession?: 'topic' | 'session' } -const HeaderNavbar: FC = ({ activeAssistant, setActiveAssistant, activeTopic, setActiveTopic }) => { +const HeaderNavbar: FC = ({ + activeAssistant, + setActiveAssistant, + activeTopic, + setActiveTopic, + activeTopicOrSession +}) => { const { assistant } = useAssistant(activeAssistant.id) const { showAssistants, toggleShowAssistants } = useShowAssistants() const { topicPosition, narrowMode } = useSettings() const { showTopics, toggleShowTopics } = useShowTopics() + const chatMaxWidth = useChatMaxWidth() const dispatch = useAppDispatch() useShortcut('toggle_show_assistants', toggleShowAssistants) @@ -113,15 +123,29 @@ const HeaderNavbar: FC = ({ activeAssistant, setActiveAssistant, activeTo )} - - - + + {activeTopicOrSession === 'topic' ? ( + + + + ) : ( + + + + )} + + @@ -196,4 +220,15 @@ const NarrowIcon = styled(NavbarIcon)` } ` +const ChatNavbarContainer: React.FC<{ children: React.ReactNode; style?: React.CSSProperties }> = ({ + children, + style +}) => { + return ( +
      + {children} +
      + ) +} + export default HeaderNavbar diff --git a/src/renderer/src/pages/home/components/ChatNavbarContent.tsx b/src/renderer/src/pages/home/components/ChatNavbarContent.tsx new file mode 100644 index 000000000..5d89bfd94 --- /dev/null +++ b/src/renderer/src/pages/home/components/ChatNavbarContent.tsx @@ -0,0 +1,136 @@ +import { BreadcrumbItem, Breadcrumbs, Chip, cn } from '@heroui/react' +import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer' +import { permissionModeCards } from '@renderer/constants/permissionModes' +import { useAgent } from '@renderer/hooks/agents/useAgent' +import { useSession } from '@renderer/hooks/agents/useSession' +import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent' +import { useRuntime } from '@renderer/hooks/useRuntime' +import { ApiModel, Assistant, PermissionMode } from '@renderer/types' +import { formatErrorMessageWithPrefix } from '@renderer/utils/error' +import { t } from 'i18next' +import { FC, ReactNode, useCallback } from 'react' + +import { AgentSettingsPopup } from '../../settings/AgentSettings' +import { AgentLabel } from '../../settings/AgentSettings/shared' +import SelectAgentModelButton from './SelectAgentModelButton' +import SelectModelButton from './SelectModelButton' + +interface Props { + assistant: Assistant +} + +const ChatNavbarContent: FC = ({ assistant }) => { + const { chat } = useRuntime() + const { activeTopicOrSession, activeAgentId } = chat + const sessionId = activeAgentId ? (chat.activeSessionId[activeAgentId] ?? null) : null + const { agent } = useAgent(activeAgentId) + const { updateModel } = useUpdateAgent() + + const handleUpdateModel = useCallback( + async (model: ApiModel) => { + if (!agent) return + return updateModel(agent.id, model.id, { showSuccessToast: false }) + }, + [agent, updateModel] + ) + + return ( + <> + {activeTopicOrSession === 'topic' && } + {activeTopicOrSession === 'session' && agent && ( + + + AgentSettingsPopup.show({ agentId: agent.id })} + classNames={{ base: 'self-stretch', item: 'h-full' }}> + + + + + + + + {activeAgentId && sessionId && ( + + + + )} + + + )} + + ) +} + +const SessionWorkspaceMeta: FC<{ agentId: string; sessionId: string }> = ({ agentId, sessionId }) => { + const { agent } = useAgent(agentId) + const { session } = useSession(agentId, sessionId) + if (!session || !agent) { + return null + } + + const firstAccessiblePath = session.accessible_paths?.[0] + const permissionMode = (session.configuration?.permission_mode ?? 'default') as PermissionMode + const permissionModeCard = permissionModeCards.find((card) => card.mode === permissionMode) + const permissionModeLabel = permissionModeCard + ? t(permissionModeCard.titleKey, permissionModeCard.titleFallback) + : permissionMode + + const infoItems: ReactNode[] = [] + + const InfoTag = ({ + text, + className, + onClick + }: { + text: string + className?: string + classNames?: {} + onClick?: (e: React.MouseEvent) => void + }) => ( +
      + {text} +
      + ) + + // infoItems.push() + + if (firstAccessiblePath) { + infoItems.push( + { + window.api.file + .openPath(firstAccessiblePath) + .catch((e) => + window.toast.error( + formatErrorMessageWithPrefix(e, t('files.error.open_path', { path: firstAccessiblePath })) + ) + ) + }} + /> + ) + } + + infoItems.push() + + if (infoItems.length === 0) { + return null + } + + return
      {infoItems}
      +} + +export default ChatNavbarContent From dc5bc64040b8e4ddb2293fc0d454543b098f3e12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=A2=E5=A5=8B=E7=8C=AB?= Date: Fri, 17 Oct 2025 10:36:36 +0800 Subject: [PATCH 26/39] =?UTF-8?q?fix:=20update=20default=20enableTopP=20se?= =?UTF-8?q?tting=20to=20false=20in=20AssistantModelSett=E2=80=A6=20(#10754?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: update default enableTopP setting to false in AssistantModelSettings and DefaultAssistantSettings - Changed default value of enableTopP from true to false in AssistantModelSettings and DefaultAssistantSettings components. - Updated related logic to ensure consistent behavior across settings. --- .../settings/AssistantSettings/AssistantModelSettings.tsx | 6 +++--- .../settings/ModelSettings/DefaultAssistantSettings.tsx | 6 +++--- src/renderer/src/services/AssistantService.ts | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx b/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx index e69f32251..a2efdf387 100644 --- a/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx +++ b/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx @@ -33,7 +33,7 @@ const AssistantModelSettings: FC = ({ assistant, updateAssistant, updateA const [toolUseMode, setToolUseMode] = useState(assistant?.settings?.toolUseMode ?? 'prompt') const [defaultModel, setDefaultModel] = useState(assistant?.defaultModel) const [topP, setTopP] = useState(assistant?.settings?.topP ?? 1) - const [enableTopP, setEnableTopP] = useState(assistant?.settings?.enableTopP ?? true) + const [enableTopP, setEnableTopP] = useState(assistant?.settings?.enableTopP ?? false) const [customParameters, setCustomParameters] = useState( assistant?.settings?.customParameters ?? [] ) @@ -164,7 +164,7 @@ const AssistantModelSettings: FC = ({ assistant, updateAssistant, updateA setMaxTokens(0) setStreamOutput(true) setTopP(1) - setEnableTopP(true) + setEnableTopP(false) setCustomParameters([]) setToolUseMode('prompt') updateAssistantSettings({ @@ -175,7 +175,7 @@ const AssistantModelSettings: FC = ({ assistant, updateAssistant, updateA maxTokens: 0, streamOutput: true, topP: 1, - enableTopP: true, + enableTopP: false, customParameters: [], toolUseMode: 'prompt' }) diff --git a/src/renderer/src/pages/settings/ModelSettings/DefaultAssistantSettings.tsx b/src/renderer/src/pages/settings/ModelSettings/DefaultAssistantSettings.tsx index b05917464..096327416 100644 --- a/src/renderer/src/pages/settings/ModelSettings/DefaultAssistantSettings.tsx +++ b/src/renderer/src/pages/settings/ModelSettings/DefaultAssistantSettings.tsx @@ -24,7 +24,7 @@ const AssistantSettings: FC = () => { const [enableMaxTokens, setEnableMaxTokens] = useState(defaultAssistant?.settings?.enableMaxTokens ?? false) const [maxTokens, setMaxTokens] = useState(defaultAssistant?.settings?.maxTokens ?? 0) const [topP, setTopP] = useState(defaultAssistant.settings?.topP ?? 1) - const [enableTopP, setEnableTopP] = useState(defaultAssistant.settings?.enableTopP ?? true) + const [enableTopP, setEnableTopP] = useState(defaultAssistant.settings?.enableTopP ?? false) const [emoji, setEmoji] = useState(defaultAssistant.emoji || getLeadingEmoji(defaultAssistant.name) || '') const [name, setName] = useState( defaultAssistant.name.replace(getLeadingEmoji(defaultAssistant.name) || '', '').trim() @@ -71,7 +71,7 @@ const AssistantSettings: FC = () => { setEnableMaxTokens(false) setMaxTokens(0) setTopP(1) - setEnableTopP(true) + setEnableTopP(false) updateDefaultAssistant({ ...defaultAssistant, settings: { @@ -83,7 +83,7 @@ const AssistantSettings: FC = () => { maxTokens: DEFAULT_MAX_TOKENS, streamOutput: true, topP: 1, - enableTopP: true + enableTopP: false } }) } diff --git a/src/renderer/src/services/AssistantService.ts b/src/renderer/src/services/AssistantService.ts index 2b083f48b..2bfcde9c7 100644 --- a/src/renderer/src/services/AssistantService.ts +++ b/src/renderer/src/services/AssistantService.ts @@ -35,7 +35,7 @@ export const DEFAULT_ASSISTANT_SETTINGS: AssistantSettings = { maxTokens: 0, streamOutput: true, topP: 1, - enableTopP: true, + enableTopP: false, toolUseMode: 'prompt', customParameters: [] } @@ -169,7 +169,7 @@ export const getAssistantSettings = (assistant: Assistant): AssistantSettings => temperature: assistant?.settings?.temperature ?? DEFAULT_TEMPERATURE, enableTemperature: assistant?.settings?.enableTemperature ?? true, topP: assistant?.settings?.topP ?? 1, - enableTopP: assistant?.settings?.enableTopP ?? true, + enableTopP: assistant?.settings?.enableTopP ?? false, enableMaxTokens: assistant?.settings?.enableMaxTokens ?? false, maxTokens: getAssistantMaxTokens(), streamOutput: assistant?.settings?.streamOutput ?? true, From fb680ce7645bfd1283e90c64b353f009f46a7f15 Mon Sep 17 00:00:00 2001 From: SongSong <32863696+SmallSongSong@users.noreply.github.com> Date: Fri, 17 Oct 2025 10:37:07 +0800 Subject: [PATCH 27/39] feat: add built-in DiDi MCP server integration (#10318) * feat: add built-in DiDi MCP server integration - Add DiDi MCP server implementation with ride-hailing services - Support map search, price estimation, order management, and driver tracking - Add multilingual translations for DiDi MCP server descriptions - Available only in mainland China, requires DIDI_API_KEY environment variable * fix: resolve code formatting issues in DiDi MCP server fixes code formatting issues in the DiDi MCP server implementation to resolve CI format check failures. --------- Co-authored-by: BillySong --- src/main/mcpServers/didi-mcp.ts | 473 +++++++++++++++++++++ src/main/mcpServers/factory.ts | 5 + src/renderer/src/i18n/label.ts | 3 +- src/renderer/src/i18n/locales/en-us.json | 1 + src/renderer/src/i18n/locales/zh-cn.json | 1 + src/renderer/src/i18n/locales/zh-tw.json | 1 + src/renderer/src/i18n/translate/el-gr.json | 1 + src/renderer/src/i18n/translate/es-es.json | 1 + src/renderer/src/i18n/translate/fr-fr.json | 1 + src/renderer/src/i18n/translate/ja-jp.json | 1 + src/renderer/src/i18n/translate/pt-pt.json | 1 + src/renderer/src/i18n/translate/ru-ru.json | 1 + src/renderer/src/store/mcp.ts | 12 + src/renderer/src/types/index.ts | 3 +- 14 files changed, 503 insertions(+), 2 deletions(-) create mode 100644 src/main/mcpServers/didi-mcp.ts diff --git a/src/main/mcpServers/didi-mcp.ts b/src/main/mcpServers/didi-mcp.ts new file mode 100644 index 000000000..905fc4ff8 --- /dev/null +++ b/src/main/mcpServers/didi-mcp.ts @@ -0,0 +1,473 @@ +/** + * DiDi MCP Server Implementation + * + * Based on official DiDi MCP API capabilities. + * API Documentation: https://mcp.didichuxing.com/api?tap=api + * + * Provides ride-hailing services including map search, price estimation, + * order management, and driver tracking. + * + * Note: Only available in Mainland China. + */ + +import { loggerService } from '@logger' +import { Server } from '@modelcontextprotocol/sdk/server/index.js' +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' + +const logger = loggerService.withContext('DiDiMCPServer') + +export class DiDiMcpServer { + private _server: Server + private readonly baseUrl = 'http://mcp.didichuxing.com/mcp-servers' + private apiKey: string + + constructor(apiKey?: string) { + this._server = new Server( + { + name: 'didi-mcp-server', + version: '0.1.0' + }, + { + capabilities: { + tools: {} + } + } + ) + + // Get API key from parameter or environment variables + this.apiKey = apiKey || process.env.DIDI_API_KEY || '' + if (!this.apiKey) { + logger.warn('DIDI_API_KEY environment variable is not set') + } + + this.setupRequestHandlers() + } + + get server(): Server { + return this._server + } + + private setupRequestHandlers() { + // List available tools + this._server.setRequestHandler(ListToolsRequestSchema, async () => { + return { + tools: [ + { + name: 'maps_textsearch', + description: 'Search for POI locations based on keywords and city', + inputSchema: { + type: 'object', + properties: { + city: { + type: 'string', + description: 'Query city' + }, + keywords: { + type: 'string', + description: 'Search keywords' + }, + location: { + type: 'string', + description: 'Location coordinates, format: longitude,latitude' + } + }, + required: ['keywords', 'city'] + } + }, + { + name: 'taxi_cancel_order', + description: 'Cancel a taxi order', + inputSchema: { + type: 'object', + properties: { + order_id: { + type: 'string', + description: 'Order ID from order creation or query results' + }, + reason: { + type: 'string', + description: + 'Cancellation reason (optional). Examples: no longer needed, waiting too long, urgent matter' + } + }, + required: ['order_id'] + } + }, + { + name: 'taxi_create_order', + description: 'Create taxi order directly via API without opening any app interface', + inputSchema: { + type: 'object', + properties: { + caller_car_phone: { + type: 'string', + description: 'Caller phone number (optional)' + }, + estimate_trace_id: { + type: 'string', + description: 'Estimation trace ID from estimation results' + }, + product_category: { + type: 'string', + description: 'Vehicle category ID from estimation results, comma-separated for multiple types' + } + }, + required: ['product_category', 'estimate_trace_id'] + } + }, + { + name: 'taxi_estimate', + description: 'Get available ride-hailing vehicle types and fare estimates', + inputSchema: { + type: 'object', + properties: { + from_lat: { + type: 'string', + description: 'Departure latitude, must be from map tools' + }, + from_lng: { + type: 'string', + description: 'Departure longitude, must be from map tools' + }, + from_name: { + type: 'string', + description: 'Departure location name' + }, + to_lat: { + type: 'string', + description: 'Destination latitude, must be from map tools' + }, + to_lng: { + type: 'string', + description: 'Destination longitude, must be from map tools' + }, + to_name: { + type: 'string', + description: 'Destination name' + } + }, + required: ['from_lng', 'from_lat', 'from_name', 'to_lng', 'to_lat', 'to_name'] + } + }, + { + name: 'taxi_generate_ride_app_link', + description: 'Generate deep links to open ride-hailing apps based on origin, destination and vehicle type', + inputSchema: { + type: 'object', + properties: { + from_lat: { + type: 'string', + description: 'Departure latitude, must be from map tools' + }, + from_lng: { + type: 'string', + description: 'Departure longitude, must be from map tools' + }, + product_category: { + type: 'string', + description: 'Vehicle category IDs from estimation results, comma-separated for multiple types' + }, + to_lat: { + type: 'string', + description: 'Destination latitude, must be from map tools' + }, + to_lng: { + type: 'string', + description: 'Destination longitude, must be from map tools' + } + }, + required: ['from_lng', 'from_lat', 'to_lng', 'to_lat'] + } + }, + { + name: 'taxi_get_driver_location', + description: 'Get real-time driver location for a taxi order', + inputSchema: { + type: 'object', + properties: { + order_id: { + type: 'string', + description: 'Taxi order ID' + } + }, + required: ['order_id'] + } + }, + { + name: 'taxi_query_order', + description: 'Query taxi order status and information such as driver contact, license plate, ETA', + inputSchema: { + type: 'object', + properties: { + order_id: { + type: 'string', + description: 'Order ID from order creation results, if available; otherwise queries incomplete orders' + } + } + } + } + ] + } + }) + + // Handle tool calls + this._server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params + + try { + switch (name) { + case 'maps_textsearch': + return await this.handleMapsTextSearch(args) + case 'taxi_cancel_order': + return await this.handleTaxiCancelOrder(args) + case 'taxi_create_order': + return await this.handleTaxiCreateOrder(args) + case 'taxi_estimate': + return await this.handleTaxiEstimate(args) + case 'taxi_generate_ride_app_link': + return await this.handleTaxiGenerateRideAppLink(args) + case 'taxi_get_driver_location': + return await this.handleTaxiGetDriverLocation(args) + case 'taxi_query_order': + return await this.handleTaxiQueryOrder(args) + default: + throw new Error(`Unknown tool: ${name}`) + } + } catch (error) { + logger.error(`Error calling tool ${name}:`, error as Error) + throw error + } + }) + } + + private async handleMapsTextSearch(args: any) { + const { city, keywords, location } = args + + const params = { + name: 'maps_textsearch', + arguments: { + keywords, + city, + ...(location && { location }) + } + } + + try { + const response = await this.makeRequest('tools/call', params) + return { + content: [ + { + type: 'text', + text: JSON.stringify(response, null, 2) + } + ] + } + } catch (error) { + logger.error('Maps text search error:', error as Error) + throw error + } + } + + private async handleTaxiCancelOrder(args: any) { + const { order_id, reason } = args + + const params = { + name: 'taxi_cancel_order', + arguments: { + order_id, + ...(reason && { reason }) + } + } + + try { + const response = await this.makeRequest('tools/call', params) + return { + content: [ + { + type: 'text', + text: JSON.stringify(response, null, 2) + } + ] + } + } catch (error) { + logger.error('Taxi cancel order error:', error as Error) + throw error + } + } + + private async handleTaxiCreateOrder(args: any) { + const { caller_car_phone, estimate_trace_id, product_category } = args + + const params = { + name: 'taxi_create_order', + arguments: { + product_category, + estimate_trace_id, + ...(caller_car_phone && { caller_car_phone }) + } + } + + try { + const response = await this.makeRequest('tools/call', params) + return { + content: [ + { + type: 'text', + text: JSON.stringify(response, null, 2) + } + ] + } + } catch (error) { + logger.error('Taxi create order error:', error as Error) + throw error + } + } + + private async handleTaxiEstimate(args: any) { + const { from_lng, from_lat, from_name, to_lng, to_lat, to_name } = args + + const params = { + name: 'taxi_estimate', + arguments: { + from_lng, + from_lat, + from_name, + to_lng, + to_lat, + to_name + } + } + + try { + const response = await this.makeRequest('tools/call', params) + return { + content: [ + { + type: 'text', + text: JSON.stringify(response, null, 2) + } + ] + } + } catch (error) { + logger.error('Taxi estimate error:', error as Error) + throw error + } + } + + private async handleTaxiGenerateRideAppLink(args: any) { + const { from_lng, from_lat, to_lng, to_lat, product_category } = args + + const params = { + name: 'taxi_generate_ride_app_link', + arguments: { + from_lng, + from_lat, + to_lng, + to_lat, + ...(product_category && { product_category }) + } + } + + try { + const response = await this.makeRequest('tools/call', params) + return { + content: [ + { + type: 'text', + text: JSON.stringify(response, null, 2) + } + ] + } + } catch (error) { + logger.error('Taxi generate ride app link error:', error as Error) + throw error + } + } + + private async handleTaxiGetDriverLocation(args: any) { + const { order_id } = args + + const params = { + name: 'taxi_get_driver_location', + arguments: { + order_id + } + } + + try { + const response = await this.makeRequest('tools/call', params) + return { + content: [ + { + type: 'text', + text: JSON.stringify(response, null, 2) + } + ] + } + } catch (error) { + logger.error('Taxi get driver location error:', error as Error) + throw error + } + } + + private async handleTaxiQueryOrder(args: any) { + const { order_id } = args + + const params = { + name: 'taxi_query_order', + arguments: { + ...(order_id && { order_id }) + } + } + + try { + const response = await this.makeRequest('tools/call', params) + return { + content: [ + { + type: 'text', + text: JSON.stringify(response, null, 2) + } + ] + } + } catch (error) { + logger.error('Taxi query order error:', error as Error) + throw error + } + } + + private async makeRequest(method: string, params: any): Promise { + const requestData = { + jsonrpc: '2.0', + method: method, + id: Date.now(), + ...(Object.keys(params).length > 0 && { params }) + } + + // API key is passed as URL parameter + const url = `${this.baseUrl}?key=${this.apiKey}` + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(requestData) + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`HTTP ${response.status}: ${errorText}`) + } + + const data = await response.json() + + if (data.error) { + throw new Error(`API Error: ${JSON.stringify(data.error)}`) + } + + return data.result + } +} + +export default DiDiMcpServer diff --git a/src/main/mcpServers/factory.ts b/src/main/mcpServers/factory.ts index 46d3bb87d..74266d7d3 100644 --- a/src/main/mcpServers/factory.ts +++ b/src/main/mcpServers/factory.ts @@ -3,6 +3,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js' import { BuiltinMCPServerName, BuiltinMCPServerNames } from '@types' import BraveSearchServer from './brave-search' +import DiDiMcpServer from './didi-mcp' import DifyKnowledgeServer from './dify-knowledge' import FetchServer from './fetch' import FileSystemServer from './filesystem' @@ -42,6 +43,10 @@ export function createInMemoryMCPServer( case BuiltinMCPServerNames.python: { return new PythonServer().server } + case BuiltinMCPServerNames.didiMCP: { + const apiKey = envs.DIDI_API_KEY + return new DiDiMcpServer(apiKey).server + } default: throw new Error(`Unknown in-memory MCP server: ${name}`) } diff --git a/src/renderer/src/i18n/label.ts b/src/renderer/src/i18n/label.ts index 376aec32e..c2ac69f1b 100644 --- a/src/renderer/src/i18n/label.ts +++ b/src/renderer/src/i18n/label.ts @@ -329,7 +329,8 @@ const builtInMcpDescriptionKeyMap: Record = { [BuiltinMCPServerNames.fetch]: 'settings.mcp.builtinServersDescriptions.fetch', [BuiltinMCPServerNames.filesystem]: 'settings.mcp.builtinServersDescriptions.filesystem', [BuiltinMCPServerNames.difyKnowledge]: 'settings.mcp.builtinServersDescriptions.dify_knowledge', - [BuiltinMCPServerNames.python]: 'settings.mcp.builtinServersDescriptions.python' + [BuiltinMCPServerNames.python]: 'settings.mcp.builtinServersDescriptions.python', + [BuiltinMCPServerNames.didiMCP]: 'settings.mcp.builtinServersDescriptions.didi_mcp' } as const export const getBuiltInMcpServerDescriptionLabel = (key: string): string => { diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 1f7ec61e2..ee3d0ff11 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -3574,6 +3574,7 @@ "builtinServers": "Builtin Servers", "builtinServersDescriptions": { "brave_search": "An MCP server implementation integrating the Brave Search API, providing both web and local search functionalities. Requires configuring the BRAVE_API_KEY environment variable", + "didi_mcp": "DiDi MCP server providing ride-hailing services including map search, price estimation, order management, and driver tracking. Only available in Mainland China. Requires configuring the DIDI_API_KEY environment variable", "dify_knowledge": "Dify's MCP server implementation provides a simple API to interact with Dify. Requires configuring the Dify Key", "fetch": "MCP server for retrieving URL web content", "filesystem": "A Node.js server implementing the Model Context Protocol (MCP) for file system operations. Requires configuration of directories allowed for access.", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 7926e7396..e5573221e 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -3574,6 +3574,7 @@ "builtinServers": "内置服务器", "builtinServersDescriptions": { "brave_search": "一个集成了Brave 搜索 API 的 MCP 服务器实现,提供网页与本地搜索双重功能。需要配置 BRAVE_API_KEY 环境变量", + "didi_mcp": "一个集成了滴滴 MCP 服务器实现,提供网约车服务包括地图搜索、价格预估、订单管理和司机跟踪。仅支持中国大陆地区。需要配置 DIDI_API_KEY 环境变量", "dify_knowledge": "Dify 的 MCP 服务器实现,提供了一个简单的 API 来与 Dify 进行交互。需要配置 Dify Key", "fetch": "用于获取 URL 网页内容的 MCP 服务器", "filesystem": "实现文件系统操作的模型上下文协议(MCP)的 Node.js 服务器。需要配置允许访问的目录", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index ab96bff08..8634254e5 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -3574,6 +3574,7 @@ "builtinServers": "內置伺服器", "builtinServersDescriptions": { "brave_search": "一個集成了Brave 搜索 API 的 MCP 伺服器實現,提供網頁與本地搜尋雙重功能。需要配置 BRAVE_API_KEY 環境變數", + "didi_mcp": "一個集成了滴滴 MCP 伺服器實現,提供網約車服務包括地圖搜尋、價格預估、訂單管理和司機追蹤。僅支援中國大陸地區。需要配置 DIDI_API_KEY 環境變數", "dify_knowledge": "Dify 的 MCP 伺服器實現,提供了一個簡單的 API 來與 Dify 進行互動。需要配置 Dify Key", "fetch": "用於獲取 URL 網頁內容的 MCP 伺服器", "filesystem": "實現文件系統操作的模型上下文協議(MCP)的 Node.js 伺服器。需要配置允許訪問的目錄", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index a39c429d1..370f3ce65 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -3574,6 +3574,7 @@ "builtinServers": "Ενσωματωμένοι Διακομιστές", "builtinServersDescriptions": { "brave_search": "μια εφαρμογή διακομιστή MCP που ενσωματώνει το Brave Search API, παρέχοντας δυνατότητες αναζήτησης στον ιστό και τοπικής αναζήτησης. Απαιτείται η ρύθμιση της μεταβλητής περιβάλλοντος BRAVE_API_KEY", + "didi_mcp": "Διακομιστής DiDi MCP που παρέχει υπηρεσίες μεταφοράς συμπεριλαμβανομένης της αναζήτησης χαρτών, εκτίμησης τιμών, διαχείρισης παραγγελιών και παρακολούθησης οδηγών. Διαθέσιμο μόνο στην ηπειρωτική Κίνα. Απαιτεί διαμόρφωση της μεταβλητής περιβάλλοντος DIDI_API_KEY", "dify_knowledge": "Η υλοποίηση του Dify για τον διακομιστή MCP, παρέχει μια απλή API για να αλληλεπιδρά με το Dify. Απαιτείται η ρύθμιση του κλειδιού Dify", "fetch": "Εξυπηρετητής MCP για λήψη περιεχομένου ιστοσελίδας URL", "filesystem": "Εξυπηρετητής Node.js για το πρωτόκολλο περιβάλλοντος μοντέλου (MCP) που εφαρμόζει λειτουργίες συστήματος αρχείων. Απαιτείται διαμόρφωση για την επιτροπή πρόσβασης σε καταλόγους", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 219f80918..0c923c973 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -3574,6 +3574,7 @@ "builtinServers": "Servidores integrados", "builtinServersDescriptions": { "brave_search": "Una implementación de servidor MCP que integra la API de búsqueda de Brave, proporcionando funciones de búsqueda web y búsqueda local. Requiere configurar la variable de entorno BRAVE_API_KEY", + "didi_mcp": "Servidor DiDi MCP que proporciona servicios de transporte incluyendo búsqueda de mapas, estimación de precios, gestión de pedidos y seguimiento de conductores. Disponible solo en China Continental. Requiere configurar la variable de entorno DIDI_API_KEY", "dify_knowledge": "Implementación del servidor MCP de Dify, que proporciona una API sencilla para interactuar con Dify. Se requiere configurar la clave de Dify.", "fetch": "Servidor MCP para obtener el contenido de la página web de una URL", "filesystem": "Servidor Node.js que implementa el protocolo de contexto del modelo (MCP) para operaciones del sistema de archivos. Requiere configuración del directorio permitido para el acceso", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 72899c3c1..9e36984cb 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -3574,6 +3574,7 @@ "builtinServers": "Serveurs intégrés", "builtinServersDescriptions": { "brave_search": "Une implémentation de serveur MCP intégrant l'API de recherche Brave, offrant des fonctionnalités de recherche web et locale. Nécessite la configuration de la variable d'environnement BRAVE_API_KEY", + "didi_mcp": "Serveur DiDi MCP fournissant des services de transport incluant la recherche de cartes, l'estimation des prix, la gestion des commandes et le suivi des conducteurs. Disponible uniquement en Chine continentale. Nécessite la configuration de la variable d'environnement DIDI_API_KEY", "dify_knowledge": "Implémentation du serveur MCP de Dify, fournissant une API simple pour interagir avec Dify. Nécessite la configuration de la clé Dify", "fetch": "serveur MCP utilisé pour récupérer le contenu des pages web URL", "filesystem": "Serveur Node.js implémentant le protocole de contexte de modèle (MCP) pour les opérations de système de fichiers. Nécessite une configuration des répertoires autorisés à être accédés.", diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index 5c7e77400..d9db953ae 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -3574,6 +3574,7 @@ "builtinServers": "組み込みサーバー", "builtinServersDescriptions": { "brave_search": "Brave検索APIを統合したMCPサーバーの実装で、ウェブ検索とローカル検索の両機能を提供します。BRAVE_API_KEY環境変数の設定が必要です", + "didi_mcp": "DiDi MCPサーバーは、地図検索、料金見積もり、注文管理、ドライバー追跡を含むライドシェアサービスを提供します。中国本土でのみ利用可能です。DIDI_API_KEY環境変数の設定が必要です", "dify_knowledge": "DifyのMCPサーバー実装は、Difyと対話するためのシンプルなAPIを提供します。Dify Keyの設定が必要です。", "fetch": "URLのウェブページコンテンツを取得するためのMCPサーバー", "filesystem": "Node.jsサーバーによるファイルシステム操作を実現するモデルコンテキストプロトコル(MCP)。アクセスを許可するディレクトリの設定が必要です", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index f10aea90d..8f46afda0 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -3574,6 +3574,7 @@ "builtinServers": "Servidores integrados", "builtinServersDescriptions": { "brave_search": "uma implementação de servidor MCP integrada com a API de pesquisa Brave, fornecendo funcionalidades de pesquisa web e local. Requer a configuração da variável de ambiente BRAVE_API_KEY", + "didi_mcp": "Servidor DiDi MCP que fornece serviços de transporte incluindo pesquisa de mapas, estimativa de preços, gestão de pedidos e rastreamento de motoristas. Disponível apenas na China Continental. Requer configuração da variável de ambiente DIDI_API_KEY", "dify_knowledge": "Implementação do servidor MCP do Dify, que fornece uma API simples para interagir com o Dify. Requer a configuração da chave Dify", "fetch": "servidor MCP para obter o conteúdo da página web do URL", "filesystem": "Servidor Node.js do protocolo de contexto de modelo (MCP) para implementar operações de sistema de ficheiros. Requer configuração do diretório permitido para acesso", diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index bf4533a4a..8bf1b823c 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -3574,6 +3574,7 @@ "builtinServers": "Встроенные серверы", "builtinServersDescriptions": { "brave_search": "реализация сервера MCP с интеграцией API поиска Brave, обеспечивающая функции веб-поиска и локального поиска. Требуется настройка переменной среды BRAVE_API_KEY", + "didi_mcp": "Сервер DiDi MCP, предоставляющий услуги такси, включая поиск на карте, оценку стоимости, управление заказами и отслеживание водителей. Доступен только в материковом Китае. Требует настройки переменной окружения DIDI_API_KEY", "dify_knowledge": "Реализация сервера MCP Dify, предоставляющая простой API для взаимодействия с Dify. Требуется настройка ключа Dify", "fetch": "MCP-сервер для получения содержимого веб-страниц по URL", "filesystem": "Node.js-сервер протокола контекста модели (MCP) для реализации операций файловой системы. Требуется настройка каталогов, к которым разрешён доступ", diff --git a/src/renderer/src/store/mcp.ts b/src/renderer/src/store/mcp.ts index 2bae82c14..eb659399b 100644 --- a/src/renderer/src/store/mcp.ts +++ b/src/renderer/src/store/mcp.ts @@ -144,6 +144,18 @@ export const builtinMCPServers: BuiltinMCPServer[] = [ type: 'inMemory', isActive: false, provider: 'CherryAI' + }, + { + id: nanoid(), + name: '@cherry/didi-mcp', + reference: 'https://mcp.didichuxing.com/', + type: 'inMemory', + isActive: false, + env: { + DIDI_API_KEY: 'YOUR_DIDI_API_KEY' + }, + shouldConfig: true, + provider: 'CherryAI' } ] as const diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 7bdf186e6..82df2c1f2 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -691,7 +691,8 @@ export const BuiltinMCPServerNames = { fetch: '@cherry/fetch', filesystem: '@cherry/filesystem', difyKnowledge: '@cherry/dify-knowledge', - python: '@cherry/python' + python: '@cherry/python', + didiMCP: '@cherry/didi-mcp' } as const export type BuiltinMCPServerName = (typeof BuiltinMCPServerNames)[keyof typeof BuiltinMCPServerNames] From 1f7d2fa93fc6af82eaf94da02c7cea362dfd1156 Mon Sep 17 00:00:00 2001 From: defi-failure <159208748+defi-failure@users.noreply.github.com> Date: Fri, 17 Oct 2025 10:38:52 +0800 Subject: [PATCH 28/39] feat: notes full text search (#10640) * feat: notes full text search initial commit * fix: update highlight overlay when scroll * fix: reset note search result properly * refactor: extract scrollToLine logic from CodeEditor into a custom hook * fix: hide match overlay when overlap * fix: truncate line with ellipsis around search match for better visibility * fix: unified note search match highlight style --- .../src/components/CodeEditor/hooks.ts | 79 ++++- .../src/components/CodeEditor/index.tsx | 8 +- src/renderer/src/components/HighlightText.tsx | 48 +++ .../src/components/RichEditor/constants.ts | 2 + .../src/components/RichEditor/index.tsx | 167 +++++++++ .../src/components/RichEditor/types.ts | 2 + .../components/RichEditor/useRichEditor.ts | 28 ++ src/renderer/src/i18n/locales/en-us.json | 8 + src/renderer/src/i18n/locales/zh-cn.json | 8 + src/renderer/src/i18n/locales/zh-tw.json | 8 + src/renderer/src/pages/notes/NotesEditor.tsx | 6 +- src/renderer/src/pages/notes/NotesPage.tsx | 78 ++++ src/renderer/src/pages/notes/NotesSidebar.tsx | 335 +++++++++++++++++- .../pages/notes/hooks/useFullTextSearch.ts | 159 +++++++++ src/renderer/src/services/EventService.ts | 1 + .../src/services/NotesSearchService.ts | 262 ++++++++++++++ .../utils/__tests__/markdownConverter.test.ts | 93 +++-- src/renderer/src/utils/markdownConverter.ts | 66 +++- 18 files changed, 1306 insertions(+), 52 deletions(-) create mode 100644 src/renderer/src/components/HighlightText.tsx create mode 100644 src/renderer/src/components/RichEditor/constants.ts create mode 100644 src/renderer/src/pages/notes/hooks/useFullTextSearch.ts create mode 100644 src/renderer/src/services/NotesSearchService.ts diff --git a/src/renderer/src/components/CodeEditor/hooks.ts b/src/renderer/src/components/CodeEditor/hooks.ts index b6689644e..65c18a5a0 100644 --- a/src/renderer/src/components/CodeEditor/hooks.ts +++ b/src/renderer/src/components/CodeEditor/hooks.ts @@ -2,7 +2,7 @@ import { linter } from '@codemirror/lint' // statically imported by @uiw/codemir import { EditorView } from '@codemirror/view' import { loggerService } from '@logger' import { Extension, keymap } from '@uiw/react-codemirror' -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { getNormalizedExtension } from './utils' @@ -203,3 +203,80 @@ export function useHeightListener({ onHeightChange }: UseHeightListenerProps) { }) }, [onHeightChange]) } + +interface UseScrollToLineOptions { + highlight?: boolean +} + +export function useScrollToLine(editorViewRef: React.MutableRefObject) { + const findLineElement = useCallback((view: EditorView, position: number): HTMLElement | null => { + const domAtPos = view.domAtPos(position) + let node: Node | null = domAtPos.node + + if (node.nodeType === Node.TEXT_NODE) { + node = node.parentElement + } + + while (node) { + if (node instanceof HTMLElement && node.classList.contains('cm-line')) { + return node + } + node = node.parentElement + } + + return null + }, []) + + const highlightLine = useCallback((view: EditorView, element: HTMLElement) => { + const previousHighlight = view.dom.querySelector('.animation-locate-highlight') as HTMLElement | null + if (previousHighlight) { + previousHighlight.classList.remove('animation-locate-highlight') + } + + element.classList.add('animation-locate-highlight') + + const handleAnimationEnd = () => { + element.classList.remove('animation-locate-highlight') + element.removeEventListener('animationend', handleAnimationEnd) + } + + element.addEventListener('animationend', handleAnimationEnd) + }, []) + + return useCallback( + (lineNumber: number, options?: UseScrollToLineOptions) => { + const view = editorViewRef.current + if (!view) return + + const targetLine = view.state.doc.line(Math.min(lineNumber, view.state.doc.lines)) + + const lineElement = findLineElement(view, targetLine.from) + if (lineElement) { + lineElement.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' }) + + if (options?.highlight) { + requestAnimationFrame(() => highlightLine(view, lineElement)) + } + return + } + + view.dispatch({ + effects: EditorView.scrollIntoView(targetLine.from, { + y: 'start' + }) + }) + + if (!options?.highlight) { + return + } + + setTimeout(() => { + const fallbackElement = findLineElement(view, targetLine.from) + if (fallbackElement) { + highlightLine(view, fallbackElement) + } + }, 200) + }, + [editorViewRef, findLineElement, highlightLine] + ) +} diff --git a/src/renderer/src/components/CodeEditor/index.tsx b/src/renderer/src/components/CodeEditor/index.tsx index 64c387ffd..31c4ce798 100644 --- a/src/renderer/src/components/CodeEditor/index.tsx +++ b/src/renderer/src/components/CodeEditor/index.tsx @@ -5,13 +5,14 @@ import diff from 'fast-diff' import { useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react' import { memo } from 'react' -import { useBlurHandler, useHeightListener, useLanguageExtensions, useSaveKeymap } from './hooks' +import { useBlurHandler, useHeightListener, useLanguageExtensions, useSaveKeymap, useScrollToLine } from './hooks' // 标记非用户编辑的变更 const External = Annotation.define() export interface CodeEditorHandles { save?: () => void + scrollToLine?: (lineNumber: number, options?: { highlight?: boolean }) => void } export interface CodeEditorProps { @@ -181,8 +182,11 @@ const CodeEditor = ({ ].flat() }, [extensions, langExtensions, wrapped, saveKeymapExtension, blurExtension, heightListenerExtension]) + const scrollToLine = useScrollToLine(editorViewRef) + useImperativeHandle(ref, () => ({ - save: handleSave + save: handleSave, + scrollToLine })) return ( diff --git a/src/renderer/src/components/HighlightText.tsx b/src/renderer/src/components/HighlightText.tsx new file mode 100644 index 000000000..debf02c92 --- /dev/null +++ b/src/renderer/src/components/HighlightText.tsx @@ -0,0 +1,48 @@ +import { FC, memo, useMemo } from 'react' + +interface HighlightTextProps { + text: string + keyword: string + caseSensitive?: boolean + className?: string +} + +/** + * Text highlighting component that marks keyword matches + */ +const HighlightText: FC = ({ text, keyword, caseSensitive = false, className }) => { + const highlightedText = useMemo(() => { + if (!keyword || !text) { + return {text} + } + + // Escape regex special characters + const escapedKeyword = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const flags = caseSensitive ? 'g' : 'gi' + const regex = new RegExp(`(${escapedKeyword})`, flags) + + // Split text by keyword matches + const parts = text.split(regex) + + return ( + <> + {parts.map((part, index) => { + // Check if part matches keyword + const isMatch = regex.test(part) + regex.lastIndex = 0 // Reset regex state + + if (isMatch) { + return {part} + } + return {part} + })} + + ) + }, [text, keyword, caseSensitive]) + + const combinedClassName = className ? `ant-typography ${className}` : 'ant-typography' + + return {highlightedText} +} + +export default memo(HighlightText) diff --git a/src/renderer/src/components/RichEditor/constants.ts b/src/renderer/src/components/RichEditor/constants.ts new file mode 100644 index 000000000..08b0ba7cf --- /dev/null +++ b/src/renderer/src/components/RichEditor/constants.ts @@ -0,0 +1,2 @@ +// Attribute used to store the original source line number in markdown editors +export const MARKDOWN_SOURCE_LINE_ATTR = 'data-source-line' diff --git a/src/renderer/src/components/RichEditor/index.tsx b/src/renderer/src/components/RichEditor/index.tsx index 83023dab9..793ccda1a 100644 --- a/src/renderer/src/components/RichEditor/index.tsx +++ b/src/renderer/src/components/RichEditor/index.tsx @@ -1,5 +1,6 @@ import { loggerService } from '@logger' import { ContentSearch, type ContentSearchRef } from '@renderer/components/ContentSearch' +import { MARKDOWN_SOURCE_LINE_ATTR } from '@renderer/components/RichEditor/constants' import DragHandle from '@tiptap/extension-drag-handle-react' import { EditorContent } from '@tiptap/react' import { Tooltip } from 'antd' @@ -29,6 +30,156 @@ import type { FormattingCommand, RichEditorProps, RichEditorRef } from './types' import { useRichEditor } from './useRichEditor' const logger = loggerService.withContext('RichEditor') +/** + * Find element by line number with fallback strategies: + * 1. Exact line + content match + * 2. Exact line match + * 3. Closest line <= target + */ +function findElementByLine(editorDom: HTMLElement, lineNumber: number, lineContent?: string): HTMLElement | null { + const allElements = Array.from(editorDom.querySelectorAll(`[${MARKDOWN_SOURCE_LINE_ATTR}]`)) as HTMLElement[] + if (allElements.length === 0) { + logger.warn('No elements with data-source-line attribute found') + return null + } + const exactMatches = editorDom.querySelectorAll( + `[${MARKDOWN_SOURCE_LINE_ATTR}="${lineNumber}"]` + ) as NodeListOf + + // Strategy 1: Exact line + content match + if (exactMatches.length > 1 && lineContent) { + for (const match of Array.from(exactMatches)) { + if (match.textContent?.includes(lineContent)) { + return match + } + } + } + + // Strategy 2: Exact line match + if (exactMatches.length > 0) { + return exactMatches[0] + } + + // Strategy 3: Closest line <= target + let closestElement: HTMLElement | null = null + let closestLine = 0 + + for (const el of allElements) { + const sourceLine = parseInt(el.getAttribute(MARKDOWN_SOURCE_LINE_ATTR) || '0', 10) + if (sourceLine <= lineNumber && sourceLine > closestLine) { + closestLine = sourceLine + closestElement = el + } + } + + return closestElement +} + +/** + * Create fixed-position highlight overlay at element location + * with boundary detection to prevent overflow and toolbar overlap + */ +function createHighlightOverlay(element: HTMLElement, container: HTMLElement): void { + try { + // Remove previous overlay + const previousOverlay = document.body.querySelector('.highlight-overlay') + if (previousOverlay) { + previousOverlay.remove() + } + + const editorWrapper = container.closest('.rich-editor-wrapper') + + // Create overlay at element position + const rect = element.getBoundingClientRect() + const overlay = document.createElement('div') + overlay.className = 'highlight-overlay animation-locate-highlight' + overlay.style.position = 'fixed' + overlay.style.left = `${rect.left}px` + overlay.style.top = `${rect.top}px` + overlay.style.width = `${rect.width}px` + overlay.style.height = `${rect.height}px` + overlay.style.pointerEvents = 'none' + overlay.style.zIndex = '9999' + overlay.style.borderRadius = '4px' + + document.body.appendChild(overlay) + + // Update overlay position and visibility on scroll + const updatePosition = () => { + const newRect = element.getBoundingClientRect() + const newContainerRect = container.getBoundingClientRect() + + // Update position + overlay.style.left = `${newRect.left}px` + overlay.style.top = `${newRect.top}px` + overlay.style.width = `${newRect.width}px` + overlay.style.height = `${newRect.height}px` + + // Get current toolbar bottom (it might change) + const currentToolbar = editorWrapper?.querySelector('[class*="ToolbarWrapper"]') + const currentToolbarRect = currentToolbar?.getBoundingClientRect() + const currentToolbarBottom = currentToolbarRect ? currentToolbarRect.bottom : newContainerRect.top + + // Check if overlay is within visible bounds + const overlayTop = newRect.top + const overlayBottom = newRect.bottom + const visibleTop = currentToolbarBottom // Don't overlap toolbar + const visibleBottom = newContainerRect.bottom + + // Hide overlay if any part is outside the visible container area + if (overlayTop < visibleTop || overlayBottom > visibleBottom) { + overlay.style.opacity = '0' + overlay.style.visibility = 'hidden' + } else { + overlay.style.opacity = '1' + overlay.style.visibility = 'visible' + } + } + + container.addEventListener('scroll', updatePosition) + + // Auto-remove after animation + const handleAnimationEnd = () => { + overlay.remove() + container.removeEventListener('scroll', updatePosition) + overlay.removeEventListener('animationend', handleAnimationEnd) + } + overlay.addEventListener('animationend', handleAnimationEnd) + } catch (error) { + logger.error('Failed to create highlight overlay:', error as Error) + } +} + +/** + * Scroll to element and show highlight after scroll completes + */ +function scrollAndHighlight(element: HTMLElement, container: HTMLElement): void { + element.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' }) + + let scrollTimeout: NodeJS.Timeout + const handleScroll = () => { + clearTimeout(scrollTimeout) + scrollTimeout = setTimeout(() => { + container.removeEventListener('scroll', handleScroll) + requestAnimationFrame(() => createHighlightOverlay(element, container)) + }, 150) + } + + container.addEventListener('scroll', handleScroll) + + // Fallback: if element already in view (no scroll happens) + setTimeout(() => { + const initialScrollTop = container.scrollTop + setTimeout(() => { + if (Math.abs(container.scrollTop - initialScrollTop) < 1) { + container.removeEventListener('scroll', handleScroll) + clearTimeout(scrollTimeout) + requestAnimationFrame(() => createHighlightOverlay(element, container)) + } + }, 200) + }, 50) +} + const RichEditor = ({ ref, initialContent = '', @@ -372,6 +523,22 @@ const RichEditor = ({ scrollContainerRef.current.scrollTop = value } }, + scrollToLine: (lineNumber: number, options?: { highlight?: boolean; lineContent?: string }) => { + if (!editor || !scrollContainerRef.current) return + + try { + const element = findElementByLine(editor.view.dom, lineNumber, options?.lineContent) + if (!element) return + + if (options?.highlight) { + scrollAndHighlight(element, scrollContainerRef.current) + } else { + element.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' }) + } + } catch (error) { + logger.error('Failed in scrollToLine:', error as Error) + } + }, // Dynamic command management registerCommand, registerToolbarCommand, diff --git a/src/renderer/src/components/RichEditor/types.ts b/src/renderer/src/components/RichEditor/types.ts index 48ae5bb11..15727e1a8 100644 --- a/src/renderer/src/components/RichEditor/types.ts +++ b/src/renderer/src/components/RichEditor/types.ts @@ -111,6 +111,8 @@ export interface RichEditorRef { getScrollTop: () => number /** Set scrollTop of the editor scroll container */ setScrollTop: (value: number) => void + /** Scroll to specific line number in markdown */ + scrollToLine: (lineNumber: number, options?: { highlight?: boolean; lineContent?: string }) => void // Dynamic command management /** Register a new command/toolbar item */ registerCommand: (cmd: Command) => void diff --git a/src/renderer/src/components/RichEditor/useRichEditor.ts b/src/renderer/src/components/RichEditor/useRichEditor.ts index 1ece36fb0..576162fac 100644 --- a/src/renderer/src/components/RichEditor/useRichEditor.ts +++ b/src/renderer/src/components/RichEditor/useRichEditor.ts @@ -2,6 +2,7 @@ import 'katex/dist/katex.min.css' import { TableKit } from '@cherrystudio/extension-table-plus' import { loggerService } from '@logger' +import { MARKDOWN_SOURCE_LINE_ATTR } from '@renderer/components/RichEditor/constants' import type { FormattingState } from '@renderer/components/RichEditor/types' import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { @@ -11,6 +12,7 @@ import { markdownToPreviewText } from '@renderer/utils/markdownConverter' import type { Editor } from '@tiptap/core' +import { Extension } from '@tiptap/core' import { TaskItem, TaskList } from '@tiptap/extension-list' import { migrateMathStrings } from '@tiptap/extension-mathematics' import Mention from '@tiptap/extension-mention' @@ -36,6 +38,31 @@ import { blobToArrayBuffer, compressImage, shouldCompressImage } from './helpers const logger = loggerService.withContext('useRichEditor') +// Create extension to preserve data-source-line attribute +const SourceLineAttribute = Extension.create({ + name: 'sourceLineAttribute', + addGlobalAttributes() { + return [ + { + types: ['paragraph', 'heading', 'blockquote', 'bulletList', 'orderedList', 'listItem', 'horizontalRule'], + attributes: { + dataSourceLine: { + default: null, + parseHTML: (element) => { + const value = element.getAttribute(MARKDOWN_SOURCE_LINE_ATTR) + return value + }, + renderHTML: (attributes) => { + if (!attributes.dataSourceLine) return {} + return { [MARKDOWN_SOURCE_LINE_ATTR]: attributes.dataSourceLine } + } + } + } + } + ] + } +}) + export interface UseRichEditorOptions { /** Initial markdown content */ initialContent?: string @@ -196,6 +223,7 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor // TipTap editor extensions const extensions = useMemo( () => [ + SourceLineAttribute, StarterKit.configure({ heading: { levels: [1, 2, 3, 4, 5, 6] diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index ee3d0ff11..6b0dd3c0c 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1957,6 +1957,14 @@ "rename": "Rename", "rename_changed": "Due to security policies, the filename has been changed from {{original}} to {{final}}", "save": "Save to Notes", + "search": { + "both": "Name+Content", + "content": "Content", + "found_results": "Found {{count}} results (Name: {{nameCount}}, Content: {{contentCount}})", + "more_matches": "more matches", + "searching": "Searching...", + "show_less": "Show less" + }, "settings": { "data": { "apply": "Apply", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index e5573221e..15675d265 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1957,6 +1957,14 @@ "rename": "重命名", "rename_changed": "由于安全策略,文件名已从 {{original}} 更改为 {{final}}", "save": "保存到笔记", + "search": { + "both": "名称+内容", + "content": "内容", + "found_results": "找到 {{count}} 个结果 (名称: {{nameCount}}, 内容: {{contentCount}})", + "more_matches": "个匹配", + "searching": "搜索中...", + "show_less": "收起" + }, "settings": { "data": { "apply": "应用", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 8634254e5..020a0e5a1 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1957,6 +1957,14 @@ "rename": "重命名", "rename_changed": "由於安全策略,文件名已從 {{original}} 更改為 {{final}}", "save": "儲存到筆記", + "search": { + "both": "名稱+內容", + "content": "內容", + "found_results": "找到 {{count}} 個結果 (名稱: {{nameCount}}, 內容: {{contentCount}})", + "more_matches": "個匹配", + "searching": "搜索中...", + "show_less": "收起" + }, "settings": { "data": { "apply": "應用", diff --git a/src/renderer/src/pages/notes/NotesEditor.tsx b/src/renderer/src/pages/notes/NotesEditor.tsx index 18c2cfe9d..66bbf2225 100644 --- a/src/renderer/src/pages/notes/NotesEditor.tsx +++ b/src/renderer/src/pages/notes/NotesEditor.tsx @@ -1,5 +1,5 @@ import ActionIconButton from '@renderer/components/Buttons/ActionIconButton' -import CodeEditor from '@renderer/components/CodeEditor' +import CodeEditor, { CodeEditorHandles } from '@renderer/components/CodeEditor' import { HSpaceBetweenStack } from '@renderer/components/Layout' import RichEditor from '@renderer/components/RichEditor' import { RichEditorRef } from '@renderer/components/RichEditor/types' @@ -20,11 +20,12 @@ interface NotesEditorProps { currentContent: string tokenCount: number editorRef: RefObject + codeEditorRef: RefObject onMarkdownChange: (content: string) => void } const NotesEditor: FC = memo( - ({ activeNodeId, currentContent, tokenCount, onMarkdownChange, editorRef }) => { + ({ activeNodeId, currentContent, tokenCount, onMarkdownChange, editorRef, codeEditorRef }) => { const { t } = useTranslation() const dispatch = useAppDispatch() const { settings } = useNotesSettings() @@ -59,6 +60,7 @@ const NotesEditor: FC = memo( {tmpViewMode === 'source' ? ( { const editorRef = useRef(null) + const codeEditorRef = useRef(null) const { t } = useTranslation() const { showWorkspace } = useShowWorkspace() const dispatch = useAppDispatch() @@ -76,6 +79,7 @@ const NotesPage: FC = () => { const lastFilePathRef = useRef(undefined) const isRenamingRef = useRef(false) const isCreatingNoteRef = useRef(false) + const pendingScrollRef = useRef<{ lineNumber: number; lineContent?: string } | null>(null) const activeFilePathRef = useRef(activeFilePath) const currentContentRef = useRef(currentContent) @@ -366,6 +370,32 @@ const NotesPage: FC = () => { } }, [currentContent, activeFilePath]) + // Execute pending scroll after file switch + useEffect(() => { + if (!pendingScrollRef.current || !currentContent) return + + const { lineNumber, lineContent } = pendingScrollRef.current + pendingScrollRef.current = null + + // Wait for DOM to update before scrolling + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const codeEditor = codeEditorRef.current + const richEditor = editorRef.current + + try { + if (codeEditor?.scrollToLine) { + codeEditor.scrollToLine(lineNumber, { highlight: true }) + } else if (richEditor?.scrollToLine) { + richEditor.scrollToLine(lineNumber, { highlight: true, lineContent }) + } + } catch (error) { + logger.error('Failed to execute pending scroll:', error as Error) + } + }) + }) + }, [activeFilePath, currentContent]) + // 切换文件时的清理工作 useEffect(() => { return () => { @@ -755,6 +785,53 @@ const NotesPage: FC = () => { } }, [currentContent, settings.defaultEditMode]) + // Listen for external requests to locate a specific line in a note + useEffect(() => { + const handleLocateNoteLine = ({ + noteId, + lineNumber, + lineContent + }: { + noteId: string + lineNumber: number + lineContent?: string + }) => { + const targetNode = findNode(notesTree, noteId) + + if (!targetNode || targetNode.type !== 'file') { + logger.warn('Target note not found or not a file', { noteId }) + return + } + + const needsSwitchFile = targetNode.externalPath !== activeFilePath + + if (needsSwitchFile) { + // switch to target note first then scroll to line + pendingScrollRef.current = { lineNumber, lineContent } + dispatch(setActiveFilePath(targetNode.externalPath)) + invalidateFileContent(targetNode.externalPath) + } else { + const richEditor = editorRef.current + const codeEditor = codeEditorRef.current + + try { + if (codeEditor?.scrollToLine) { + codeEditor.scrollToLine(lineNumber, { highlight: true }) + } else if (richEditor?.scrollToLine) { + richEditor.scrollToLine(lineNumber, { highlight: true, lineContent }) + } + } catch (error) { + logger.error('Failed to scroll to line:', error as Error) + } + } + } + + const unsubscribe = EventEmitter.on(EVENT_NAMES.LOCATE_NOTE_LINE, handleLocateNoteLine) + return () => { + unsubscribe() + } + }, [activeNode?.id, activeFilePath, notesTree, dispatch, invalidateFileContent]) + return ( @@ -800,6 +877,7 @@ const NotesPage: FC = () => { tokenCount={tokenCount} onMarkdownChange={handleMarkdownChange} editorRef={editorRef} + codeEditorRef={codeEditorRef} />
      diff --git a/src/renderer/src/pages/notes/NotesSidebar.tsx b/src/renderer/src/pages/notes/NotesSidebar.tsx index 47a27030c..d09c228f6 100644 --- a/src/renderer/src/pages/notes/NotesSidebar.tsx +++ b/src/renderer/src/pages/notes/NotesSidebar.tsx @@ -1,4 +1,5 @@ import { loggerService } from '@logger' +import HighlightText from '@renderer/components/HighlightText' import { DeleteIcon } from '@renderer/components/Icons' import SaveToKnowledgePopup from '@renderer/components/Popups/SaveToKnowledgePopup' import Scrollbar from '@renderer/components/Scrollbar' @@ -7,6 +8,8 @@ import { useKnowledgeBases } from '@renderer/hooks/useKnowledge' import { useActiveNode } from '@renderer/hooks/useNotesQuery' import NotesSidebarHeader from '@renderer/pages/notes/NotesSidebarHeader' import { fetchNoteSummary } from '@renderer/services/ApiService' +import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' +import { SearchMatch, SearchResult } from '@renderer/services/NotesSearchService' import { RootState, useAppSelector } from '@renderer/store' import { selectSortType } from '@renderer/store/note' import { NotesSortType, NotesTreeNode } from '@renderer/types/note' @@ -23,16 +26,20 @@ import { FileSearch, Folder, FolderOpen, + Loader2, Sparkles, Star, StarOff, - UploadIcon + UploadIcon, + X } from 'lucide-react' import { FC, memo, Ref, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' import styled from 'styled-components' +import { useFullTextSearch } from './hooks/useFullTextSearch' + interface NotesSidebarProps { onCreateFolder: (name: string, targetFolderId?: string) => void onCreateNote: (name: string, targetFolderId?: string) => void @@ -51,7 +58,7 @@ interface NotesSidebarProps { const logger = loggerService.withContext('NotesSidebar') interface TreeNodeProps { - node: NotesTreeNode + node: NotesTreeNode | SearchResult depth: number selectedFolderId?: string | null activeNodeId?: string @@ -71,6 +78,8 @@ interface TreeNodeProps { onDrop: (e: React.DragEvent, node: NotesTreeNode) => void onDragEnd: () => void renderChildren?: boolean // 控制是否渲染子节点 + searchKeyword?: string // 搜索关键词,用于高亮 + showMatches?: boolean // 是否显示匹配预览 openDropdownKey: string | null onDropdownOpenChange: (key: string | null) => void } @@ -97,10 +106,30 @@ const TreeNode = memo( onDrop, onDragEnd, renderChildren = true, + searchKeyword = '', + showMatches = false, openDropdownKey, onDropdownOpenChange }) => { const { t } = useTranslation() + const [showAllMatches, setShowAllMatches] = useState(false) + + // 检查是否是搜索结果 + const searchResult = 'matchType' in node ? (node as SearchResult) : null + const hasMatches = searchResult && searchResult.matches && searchResult.matches.length > 0 + + // 处理匹配项点击 + const handleMatchClick = useCallback( + (match: SearchMatch) => { + // 发送定位事件 + EventEmitter.emit(EVENT_NAMES.LOCATE_NOTE_LINE, { + noteId: node.id, + lineNumber: match.lineNumber, + lineContent: match.lineContent + }) + }, + [node] + ) const isActive = selectedFolderId ? node.type === 'folder' && node.id === selectedFolderId @@ -121,6 +150,37 @@ const TreeNode = memo( return '' } + const displayName = useMemo(() => { + if (!searchKeyword) { + return node.name + } + + const name = node.name ?? '' + if (!name) { + return name + } + + const keyword = searchKeyword + const nameLower = name.toLowerCase() + const keywordLower = keyword.toLowerCase() + const matchStart = nameLower.indexOf(keywordLower) + + if (matchStart === -1) { + return name + } + + const matchEnd = matchStart + keyword.length + const beforeMatch = Math.min(2, matchStart) + const contextStart = matchStart - beforeMatch + const contextLength = 50 + const contextEnd = Math.min(name.length, matchEnd + contextLength) + + const prefix = contextStart > 0 ? '...' : '' + const suffix = contextEnd < name.length ? '...' : '' + + return prefix + name.substring(contextStart, contextEnd) + suffix + }, [node.name, searchKeyword]) + return (
      ( size="small" /> ) : ( - {node.name} + + + {searchKeyword ? : node.name} + + {searchResult && searchResult.matchType && searchResult.matchType !== 'filename' && ( + + {searchResult.matchType === 'both' ? t('notes.search.both') : t('notes.search.content')} + + )} + )}
      + {showMatches && hasMatches && ( + + {(showAllMatches ? searchResult!.matches! : searchResult!.matches!.slice(0, 3)).map((match, idx) => ( + handleMatchClick(match)}> + {match.lineNumber} + + + + + ))} + {searchResult!.matches!.length > 3 && ( + { + e.stopPropagation() + setShowAllMatches(!showAllMatches) + }}> + {showAllMatches ? ( + <> + + {t('notes.search.show_less')} + + ) : ( + <> + +{searchResult!.matches!.length - 3}{' '} + {t('notes.search.more_matches')} + + )} + + )} + + )} + {renderChildren && node.type === 'folder' && node.expanded && hasChildren && (
      {node.children!.map((child) => ( @@ -257,6 +359,31 @@ const NotesSidebar: FC = ({ const [openDropdownKey, setOpenDropdownKey] = useState(null) const dragNodeRef = useRef(null) const scrollbarRef = useRef(null) + const notesTreeRef = useRef(notesTree) + const trimmedSearchKeyword = useMemo(() => searchKeyword.trim(), [searchKeyword]) + const hasSearchKeyword = trimmedSearchKeyword.length > 0 + + // 全文搜索配置 + const searchOptions = useMemo( + () => ({ + debounceMs: 300, + maxResults: 100, + contextLength: 50, + caseSensitive: false, + maxFileSize: 10 * 1024 * 1024, // 10MB + enabled: isShowSearch + }), + [isShowSearch] + ) + + const { + search, + cancel, + reset, + isSearching, + results: searchResults, + stats: searchStats + } = useFullTextSearch(searchOptions) const inPlaceEdit = useInPlaceEdit({ onSave: (newName: string) => { @@ -514,6 +641,25 @@ const NotesSidebar: FC = ({ setIsShowSearch(!isShowSearch) }, [isShowSearch]) + // 同步 notesTree 到 ref + useEffect(() => { + notesTreeRef.current = notesTree + }, [notesTree]) + + // 触发全文搜索 + useEffect(() => { + if (!isShowSearch) { + reset() + return + } + + if (hasSearchKeyword) { + search(notesTreeRef.current, trimmedSearchKeyword) + } else { + reset() + } + }, [isShowSearch, hasSearchKeyword, trimmedSearchKeyword, search, reset]) + // Flatten tree nodes for virtualization and filtering const flattenedNodes = useMemo(() => { const flattenForVirtualization = ( @@ -537,11 +683,7 @@ const NotesSidebar: FC = ({ let result: NotesTreeNode[] = [] for (const node of nodes) { - if (isShowSearch && searchKeyword) { - if (node.type === 'file' && node.name.toLowerCase().includes(searchKeyword.toLowerCase())) { - result.push(node) - } - } else if (isShowStarred) { + if (isShowStarred) { if (node.type === 'file' && node.isStarred) { result.push(node) } @@ -553,7 +695,14 @@ const NotesSidebar: FC = ({ return result } - if (isShowStarred || isShowSearch) { + if (isShowSearch) { + if (hasSearchKeyword) { + return searchResults.map((result) => ({ node: result, depth: 0 })) + } + return [] // 搜索关键词为空 + } + + if (isShowStarred) { // For filtered views, return flat list without virtualization for simplicity const filteredNodes = flattenForFiltering(notesTree) return filteredNodes.map((node) => ({ node, depth: 0 })) @@ -561,7 +710,7 @@ const NotesSidebar: FC = ({ // For normal tree view, use hierarchical flattening for virtualization return flattenForVirtualization(notesTree) - }, [notesTree, isShowStarred, isShowSearch, searchKeyword]) + }, [notesTree, isShowStarred, isShowSearch, hasSearchKeyword, searchResults]) // Use virtualization only for normal tree view with many items const shouldUseVirtualization = !isShowStarred && !isShowSearch && flattenedNodes.length > 100 @@ -863,6 +1012,26 @@ const NotesSidebar: FC = ({ /> + {isShowSearch && isSearching && ( + + + {t('notes.search.searching')} + + + + + )} + {isShowSearch && !isSearching && hasSearchKeyword && searchStats.total > 0 && ( + + + {t('notes.search.found_results', { + count: searchStats.total, + nameCount: searchStats.fileNameMatches, + contentCount: searchStats.contentMatches + searchStats.bothMatches + })} + + + )} {shouldUseVirtualization ? ( = ({ onDragEnd={handleDragEnd} openDropdownKey={openDropdownKey} onDropdownOpenChange={setOpenDropdownKey} + searchKeyword={isShowSearch ? trimmedSearchKeyword : ''} + showMatches={isShowSearch} /> )) : notesTree.map((node) => ( @@ -1249,4 +1420,148 @@ const DropHintText = styled.div` font-style: italic; ` +// 搜索相关样式 +const SearchStatusBar = styled.div` + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background-color: var(--color-background-soft); + border-bottom: 0.5px solid var(--color-border); + font-size: 12px; + color: var(--color-text-2); + + .animate-spin { + animation: spin 1s linear infinite; + } + + @keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } +` + +const CancelButton = styled.button` + margin-left: auto; + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + border: none; + background-color: transparent; + color: var(--color-text-3); + cursor: pointer; + border-radius: 3px; + transition: all 0.2s ease; + + &:hover { + background-color: var(--color-background-mute); + color: var(--color-text); + } + + &:active { + background-color: var(--color-active); + } +` + +const NodeNameContainer = styled.div` + display: flex; + align-items: center; + gap: 6px; + flex: 1; + min-width: 0; +` + +const MatchBadge = styled.span<{ matchType: string }>` + display: inline-flex; + align-items: center; + padding: 0 4px; + height: 16px; + font-size: 10px; + line-height: 1; + border-radius: 2px; + background-color: ${(props) => + props.matchType === 'both' ? 'var(--color-primary-soft)' : 'var(--color-background-mute)'}; + color: ${(props) => (props.matchType === 'both' ? 'var(--color-primary)' : 'var(--color-text-3)')}; + font-weight: 500; + flex-shrink: 0; +` + +const SearchMatchesContainer = styled.div<{ depth: number }>` + margin-left: ${(props) => props.depth * 16 + 40}px; + margin-top: 4px; + margin-bottom: 8px; + padding: 6px 8px; + background-color: var(--color-background-mute); + border-radius: 4px; + border-left: 2px solid var(--color-primary-soft); +` + +const MatchItem = styled.div` + display: flex; + gap: 8px; + margin-bottom: 4px; + font-size: 12px; + padding: 4px 6px; + margin-left: -6px; + margin-right: -6px; + border-radius: 3px; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + background-color: var(--color-background-soft); + transform: translateX(2px); + } + + &:active { + background-color: var(--color-active); + } + + &:last-child { + margin-bottom: 0; + } +` + +const MatchLineNumber = styled.span` + color: var(--color-text-3); + font-family: monospace; + flex-shrink: 0; + width: 30px; +` + +const MatchContext = styled.div` + color: var(--color-text-2); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: monospace; +` + +const MoreMatches = styled.div<{ depth: number }>` + margin-top: 4px; + padding: 4px 6px; + margin-left: -6px; + margin-right: -6px; + font-size: 11px; + color: var(--color-text-3); + border-radius: 3px; + cursor: pointer; + display: flex; + align-items: center; + transition: all 0.15s ease; + + &:hover { + color: var(--color-text-2); + background-color: var(--color-background-soft); + } +` + export default memo(NotesSidebar) diff --git a/src/renderer/src/pages/notes/hooks/useFullTextSearch.ts b/src/renderer/src/pages/notes/hooks/useFullTextSearch.ts new file mode 100644 index 000000000..6a2d12c2c --- /dev/null +++ b/src/renderer/src/pages/notes/hooks/useFullTextSearch.ts @@ -0,0 +1,159 @@ +import { searchAllFiles, SearchOptions, SearchResult } from '@renderer/services/NotesSearchService' +import { NotesTreeNode } from '@renderer/types/note' +import { useCallback, useEffect, useRef, useState } from 'react' + +export interface UseFullTextSearchOptions extends SearchOptions { + debounceMs?: number + maxResults?: number + enabled?: boolean +} + +export interface UseFullTextSearchReturn { + search: (nodes: NotesTreeNode[], keyword: string) => void + cancel: () => void + reset: () => void + isSearching: boolean + results: SearchResult[] + stats: { + total: number + fileNameMatches: number + contentMatches: number + bothMatches: number + } + error: Error | null +} + +/** + * Full-text search hook for notes + */ +export function useFullTextSearch(options: UseFullTextSearchOptions = {}): UseFullTextSearchReturn { + const { debounceMs = 300, maxResults = 100, enabled = true, ...searchOptions } = options + + const [isSearching, setIsSearching] = useState(false) + const [results, setResults] = useState([]) + const [error, setError] = useState(null) + const [stats, setStats] = useState({ + total: 0, + fileNameMatches: 0, + contentMatches: 0, + bothMatches: 0 + }) + + const abortControllerRef = useRef(null) + const debounceTimerRef = useRef(null) + + // Store options in refs to avoid reference changes + const searchOptionsRef = useRef(searchOptions) + const maxResultsRef = useRef(maxResults) + const enabledRef = useRef(enabled) + + useEffect(() => { + searchOptionsRef.current = searchOptions + maxResultsRef.current = maxResults + enabledRef.current = enabled + }, [searchOptions, maxResults, enabled]) + + const cancel = useCallback(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort() + abortControllerRef.current = null + } + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current) + debounceTimerRef.current = null + } + setIsSearching(false) + }, []) + + const reset = useCallback(() => { + cancel() + setResults([]) + setStats({ total: 0, fileNameMatches: 0, contentMatches: 0, bothMatches: 0 }) + setError(null) + }, [cancel]) + + const performSearch = useCallback( + async (nodes: NotesTreeNode[], keyword: string) => { + if (!enabledRef.current) { + return + } + + cancel() + + if (!keyword) { + setResults([]) + setStats({ total: 0, fileNameMatches: 0, contentMatches: 0, bothMatches: 0 }) + return + } + + setIsSearching(true) + setError(null) + + const abortController = new AbortController() + abortControllerRef.current = abortController + + try { + const searchResults = await searchAllFiles( + nodes, + keyword.trim(), + searchOptionsRef.current, + abortController.signal + ) + + if (abortController.signal.aborted) { + return + } + + const limitedResults = searchResults.slice(0, maxResultsRef.current) + + const newStats = { + total: limitedResults.length, + fileNameMatches: limitedResults.filter((r) => r.matchType === 'filename').length, + contentMatches: limitedResults.filter((r) => r.matchType === 'content').length, + bothMatches: limitedResults.filter((r) => r.matchType === 'both').length + } + + setResults(limitedResults) + setStats(newStats) + } catch (err) { + if (err instanceof Error && err.name !== 'AbortError') { + setError(err) + } + } finally { + if (!abortController.signal.aborted) { + setIsSearching(false) + } + } + }, + [cancel] + ) + + const search = useCallback( + (nodes: NotesTreeNode[], keyword: string) => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current) + } + + debounceTimerRef.current = setTimeout(() => { + performSearch(nodes, keyword) + }, debounceMs) + }, + [performSearch, debounceMs] + ) + + useEffect(() => { + return () => { + cancel() + } + }, [cancel]) + + return { + search, + cancel, + reset, + isSearching, + results, + stats, + error + } +} diff --git a/src/renderer/src/services/EventService.ts b/src/renderer/src/services/EventService.ts index 498beed04..ce5895fc3 100644 --- a/src/renderer/src/services/EventService.ts +++ b/src/renderer/src/services/EventService.ts @@ -24,6 +24,7 @@ export const EVENT_NAMES = { COPY_TOPIC_IMAGE: 'COPY_TOPIC_IMAGE', EXPORT_TOPIC_IMAGE: 'EXPORT_TOPIC_IMAGE', LOCATE_MESSAGE: 'LOCATE_MESSAGE', + LOCATE_NOTE_LINE: 'LOCATE_NOTE_LINE', ADD_NEW_TOPIC: 'ADD_NEW_TOPIC', RESEND_MESSAGE: 'RESEND_MESSAGE', SHOW_MODEL_SELECTOR: 'SHOW_MODEL_SELECTOR', diff --git a/src/renderer/src/services/NotesSearchService.ts b/src/renderer/src/services/NotesSearchService.ts new file mode 100644 index 000000000..f4331ff16 --- /dev/null +++ b/src/renderer/src/services/NotesSearchService.ts @@ -0,0 +1,262 @@ +import { loggerService } from '@logger' +import { NotesTreeNode } from '@renderer/types/note' + +const logger = loggerService.withContext('NotesSearchService') + +/** + * Search match result + */ +export interface SearchMatch { + lineNumber: number + lineContent: string + matchStart: number + matchEnd: number + context: string +} + +/** + * Search result with match information + */ +export interface SearchResult extends NotesTreeNode { + matchType: 'filename' | 'content' | 'both' + matches?: SearchMatch[] + score: number +} + +/** + * Search options + */ +export interface SearchOptions { + caseSensitive?: boolean + useRegex?: boolean + maxFileSize?: number + maxMatchesPerFile?: number + contextLength?: number +} + +/** + * Escape regex special characters + */ +export function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +/** + * Calculate relevance score + * - Filename match has higher priority + * - More matches increase score + * - More recent updates increase score + */ +export function calculateRelevanceScore(node: NotesTreeNode, keyword: string, matches: SearchMatch[]): number { + let score = 0 + + // Exact filename match (highest weight) + if (node.name.toLowerCase() === keyword.toLowerCase()) { + score += 200 + } + // Filename contains match (high weight) + else if (node.name.toLowerCase().includes(keyword.toLowerCase())) { + score += 100 + } + + // Content match count + score += Math.min(matches.length * 2, 50) + + // Recent updates boost score + const daysSinceUpdate = (Date.now() - new Date(node.updatedAt).getTime()) / (1000 * 60 * 60 * 24) + score += Math.max(0, 10 - daysSinceUpdate) + + return score +} + +/** + * Search file content for keyword matches + */ +export async function searchFileContent( + node: NotesTreeNode, + keyword: string, + options: SearchOptions = {} +): Promise { + const { + caseSensitive = false, + useRegex = false, + maxFileSize = 10 * 1024 * 1024, // 10MB + maxMatchesPerFile = 50, + contextLength = 50 + } = options + + try { + if (node.type !== 'file') { + return null + } + + const content = await window.api.file.readExternal(node.externalPath) + + if (!content) { + return null + } + + if (content.length > maxFileSize) { + logger.warn(`File too large to search: ${node.externalPath} (${content.length} bytes)`) + return null + } + + const flags = caseSensitive ? 'g' : 'gi' + const pattern = useRegex ? new RegExp(keyword, flags) : new RegExp(escapeRegex(keyword), flags) + + const lines = content.split('\n') + const matches: SearchMatch[] = [] + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + pattern.lastIndex = 0 + + let match: RegExpExecArray | null + while ((match = pattern.exec(line)) !== null) { + const matchStart = match.index + const matchEnd = matchStart + match[0].length + + // Keep context short: only 2 chars before match, more after + const beforeMatch = Math.min(2, matchStart) + const contextStart = matchStart - beforeMatch + const contextEnd = Math.min(line.length, matchEnd + contextLength) + + // Add ellipsis if context doesn't start at line beginning + const prefix = contextStart > 0 ? '...' : '' + const contextText = prefix + line.substring(contextStart, contextEnd) + + matches.push({ + lineNumber: i + 1, + lineContent: line, + matchStart: beforeMatch + prefix.length, + matchEnd: matchEnd - matchStart + beforeMatch + prefix.length, + context: contextText + }) + + if (matches.length >= maxMatchesPerFile) { + break + } + } + + if (matches.length >= maxMatchesPerFile) { + break + } + } + + if (matches.length === 0) { + return null + } + + const score = calculateRelevanceScore(node, keyword, matches) + + return { + ...node, + matchType: 'content', + matches, + score + } + } catch (error) { + logger.error(`Failed to search file content for ${node.externalPath}:`, error as Error) + return null + } +} + +/** + * Check if filename matches keyword + */ +export function matchFileName(node: NotesTreeNode, keyword: string, caseSensitive = false): boolean { + const name = caseSensitive ? node.name : node.name.toLowerCase() + const key = caseSensitive ? keyword : keyword.toLowerCase() + return name.includes(key) +} + +/** + * Flatten tree to extract file nodes + */ +export function flattenTreeToFiles(nodes: NotesTreeNode[]): NotesTreeNode[] { + const result: NotesTreeNode[] = [] + + function traverse(nodes: NotesTreeNode[]) { + for (const node of nodes) { + if (node.type === 'file') { + result.push(node) + } + if (node.children && node.children.length > 0) { + traverse(node.children) + } + } + } + + traverse(nodes) + return result +} + +/** + * Search all files concurrently + */ +export async function searchAllFiles( + nodes: NotesTreeNode[], + keyword: string, + options: SearchOptions = {}, + signal?: AbortSignal +): Promise { + const startTime = performance.now() + const CONCURRENCY = 5 + const results: SearchResult[] = [] + + const fileNodes = flattenTreeToFiles(nodes) + + logger.debug( + `Starting full-text search: keyword="${keyword}", totalFiles=${fileNodes.length}, options=${JSON.stringify(options)}` + ) + + const queue = [...fileNodes] + + const worker = async () => { + while (queue.length > 0) { + if (signal?.aborted) { + break + } + + const node = queue.shift() + if (!node) break + + const nameMatch = matchFileName(node, keyword, options.caseSensitive) + const contentResult = await searchFileContent(node, keyword, options) + + if (nameMatch && contentResult) { + results.push({ + ...contentResult, + matchType: 'both', + score: contentResult.score + 100 + }) + } else if (nameMatch) { + results.push({ + ...node, + matchType: 'filename', + matches: [], + score: 100 + }) + } else if (contentResult) { + results.push(contentResult) + } + } + } + + await Promise.all(Array.from({ length: Math.min(CONCURRENCY, fileNodes.length) }, () => worker())) + + const sortedResults = results.sort((a, b) => b.score - a.score) + + const endTime = performance.now() + const duration = (endTime - startTime).toFixed(2) + + logger.debug( + `Full-text search completed: keyword="${keyword}", duration=${duration}ms, ` + + `totalFiles=${fileNodes.length}, resultsFound=${sortedResults.length}, ` + + `filenameMatches=${sortedResults.filter((r) => r.matchType === 'filename').length}, ` + + `contentMatches=${sortedResults.filter((r) => r.matchType === 'content').length}, ` + + `bothMatches=${sortedResults.filter((r) => r.matchType === 'both').length}` + ) + + return sortedResults +} diff --git a/src/renderer/src/utils/__tests__/markdownConverter.test.ts b/src/renderer/src/utils/__tests__/markdownConverter.test.ts index 44396d76c..25a330b11 100644 --- a/src/renderer/src/utils/__tests__/markdownConverter.test.ts +++ b/src/renderer/src/utils/__tests__/markdownConverter.test.ts @@ -1,7 +1,18 @@ +import { MARKDOWN_SOURCE_LINE_ATTR } from '@renderer/components/RichEditor/constants' import { describe, expect, it } from 'vitest' import { htmlToMarkdown, markdownToHtml } from '../markdownConverter' +/** + * Strip markdown line number attributes for testing HTML structure + */ + +const LINE_NUMBER_REGEX = new RegExp(`\\s*${MARKDOWN_SOURCE_LINE_ATTR.replace(/-/g, '\\-')}="\\d+"`, 'g') + +function stripLineNumbers(html: string): string { + return html.replace(LINE_NUMBER_REGEX, '') +} + describe('markdownConverter', () => { describe('htmlToMarkdown', () => { it('should convert HTML to Markdown', () => { @@ -104,13 +115,13 @@ describe('markdownConverter', () => { describe('markdownToHtml', () => { it('should convert
      to
      ', () => { const markdown = 'Text with
      \nindentation
      \nand without indentation' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe('

      Text with
      \nindentation
      \nand without indentation

      \n') }) it('should handle indentation in blockquotes', () => { const markdown = '> Quote line 1\n> Quote line 2 with indentation' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) // This should preserve indentation within the blockquote expect(result).toContain('Quote line 1') expect(result).toContain('Quote line 2 with indentation') @@ -118,7 +129,7 @@ describe('markdownConverter', () => { it('should preserve indentation in nested lists', () => { const markdown = '- Item 1\n - Nested item\n - Double nested\n with continued line' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) // Should create proper nested list structure expect(result).toContain('
        ') expect(result).toContain('
      • ') @@ -126,13 +137,13 @@ describe('markdownConverter', () => { it('should handle poetry or formatted text with indentation', () => { const markdown = 'Roses are red\n Violets are blue\n Sugar is sweet\n And so are you' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe('

        Roses are red\nViolets are blue\nSugar is sweet\nAnd so are you

        \n') }) it('should preserve indentation after line breaks with multiple paragraphs', () => { const markdown = 'First paragraph\n\n with indentation\n\n Second paragraph\n\nwith different indentation' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe( '

        First paragraph

        \n
        with indentation\n\nSecond paragraph\n

        with different indentation

        \n' ) @@ -140,14 +151,14 @@ describe('markdownConverter', () => { it('should handle zero-width indentation (just line break)', () => { const markdown = 'Hello\n\nWorld' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe('

        Hello

        \n

        World

        \n') }) it('should preserve indentation in mixed content', () => { const markdown = 'Normal text\n Indented continuation\n\n- List item\n List continuation\n\n> Quote\n> Indented quote' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe( '

        Normal text\nIndented continuation

        \n
          \n
        • List item\nList continuation
        • \n
        \n
        \n

        Quote\nIndented quote

        \n
        \n' ) @@ -155,19 +166,19 @@ describe('markdownConverter', () => { it('should convert Markdown to HTML', () => { const markdown = '# Hello World' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toContain('

        Hello World

        ') }) it('should convert math block syntax to HTML', () => { const markdown = '$$a+b+c$$' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toContain('
        ') }) it('should convert math inline syntax to HTML', () => { const markdown = '$a+b+c$' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toContain('') }) @@ -181,7 +192,7 @@ describe('markdownConverter', () => { \\nabla \\cdot \\vec{\\mathbf{B}} & = 0 \\end{array}$$` - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toContain( '
        ' ) @@ -189,7 +200,7 @@ describe('markdownConverter', () => { it('should convert task list syntax to proper HTML', () => { const markdown = '- [ ] abcd\n\n- [x] efgh\n\n' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toContain('data-type="taskList"') expect(result).toContain('data-type="taskItem"') expect(result).toContain('data-checked="false"') @@ -202,7 +213,7 @@ describe('markdownConverter', () => { it('should convert mixed task list with checked and unchecked items', () => { const markdown = '- [ ] First task\n\n- [x] Second task\n\n- [ ] Third task' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toContain('data-type="taskList"') expect(result).toContain('First task') expect(result).toContain('Second task') @@ -213,14 +224,14 @@ describe('markdownConverter', () => { it('should NOT convert standalone task syntax to task list', () => { const markdown = '[x] abcd' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toContain('

        [x] abcd

        ') expect(result).not.toContain('data-type="taskList"') }) it('should handle regular list items alongside task lists', () => { const markdown = '- Regular item\n\n- [ ] Task item\n\n- Another regular item' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toContain('data-type="taskList"') expect(result).toContain('Regular item') expect(result).toContain('Task item') @@ -241,25 +252,25 @@ describe('markdownConverter', () => { const markdown = `# 🌠 Screenshot ![](https://example.com/image.png)` - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe('

        🌠 Screenshot

        \n

        \n') }) it('should handle heading and paragraph', () => { const markdown = '# Hello\n\nHello' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe('

        Hello

        \n

        Hello

        \n') }) it('should convert code block to HTML', () => { const markdown = '```\nconsole.log("Hello, world!");\n```' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe('
        console.log("Hello, world!");\n
        ') }) it('should convert code block with language to HTML', () => { const markdown = '```javascript\nconsole.log("Hello, world!");\n```' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe( '
        console.log("Hello, world!");\n
        ' ) @@ -267,7 +278,7 @@ describe('markdownConverter', () => { it('should convert table to HTML', () => { const markdown = '| f | | |\n| --- | --- | --- |\n| | f | |\n| | | f |' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe( '\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
        f
        f
        f
        \n' ) @@ -275,7 +286,7 @@ describe('markdownConverter', () => { it('should escape XML-like tags in code blocks', () => { const markdown = '```jsx\nconst component = <>
        content
        \n```' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe( '
        const component = <><div>content</div></>\n
        ' ) @@ -283,13 +294,13 @@ describe('markdownConverter', () => { it('should escape XML-like tags in inline code', () => { const markdown = 'Use `<>` for fragments' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe('

        Use <> for fragments

        \n') }) it('shoud convert XML-like tags in paragraph', () => { const markdown = '' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe('

        \n') }) }) @@ -297,7 +308,7 @@ describe('markdownConverter', () => { describe('Task List with Labels', () => { it('should wrap task items with labels when label option is true', () => { const markdown = '- [ ] abcd\n\n- [x] efgh' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe( '
          \n
        • \n

          \n
        • \n
        • \n

          \n
        • \n
        \n' ) @@ -317,7 +328,7 @@ describe('markdownConverter', () => { const originalHtml = '
        ' const markdown = htmlToMarkdown(originalHtml) - const html = markdownToHtml(markdown) + const html = stripLineNumbers(markdownToHtml(markdown)) expect(html).toBe( '
          \n
        • \n
        \n' @@ -328,7 +339,7 @@ describe('markdownConverter', () => { const originalHtml = '
          \n
        • \n

          123

          \n
        • \n
        • \n

          \n
        • \n
        \n' const markdown = htmlToMarkdown(originalHtml) - const html = markdownToHtml(markdown) + const html = stripLineNumbers(markdownToHtml(markdown)) expect(html).toBe(originalHtml) }) @@ -383,7 +394,7 @@ describe('markdownConverter', () => { describe('markdown image', () => { it('should convert markdown image to HTML img tag', () => { const markdown = '![foo](train.jpg)' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe('

        foo

        \n') }) it('should convert markdown image with file:// protocol to HTML img tag', () => { @@ -420,7 +431,7 @@ describe('markdownConverter', () => { it('should handle hardbreak with backslash followed by indented text', () => { const markdown = 'Text with \\\n indentation \\\nand without indentation' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe('

        Text with
        \nindentation
        \nand without indentation

        \n') }) @@ -454,7 +465,7 @@ describe('markdownConverter', () => { it('should preserve custom XML tags mixed with regular markdown', () => { const markdown = '# Heading\n\nWidget content\n\n**Bold text**' - const html = markdownToHtml(markdown) + const html = stripLineNumbers(markdownToHtml(markdown)) const backToMarkdown = htmlToMarkdown(html) expect(html).toContain('

        Heading

        ') @@ -470,7 +481,7 @@ describe('markdownConverter', () => { it('should not add unwanted line breaks during simple text typing', () => { const html = '

        Hello world

        ' const markdown = htmlToMarkdown(html) - const backToHtml = markdownToHtml(markdown) + const backToHtml = stripLineNumbers(markdownToHtml(markdown)) expect(markdown).toBe('Hello world') expect(backToHtml).toBe('

        Hello world

        \n') @@ -479,7 +490,7 @@ describe('markdownConverter', () => { it('should preserve simple paragraph structure during round-trip conversion', () => { const originalHtml = '

        This is a simple paragraph being typed

        ' const markdown = htmlToMarkdown(originalHtml) - const backToHtml = markdownToHtml(markdown) + const backToHtml = stripLineNumbers(markdownToHtml(markdown)) expect(markdown).toBe('This is a simple paragraph being typed') expect(backToHtml).toBe('

        This is a simple paragraph being typed

        \n') }) @@ -520,4 +531,24 @@ cssclasses: expect(backToMarkdown).toBe(markdown) }) }) + + describe('should have markdown line number injected in HTML', () => { + it('should inject line numbers into paragraphs', () => { + const markdown = 'First paragraph\n\nSecond paragraph\n\nThird paragraph' + const result = markdownToHtml(markdown) + expect(result).toContain(`

        First paragraph

        `) + expect(result).toContain(`

        Second paragraph

        `) + expect(result).toContain(`

        Third paragraph

        `) + }) + + it('should inject line numbers into mixed content', () => { + const markdown = 'Text\n\n- List\n\n> Quote' + const result = markdownToHtml(markdown) + expect(result).toContain(`

        Text

        `) + expect(result).toContain(`
          `) + expect(result).toContain(`
        • List
        • `) + expect(result).toContain(`
          `) + expect(result).toContain(`

          Quote

          `) + }) + }) }) diff --git a/src/renderer/src/utils/markdownConverter.ts b/src/renderer/src/utils/markdownConverter.ts index 3b766cd32..51d684612 100644 --- a/src/renderer/src/utils/markdownConverter.ts +++ b/src/renderer/src/utils/markdownConverter.ts @@ -1,4 +1,5 @@ import { loggerService } from '@logger' +import { MARKDOWN_SOURCE_LINE_ATTR } from '@renderer/components/RichEditor/constants' import { TurndownPlugin } from '@truto/turndown-plugin-gfm' import he from 'he' import htmlTags, { type HtmlTags } from 'html-tags' @@ -85,12 +86,57 @@ const md = new MarkdownIt({ typographer: false // Enable smartypants and other sweet transforms }) +// Helper function to inject line number data attribute +function injectLineNumber(token: any, openTag: string): string { + if (token.map && token.map.length >= 2) { + const startLine = token.map[0] + 1 // Convert to 1-based line number + // Insert data attribute before the first closing > + // Handle both self-closing tags (e.g.,
          ) and opening tags (e.g.,

          ) + const result = openTag.replace(/(\s*\/?>)/, ` ${MARKDOWN_SOURCE_LINE_ATTR}="${startLine}"$1`) + logger.debug('injectLineNumber', { openTag, result, startLine, hasMap: !!token.map }) + return result + } + return openTag +} + +// Store the original renderer +const defaultRender = md.renderer.render.bind(md.renderer) + +// Override the main render method to inject line numbers +md.renderer.render = function (tokens, options, env) { + return defaultRender(tokens, options, env) +} + +// Override default rendering rules to add line numbers +const defaultBlockRules = [ + 'paragraph_open', + 'heading_open', + 'blockquote_open', + 'bullet_list_open', + 'ordered_list_open', + 'list_item_open', + 'table_open', + 'hr' +] + +defaultBlockRules.forEach((ruleName) => { + const original = md.renderer.rules[ruleName] + md.renderer.rules[ruleName] = function (tokens, idx, options, env, self) { + const token = tokens[idx] + let result = original ? original(tokens, idx, options, env, self) : self.renderToken(tokens, idx, options) + result = injectLineNumber(token, result) + return result + } +}) + // Override the code_block and code_inline renderers to properly escape HTML entities md.renderer.rules.code_block = function (tokens, idx) { const token = tokens[idx] const langName = token.info ? ` class="language-${token.info.trim()}"` : '' const escapedContent = he.encode(token.content, { useNamedReferences: false }) - return `

          ${escapedContent}
          ` + let html = `
          ${escapedContent}
          ` + html = injectLineNumber(token, html) + return html } md.renderer.rules.code_inline = function (tokens, idx) { @@ -103,7 +149,9 @@ md.renderer.rules.fence = function (tokens, idx) { const token = tokens[idx] const langName = token.info ? ` class="language-${token.info.trim()}"` : '' const escapedContent = he.encode(token.content, { useNamedReferences: false }) - return `
          ${escapedContent}
          ` + let html = `
          ${escapedContent}
          ` + html = injectLineNumber(token, html) + return html } // Custom task list plugin for markdown-it @@ -305,8 +353,11 @@ function yamlFrontMatterPlugin(md: MarkdownIt) { // Renderer: output YAML front matter as special HTML element md.renderer.rules.yaml_front_matter = (tokens: Array<{ content?: string }>, idx: number): string => { - const content = tokens[idx]?.content ?? '' - return `
          ${content}
          ` + const token = tokens[idx] + const content = token?.content ?? '' + let html = `
          ${content}
          ` + html = injectLineNumber(token, html) + return html } } @@ -408,9 +459,12 @@ function tipTapKatexPlugin(md: MarkdownIt) { // 2) Renderer: output TipTap-friendly container md.renderer.rules.math_block = (tokens: Array<{ content?: string }>, idx: number): string => { - const content = tokens[idx]?.content ?? '' + const token = tokens[idx] + const content = token?.content ?? '' const latexEscaped = he.encode(content, { useNamedReferences: true }) - return `
          ` + let html = `
          ` + html = injectLineNumber(token, html) + return html } // 3) Inline parser: recognize $...$ on a single line as inline math From 2243bb286207ace993f641687099f738847963e6 Mon Sep 17 00:00:00 2001 From: Kejiang Ma Date: Fri, 17 Oct 2025 13:40:53 +0800 Subject: [PATCH 29/39] =?UTF-8?q?feat:=20update=20and=20download=20ovms=20?= =?UTF-8?q?to=202025.3=20official=20release=20from=20offici=E2=80=A6=20(#1?= =?UTF-8?q?0603)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: update and download ovms to 2025.3 official release from official site. Signed-off-by: Kejiang Ma * fix UI text Signed-off-by: Kejiang Ma --------- Signed-off-by: Kejiang Ma --- resources/scripts/install-ovms.js | 236 ++++++++++++------ src/renderer/src/i18n/locales/en-us.json | 6 +- src/renderer/src/i18n/locales/zh-cn.json | 8 +- src/renderer/src/i18n/locales/zh-tw.json | 8 +- .../ProviderSettings/OVMSSettings.tsx | 6 +- 5 files changed, 179 insertions(+), 85 deletions(-) diff --git a/resources/scripts/install-ovms.js b/resources/scripts/install-ovms.js index 57710e43f..e4a5cf044 100644 --- a/resources/scripts/install-ovms.js +++ b/resources/scripts/install-ovms.js @@ -5,105 +5,171 @@ const { execSync } = require('child_process') const { downloadWithPowerShell } = require('./download') // Base URL for downloading OVMS binaries -const OVMS_PKG_NAME = 'ovms250911.zip' -const OVMS_RELEASE_BASE_URL = [`https://gitcode.com/gcw_ggDjjkY3/kjfile/releases/download/download/${OVMS_PKG_NAME}`] +const OVMS_RELEASE_BASE_URL = + 'https://storage.openvinotoolkit.org/repositories/openvino_model_server/packages/2025.3.0/ovms_windows_python_on.zip' +const OVMS_EX_URL = 'https://gitcode.com/gcw_ggDjjkY3/kjfile/releases/download/download/ovms_25.3_ex.zip' /** - * Downloads and extracts the OVMS binary for the specified platform + * error code: + * 101: Unsupported CPU (not Intel Ultra) + * 102: Unsupported platform (not Windows) + * 103: Download failed + * 104: Installation failed + * 105: Failed to create ovdnd.exe + * 106: Failed to create run.bat + * 110: Cleanup of old installation failed */ -async function downloadOvmsBinary() { - // Create output directory structure - OVMS goes into its own subdirectory + +/** + * Clean old OVMS installation if it exists + */ +function cleanOldOvmsInstallation() { + console.log('Cleaning the existing OVMS installation...') const csDir = path.join(os.homedir(), '.cherrystudio') - - // Ensure directories exist - fs.mkdirSync(csDir, { recursive: true }) - const csOvmsDir = path.join(csDir, 'ovms') - // Delete existing OVMS directory if it exists if (fs.existsSync(csOvmsDir)) { - fs.rmSync(csOvmsDir, { recursive: true }) - } - - const tempdir = os.tmpdir() - const tempFilename = path.join(tempdir, 'ovms.zip') - - // Try each URL until one succeeds - let downloadSuccess = false - let lastError = null - - for (let i = 0; i < OVMS_RELEASE_BASE_URL.length; i++) { - const downloadUrl = OVMS_RELEASE_BASE_URL[i] - console.log(`Attempting download from URL ${i + 1}/${OVMS_RELEASE_BASE_URL.length}: ${downloadUrl}`) - try { - console.log(`Downloading OVMS from ${downloadUrl} to ${tempFilename}...`) - - // Try PowerShell download first, fallback to Node.js download if it fails - await downloadWithPowerShell(downloadUrl, tempFilename) - - // If we get here, download was successful - downloadSuccess = true - console.log(`Successfully downloaded from: ${downloadUrl}`) - break + fs.rmSync(csOvmsDir, { recursive: true }) } catch (error) { - console.warn(`Download failed from ${downloadUrl}: ${error.message}`) - lastError = error - - // Clean up failed download file if it exists - if (fs.existsSync(tempFilename)) { - try { - fs.unlinkSync(tempFilename) - } catch (cleanupError) { - console.warn(`Failed to clean up temporary file: ${cleanupError.message}`) - } - } - - // Continue to next URL if this one failed - if (i < OVMS_RELEASE_BASE_URL.length - 1) { - console.log(`Trying next URL...`) - } + console.warn(`Failed to clean up old OVMS installation: ${error.message}`) + return 110 } } - // Check if any download succeeded - if (!downloadSuccess) { - console.error(`All download URLs failed. Last error: ${lastError?.message || 'Unknown error'}`) + return 0 +} + +/** + * Install OVMS Base package + */ +async function installOvmsBase() { + // Download the base package + const tempdir = os.tmpdir() + const tempFilename = path.join(tempdir, 'ovms.zip') + + try { + console.log(`Downloading OVMS Base Package from ${OVMS_RELEASE_BASE_URL} to ${tempFilename}...`) + + // Try PowerShell download first, fallback to Node.js download if it fails + await downloadWithPowerShell(OVMS_RELEASE_BASE_URL, tempFilename) + console.log(`Successfully downloaded from: ${OVMS_RELEASE_BASE_URL}`) + } catch (error) { + console.error(`Download OVMS Base failed: ${error.message}`) + fs.unlinkSync(tempFilename) return 103 } - try { - console.log(`Extracting to ${csDir}...`) + // unzip the base package to the target directory + const csDir = path.join(os.homedir(), '.cherrystudio') + const csOvmsDir = path.join(csDir, 'ovms') + fs.mkdirSync(csOvmsDir, { recursive: true }) + try { // Use tar.exe to extract the ZIP file - console.log(`Extracting OVMS to ${csDir}...`) - execSync(`tar -xf ${tempFilename} -C ${csDir}`, { stdio: 'inherit' }) - console.log(`OVMS extracted to ${csDir}`) + console.log(`Extracting OVMS Base to ${csOvmsDir}...`) + execSync(`tar -xf ${tempFilename} -C ${csOvmsDir}`, { stdio: 'inherit' }) + console.log(`OVMS extracted to ${csOvmsDir}`) // Clean up temporary file fs.unlinkSync(tempFilename) console.log(`Installation directory: ${csDir}`) } catch (error) { console.error(`Error installing OVMS: ${error.message}`) - if (fs.existsSync(tempFilename)) { - fs.unlinkSync(tempFilename) - } - - // Check if ovmsDir is empty and remove it if so - try { - const ovmsDir = path.join(csDir, 'ovms') - const files = fs.readdirSync(ovmsDir) - if (files.length === 0) { - fs.rmSync(ovmsDir, { recursive: true }) - console.log(`Removed empty directory: ${ovmsDir}`) - } - } catch (cleanupError) { - console.warn(`Warning: Failed to clean up directory: ${cleanupError.message}`) - return 105 - } - + fs.unlinkSync(tempFilename) return 104 } + const csOvmsBinDir = path.join(csOvmsDir, 'ovms') + // copy ovms.exe to ovdnd.exe + try { + fs.copyFileSync(path.join(csOvmsBinDir, 'ovms.exe'), path.join(csOvmsBinDir, 'ovdnd.exe')) + console.log('Copied ovms.exe to ovdnd.exe') + } catch (error) { + console.error(`Error copying ovms.exe to ovdnd.exe: ${error.message}`) + return 105 + } + + // copy {csOvmsBinDir}/setupvars.bat to {csOvmsBinDir}/run.bat, and append the following lines to run.bat: + // del %USERPROFILE%\.cherrystudio\ovms_log.log + // ovms.exe --config_path models/config.json --rest_port 8000 --log_level DEBUG --log_path %USERPROFILE%\.cherrystudio\ovms_log.log + const runBatPath = path.join(csOvmsBinDir, 'run.bat') + try { + fs.copyFileSync(path.join(csOvmsBinDir, 'setupvars.bat'), runBatPath) + fs.appendFileSync(runBatPath, '\r\n') + fs.appendFileSync(runBatPath, 'del %USERPROFILE%\\.cherrystudio\\ovms_log.log\r\n') + fs.appendFileSync( + runBatPath, + 'ovms.exe --config_path models/config.json --rest_port 8000 --log_level DEBUG --log_path %USERPROFILE%\\.cherrystudio\\ovms_log.log\r\n' + ) + console.log(`Created run.bat at: ${runBatPath}`) + } catch (error) { + console.error(`Error creating run.bat: ${error.message}`) + return 106 + } + + // create {csOvmsBinDir}/models/config.json with content '{"model_config_list": []}' + const configJsonPath = path.join(csOvmsBinDir, 'models', 'config.json') + fs.mkdirSync(path.dirname(configJsonPath), { recursive: true }) + fs.writeFileSync(configJsonPath, '{"mediapipe_config_list":[],"model_config_list":[]}') + console.log(`Created config file: ${configJsonPath}`) + + return 0 +} + +/** + * Install OVMS Extra package + */ +async function installOvmsExtra() { + // Download the extra package + const tempdir = os.tmpdir() + const tempFilename = path.join(tempdir, 'ovms_ex.zip') + + try { + console.log(`Downloading OVMS Extra Package from ${OVMS_EX_URL} to ${tempFilename}...`) + + // Try PowerShell download first, fallback to Node.js download if it fails + await downloadWithPowerShell(OVMS_EX_URL, tempFilename) + console.log(`Successfully downloaded from: ${OVMS_EX_URL}`) + } catch (error) { + console.error(`Download OVMS Extra failed: ${error.message}`) + fs.unlinkSync(tempFilename) + return 103 + } + + // unzip the extra package to the target directory + const csDir = path.join(os.homedir(), '.cherrystudio') + const csOvmsDir = path.join(csDir, 'ovms') + + try { + // Use tar.exe to extract the ZIP file + console.log(`Extracting OVMS Extra to ${csOvmsDir}...`) + execSync(`tar -xf ${tempFilename} -C ${csOvmsDir}`, { stdio: 'inherit' }) + console.log(`OVMS extracted to ${csOvmsDir}`) + + // Clean up temporary file + fs.unlinkSync(tempFilename) + console.log(`Installation directory: ${csDir}`) + } catch (error) { + console.error(`Error installing OVMS Extra: ${error.message}`) + fs.unlinkSync(tempFilename) + return 104 + } + + // apply ovms patch, copy all files in {csOvmsDir}/patch/ovms to {csOvmsDir}/ovms with overwrite mode + const patchDir = path.join(csOvmsDir, 'patch', 'ovms') + const csOvmsBinDir = path.join(csOvmsDir, 'ovms') + try { + const files = fs.readdirSync(patchDir) + files.forEach((file) => { + const srcPath = path.join(patchDir, file) + const destPath = path.join(csOvmsBinDir, file) + fs.copyFileSync(srcPath, destPath) + console.log(`Applied patch file: ${file}`) + }) + } catch (error) { + console.error(`Error applying OVMS patch: ${error.message}`) + } + return 0 } @@ -158,7 +224,27 @@ async function installOvms() { return 102 } - return await downloadOvmsBinary() + // Clean old installation if it exists + const cleanupCode = cleanOldOvmsInstallation() + if (cleanupCode !== 0) { + console.error(`OVMS cleanup failed with code: ${cleanupCode}`) + return cleanupCode + } + + const installBaseCode = await installOvmsBase() + if (installBaseCode !== 0) { + console.error(`OVMS Base installation failed with code: ${installBaseCode}`) + cleanOldOvmsInstallation() + return installBaseCode + } + + const installExtraCode = await installOvmsExtra() + if (installExtraCode !== 0) { + console.error(`OVMS Extra installation failed with code: ${installExtraCode}`) + return installExtraCode + } + + return 0 } // Run the installation diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 6b0dd3c0c..035420b61 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -2110,8 +2110,10 @@ "install_code_101": "Only supports Intel(R) Core(TM) Ultra CPU", "install_code_102": "Only supports Windows", "install_code_103": "Download OVMS runtime failed", - "install_code_104": "Uncompress OVMS runtime failed", - "install_code_105": "Clean OVMS runtime failed", + "install_code_104": "Failed to install OVMS runtime", + "install_code_105": "Failed to create ovdnd.exe", + "install_code_106": "Failed to create run.bat", + "install_code_110": "Failed to clean old OVMS runtime", "run": "Run OVMS failed:", "stop": "Stop OVMS failed:" }, diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 15675d265..071ff09c8 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -2085,7 +2085,7 @@ "description": "

          1. 下载 OV 模型.

          2. 在 'Manager' 中添加模型.

          仅支持 Windows!

          OVMS 安装路径: '%USERPROFILE%\\.cherrystudio\\ovms' .

          请参考 Intel OVMS 指南

          ", "download": { "button": "下载", - "error": "选择失败", + "error": "下载失败", "model_id": { "label": "模型 ID", "model_id_pattern": "模型 ID 必须以 OpenVINO/ 开头", @@ -2110,8 +2110,10 @@ "install_code_101": "仅支持 Intel(R) Core(TM) Ultra CPU", "install_code_102": "仅支持 Windows", "install_code_103": "下载 OVMS runtime 失败", - "install_code_104": "解压 OVMS runtime 失败", - "install_code_105": "清理 OVMS runtime 失败", + "install_code_104": "安装 OVMS runtime 失败", + "install_code_105": "创建 ovdnd.exe 失败", + "install_code_106": "创建 run.bat 失败", + "install_code_110": "清理旧 OVMS runtime 失败", "run": "运行 OVMS 失败:", "stop": "停止 OVMS 失败:" }, diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 020a0e5a1..fea60d0fd 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -2085,7 +2085,7 @@ "description": "

          1. 下載 OV 模型。

          2. 在 'Manager' 中新增模型。

          僅支援 Windows!

          OVMS 安裝路徑: '%USERPROFILE%\\.cherrystudio\\ovms' 。

          請參考 Intel OVMS 指南

          ", "download": { "button": "下載", - "error": "選擇失敗", + "error": "下載失敗", "model_id": { "label": "模型 ID", "model_id_pattern": "模型 ID 必須以 OpenVINO/ 開頭", @@ -2110,8 +2110,10 @@ "install_code_101": "僅支援 Intel(R) Core(TM) Ultra CPU", "install_code_102": "僅支援 Windows", "install_code_103": "下載 OVMS runtime 失敗", - "install_code_104": "解壓 OVMS runtime 失敗", - "install_code_105": "清理 OVMS runtime 失敗", + "install_code_104": "安裝 OVMS runtime 失敗", + "install_code_105": "創建 ovdnd.exe 失敗", + "install_code_106": "創建 run.bat 失敗", + "install_code_110": "清理舊 OVMS runtime 失敗", "run": "執行 OVMS 失敗:", "stop": "停止 OVMS 失敗:" }, diff --git a/src/renderer/src/pages/settings/ProviderSettings/OVMSSettings.tsx b/src/renderer/src/pages/settings/ProviderSettings/OVMSSettings.tsx index 827e4097e..057408375 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/OVMSSettings.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/OVMSSettings.tsx @@ -36,7 +36,9 @@ const OVMSSettings: FC = () => { '102': t('ovms.failed.install_code_102'), '103': t('ovms.failed.install_code_103'), '104': t('ovms.failed.install_code_104'), - '105': t('ovms.failed.install_code_105') + '105': t('ovms.failed.install_code_105'), + '106': t('ovms.failed.install_code_106'), + '110': t('ovms.failed.install_code_110') } const match = error.message.match(/code (\d+)/) const code = match ? match[1] : 'unknown' @@ -135,7 +137,7 @@ const OVMSSettings: FC = () => { type="primary" onClick={runOvms} loading={isRunningOvms} - disabled={isRunningOvms} + disabled={isRunningOvms || isInstallingOvms} size="small"> {isRunningOvms ? t('ovms.action.starting') : t('ovms.action.run')} From c4e0a6acfe2d904dfbc8ec6b83c22bd1cb6b2408 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Fri, 17 Oct 2025 15:07:19 +0800 Subject: [PATCH 30/39] fix: prevent default behavior for Cmd/Ctrl+F in WebviewService (#10783) fix: prevent default behavior for Cmd/Ctrl+F in WebviewService (#10800) Updated the keyboard handler in WebviewService to always prevent the default action for the Cmd/Ctrl+F shortcut, ensuring it overrides the guest page's native find dialog. This change allows the renderer to manage the behavior of Escape and Enter keys based on the visibility of the search bar. --- src/main/services/WebviewService.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/main/services/WebviewService.ts b/src/main/services/WebviewService.ts index 1b60cc664..fb2049de7 100644 --- a/src/main/services/WebviewService.ts +++ b/src/main/services/WebviewService.ts @@ -60,15 +60,20 @@ const attachKeyboardHandler = (contents: Electron.WebContents) => { if (!isFindShortcut && !isEscape && !isEnter) { return } - // Prevent default to override the guest page's native find dialog - // and keep shortcuts routed to our custom search overlay - event.preventDefault() const host = contents.hostWebContents if (!host || host.isDestroyed()) { return } + // Always prevent Cmd/Ctrl+F to override the guest page's native find dialog + if (isFindShortcut) { + event.preventDefault() + } + + // Send the hotkey event to the renderer + // The renderer will decide whether to preventDefault for Escape and Enter + // based on whether the search bar is visible host.send(IpcChannel.Webview_SearchHotkey, { webviewId: contents.id, key, From 0e5ebcfd004da70eea3b2516bdbb8c0e76a6378b Mon Sep 17 00:00:00 2001 From: Kejiang Ma Date: Fri, 17 Oct 2025 15:18:00 +0800 Subject: [PATCH 31/39] feat: new build-in OCR provider -> intel OV(NPU) OCR (#10737) * new build-in ocr provider intel ov Signed-off-by: Ma, Kejiang Signed-off-by: Kejiang Ma * updated base on PR's commnets Signed-off-by: Kejiang Ma * feat(OcrImageSettings): use swr to fetch available providers Add loading state and error handling when fetching available OCR providers. Display an alert when provider loading fails, showing the error message. Also optimize provider filtering logic using useMemo. * refactor(ocr): rename providers to listProviders for consistency Update method name to better reflect its functionality and maintain naming consistency across the codebase --------- Signed-off-by: Ma, Kejiang Signed-off-by: Kejiang Ma Co-authored-by: icarus --- packages/shared/IpcChannel.ts | 1 + src/main/ipc.ts | 1 + src/main/services/ocr/OcrService.ts | 7 + src/main/services/ocr/builtin/OvOcrService.ts | 128 ++++++++++++++++++ src/preload/index.ts | 3 +- src/renderer/src/config/ocr.ts | 16 ++- src/renderer/src/hooks/useOcrProvider.tsx | 3 + src/renderer/src/i18n/label.ts | 4 +- src/renderer/src/i18n/locales/en-us.json | 1 + src/renderer/src/i18n/locales/zh-cn.json | 1 + src/renderer/src/i18n/locales/zh-tw.json | 5 +- src/renderer/src/i18n/translate/el-gr.json | 1 + src/renderer/src/i18n/translate/es-es.json | 1 + src/renderer/src/i18n/translate/fr-fr.json | 1 + src/renderer/src/i18n/translate/ja-jp.json | 1 + src/renderer/src/i18n/translate/pt-pt.json | 1 + src/renderer/src/i18n/translate/ru-ru.json | 1 + .../DocProcessSettings/OcrImageSettings.tsx | 49 +++++-- .../DocProcessSettings/OcrOVSettings.tsx | 32 +++++ .../OcrProviderSettings.tsx | 3 + src/renderer/src/store/migrate.ts | 9 ++ src/renderer/src/types/ocr.ts | 19 ++- 22 files changed, 270 insertions(+), 18 deletions(-) create mode 100644 src/main/services/ocr/builtin/OvOcrService.ts create mode 100644 src/renderer/src/pages/settings/DocProcessSettings/OcrOVSettings.tsx diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 93679f5fa..be037f666 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -337,6 +337,7 @@ export enum IpcChannel { // OCR OCR_ocr = 'ocr:ocr', + OCR_ListProviders = 'ocr:list-providers', // OVMS Ovms_AddModel = 'ovms:add-model', diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 55d60fa20..f1a4de6a5 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -875,6 +875,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.OCR_ocr, (_, file: SupportedOcrFile, provider: OcrProvider) => ocrService.ocr(file, provider) ) + ipcMain.handle(IpcChannel.OCR_ListProviders, () => ocrService.listProviderIds()) // OVMS ipcMain.handle(IpcChannel.Ovms_AddModel, (_, modelName: string, modelId: string, modelSource: string, task: string) => diff --git a/src/main/services/ocr/OcrService.ts b/src/main/services/ocr/OcrService.ts index 471d31edc..b2943e30e 100644 --- a/src/main/services/ocr/OcrService.ts +++ b/src/main/services/ocr/OcrService.ts @@ -2,6 +2,7 @@ import { loggerService } from '@logger' import { isLinux } from '@main/constant' import { BuiltinOcrProviderIds, OcrHandler, OcrProvider, OcrResult, SupportedOcrFile } from '@types' +import { ovOcrService } from './builtin/OvOcrService' import { ppocrService } from './builtin/PpocrService' import { systemOcrService } from './builtin/SystemOcrService' import { tesseractService } from './builtin/TesseractService' @@ -22,6 +23,10 @@ export class OcrService { this.registry.delete(providerId) } + public listProviderIds(): string[] { + return Array.from(this.registry.keys()) + } + public async ocr(file: SupportedOcrFile, provider: OcrProvider): Promise { const handler = this.registry.get(provider.id) if (!handler) { @@ -39,3 +44,5 @@ ocrService.register(BuiltinOcrProviderIds.tesseract, tesseractService.ocr.bind(t !isLinux && ocrService.register(BuiltinOcrProviderIds.system, systemOcrService.ocr.bind(systemOcrService)) ocrService.register(BuiltinOcrProviderIds.paddleocr, ppocrService.ocr.bind(ppocrService)) + +ovOcrService.isAvailable() && ocrService.register(BuiltinOcrProviderIds.ovocr, ovOcrService.ocr.bind(ovOcrService)) diff --git a/src/main/services/ocr/builtin/OvOcrService.ts b/src/main/services/ocr/builtin/OvOcrService.ts new file mode 100644 index 000000000..1650ca883 --- /dev/null +++ b/src/main/services/ocr/builtin/OvOcrService.ts @@ -0,0 +1,128 @@ +import { loggerService } from '@logger' +import { isWin } from '@main/constant' +import { isImageFileMetadata, OcrOvConfig, OcrResult, SupportedOcrFile } from '@types' +import { exec } from 'child_process' +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' +import { promisify } from 'util' + +import { OcrBaseService } from './OcrBaseService' + +const logger = loggerService.withContext('OvOcrService') +const execAsync = promisify(exec) + +const PATH_BAT_FILE = path.join(os.homedir(), '.cherrystudio', 'ovms', 'ovocr', 'run.npu.bat') + +export class OvOcrService extends OcrBaseService { + constructor() { + super() + } + + public isAvailable(): boolean { + return ( + isWin && + os.cpus()[0].model.toLowerCase().includes('intel') && + os.cpus()[0].model.toLowerCase().includes('ultra') && + fs.existsSync(PATH_BAT_FILE) + ) + } + + private getOvOcrPath(): string { + return path.join(os.homedir(), '.cherrystudio', 'ovms', 'ovocr') + } + + private getImgDir(): string { + return path.join(this.getOvOcrPath(), 'img') + } + + private getOutputDir(): string { + return path.join(this.getOvOcrPath(), 'output') + } + + private async clearDirectory(dirPath: string): Promise { + if (fs.existsSync(dirPath)) { + const files = await fs.promises.readdir(dirPath) + for (const file of files) { + const filePath = path.join(dirPath, file) + const stats = await fs.promises.stat(filePath) + if (stats.isDirectory()) { + await this.clearDirectory(filePath) + await fs.promises.rmdir(filePath) + } else { + await fs.promises.unlink(filePath) + } + } + } else { + // If the directory does not exist, create it + await fs.promises.mkdir(dirPath, { recursive: true }) + } + } + + private async copyFileToImgDir(sourceFilePath: string, targetFileName: string): Promise { + const imgDir = this.getImgDir() + const targetFilePath = path.join(imgDir, targetFileName) + await fs.promises.copyFile(sourceFilePath, targetFilePath) + } + + private async runOcrBatch(): Promise { + const ovOcrPath = this.getOvOcrPath() + + try { + // Execute run.bat in the ov-ocr directory + await execAsync(`"${PATH_BAT_FILE}"`, { + cwd: ovOcrPath, + timeout: 60000 // 60 second timeout + }) + } catch (error) { + logger.error(`Error running ovocr batch: ${error}`) + throw new Error(`Failed to run OCR batch: ${error}`) + } + } + + private async ocrImage(filePath: string, options?: OcrOvConfig): Promise { + logger.info(`OV OCR called on ${filePath} with options ${JSON.stringify(options)}`) + + try { + // 1. Clear img directory and output directory + await this.clearDirectory(this.getImgDir()) + await this.clearDirectory(this.getOutputDir()) + + // 2. Copy file to img directory + const fileName = path.basename(filePath) + await this.copyFileToImgDir(filePath, fileName) + logger.info(`File copied to img directory: ${fileName}`) + + // 3. Run run.bat + logger.info('Running OV OCR batch process...') + await this.runOcrBatch() + + // 4. Check that output/[basename].txt file exists + const baseNameWithoutExt = path.basename(fileName, path.extname(fileName)) + const outputFilePath = path.join(this.getOutputDir(), `${baseNameWithoutExt}.txt`) + if (!fs.existsSync(outputFilePath)) { + throw new Error(`OV OCR output file not found at: ${outputFilePath}`) + } + + // 5. Read output/[basename].txt file content + const ocrText = await fs.promises.readFile(outputFilePath, 'utf-8') + logger.info(`OV OCR text extracted: ${ocrText.substring(0, 100)}...`) + + // 6. Return result + return { text: ocrText } + } catch (error) { + logger.error(`Error during OV OCR process: ${error}`) + throw error + } + } + + public ocr = async (file: SupportedOcrFile, options?: OcrOvConfig): Promise => { + if (isImageFileMetadata(file)) { + return this.ocrImage(file.path, options) + } else { + throw new Error('Unsupported file type, currently only image files are supported') + } + } +} + +export const ovOcrService = new OvOcrService() diff --git a/src/preload/index.ts b/src/preload/index.ts index 34656092b..900456004 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -480,7 +480,8 @@ const api = { }, ocr: { ocr: (file: SupportedOcrFile, provider: OcrProvider): Promise => - ipcRenderer.invoke(IpcChannel.OCR_ocr, file, provider) + ipcRenderer.invoke(IpcChannel.OCR_ocr, file, provider), + listProviders: (): Promise => ipcRenderer.invoke(IpcChannel.OCR_ListProviders) }, cherryai: { generateSignature: (params: { method: string; path: string; query: string; body: Record }) => diff --git a/src/renderer/src/config/ocr.ts b/src/renderer/src/config/ocr.ts index 02f0995a8..6eb52f1ad 100644 --- a/src/renderer/src/config/ocr.ts +++ b/src/renderer/src/config/ocr.ts @@ -1,6 +1,7 @@ import { BuiltinOcrProvider, BuiltinOcrProviderId, + OcrOvProvider, OcrPpocrProvider, OcrProviderCapability, OcrSystemProvider, @@ -50,10 +51,23 @@ const ppocrOcr: OcrPpocrProvider = { } } as const +const ovOcr: OcrOvProvider = { + id: 'ovocr', + name: 'Intel OV(NPU) OCR', + config: { + langs: isWin ? ['en-us', 'zh-cn'] : undefined + }, + capabilities: { + image: true + // pdf: true + } +} as const satisfies OcrOvProvider + export const BUILTIN_OCR_PROVIDERS_MAP = { tesseract, system: systemOcr, - paddleocr: ppocrOcr + paddleocr: ppocrOcr, + ovocr: ovOcr } as const satisfies Record export const BUILTIN_OCR_PROVIDERS: BuiltinOcrProvider[] = Object.values(BUILTIN_OCR_PROVIDERS_MAP) diff --git a/src/renderer/src/hooks/useOcrProvider.tsx b/src/renderer/src/hooks/useOcrProvider.tsx index 0a40f047f..bd597e226 100644 --- a/src/renderer/src/hooks/useOcrProvider.tsx +++ b/src/renderer/src/hooks/useOcrProvider.tsx @@ -1,4 +1,5 @@ import { loggerService } from '@logger' +import IntelLogo from '@renderer/assets/images/providers/intel.png' import PaddleocrLogo from '@renderer/assets/images/providers/paddleocr.png' import TesseractLogo from '@renderer/assets/images/providers/Tesseract.js.png' import { BUILTIN_OCR_PROVIDERS_MAP, DEFAULT_OCR_PROVIDER } from '@renderer/config/ocr' @@ -83,6 +84,8 @@ export const useOcrProviders = () => { return case 'paddleocr': return + case 'ovocr': + return } } return diff --git a/src/renderer/src/i18n/label.ts b/src/renderer/src/i18n/label.ts index c2ac69f1b..51edc964b 100644 --- a/src/renderer/src/i18n/label.ts +++ b/src/renderer/src/i18n/label.ts @@ -340,12 +340,14 @@ export const getBuiltInMcpServerDescriptionLabel = (key: string): string => { const builtinOcrProviderKeyMap = { system: 'ocr.builtin.system', tesseract: '', - paddleocr: '' + paddleocr: '', + ovocr: '' } as const satisfies Record export const getBuiltinOcrProviderLabel = (key: BuiltinOcrProviderId) => { if (key === 'tesseract') return 'Tesseract' else if (key == 'paddleocr') return 'PaddleOCR' + else if (key == 'ovocr') return 'Intel OV(NPU) OCR' else return getLabel(builtinOcrProviderKeyMap, key) } diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 035420b61..98cce3a51 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -2049,6 +2049,7 @@ "provider": { "cannot_remove_builtin": "Cannot delete built-in provider", "existing": "The provider already exists", + "get_providers": "Failed to get available providers", "not_found": "OCR provider does not exist", "update_failed": "Failed to update configuration" }, diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 071ff09c8..8e5e4ebe3 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -2049,6 +2049,7 @@ "provider": { "cannot_remove_builtin": "不能删除内置提供商", "existing": "提供商已存在", + "get_providers": "获取可用提供商失败", "not_found": "OCR 提供商不存在", "update_failed": "更新配置失败" }, diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index fea60d0fd..0b8842466 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -2048,8 +2048,9 @@ "error": { "provider": { "cannot_remove_builtin": "不能刪除內建提供者", - "existing": "提供商已存在", - "not_found": "OCR 提供商不存在", + "existing": "提供者已存在", + "get_providers": "取得可用提供者失敗", + "not_found": "OCR 提供者不存在", "update_failed": "更新配置失敗" }, "unknown": "OCR過程發生錯誤" diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 370f3ce65..c1d289316 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -2041,6 +2041,7 @@ "provider": { "cannot_remove_builtin": "Δεν είναι δυνατή η διαγραφή του ενσωματωμένου παρόχου", "existing": "Ο πάροχος υπηρεσιών υπάρχει ήδη", + "get_providers": "Αποτυχία λήψης διαθέσιμων παρόχων", "not_found": "Ο πάροχος OCR δεν υπάρχει", "update_failed": "Αποτυχία ενημέρωσης της διαμόρφωσης" }, diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 0c923c973..5825c72fd 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -2041,6 +2041,7 @@ "provider": { "cannot_remove_builtin": "No se puede eliminar el proveedor integrado", "existing": "El proveedor ya existe", + "get_providers": "Error al obtener proveedores disponibles", "not_found": "El proveedor de OCR no existe", "update_failed": "Actualización de la configuración fallida" }, diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 9e36984cb..e82bbe99e 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -2041,6 +2041,7 @@ "provider": { "cannot_remove_builtin": "Impossible de supprimer le fournisseur intégré", "existing": "Le fournisseur existe déjà", + "get_providers": "Échec de l'obtention des fournisseurs disponibles", "not_found": "Le fournisseur OCR n'existe pas", "update_failed": "Échec de la mise à jour de la configuration" }, diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index d9db953ae..5600675de 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -2041,6 +2041,7 @@ "provider": { "cannot_remove_builtin": "組み込みプロバイダーは削除できません", "existing": "プロバイダーはすでに存在します", + "get_providers": "利用可能なプロバイダーの取得に失敗しました", "not_found": "OCRプロバイダーが存在しません", "update_failed": "更新構成に失敗しました" }, diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 8f46afda0..c732b90f1 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -2041,6 +2041,7 @@ "provider": { "cannot_remove_builtin": "Não é possível excluir o provedor integrado", "existing": "O provedor já existe", + "get_providers": "Falha ao obter provedores disponíveis", "not_found": "O provedor OCR não existe", "update_failed": "Falha ao atualizar a configuração" }, diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 8bf1b823c..394cc9163 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -2041,6 +2041,7 @@ "provider": { "cannot_remove_builtin": "Не удается удалить встроенного поставщика", "existing": "Поставщик уже существует", + "get_providers": "Не удалось получить доступных поставщиков", "not_found": "Поставщик OCR отсутствует", "update_failed": "Обновление конфигурации не удалось" }, diff --git a/src/renderer/src/pages/settings/DocProcessSettings/OcrImageSettings.tsx b/src/renderer/src/pages/settings/DocProcessSettings/OcrImageSettings.tsx index 622d1349d..9050088d5 100644 --- a/src/renderer/src/pages/settings/DocProcessSettings/OcrImageSettings.tsx +++ b/src/renderer/src/pages/settings/DocProcessSettings/OcrImageSettings.tsx @@ -1,11 +1,14 @@ +import { Alert, Skeleton } from '@heroui/react' import { loggerService } from '@logger' import { ErrorTag } from '@renderer/components/Tags/ErrorTag' import { isMac, isWin } from '@renderer/config/constant' import { useOcrProviders } from '@renderer/hooks/useOcrProvider' import { BuiltinOcrProviderIds, ImageOcrProvider, isImageOcrProvider, OcrProvider } from '@renderer/types' +import { getErrorMessage } from '@renderer/utils' import { Select } from 'antd' -import { useEffect, useMemo } from 'react' +import { useCallback, useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' +import useSWRImmutable from 'swr/immutable' import { SettingRow, SettingRowTitle } from '..' @@ -18,10 +21,16 @@ type Props = { const OcrImageSettings = ({ setProvider }: Props) => { const { t } = useTranslation() const { providers, imageProvider, getOcrProviderName, setImageProviderId } = useOcrProviders() + const fetcher = useCallback(() => { + return window.api.ocr.listProviders() + }, []) + + const { data: validProviders, isLoading, error } = useSWRImmutable('ocr/providers', fetcher) const imageProviders = providers.filter((p) => isImageOcrProvider(p)) // 挂载时更新外部状态 + // FIXME: Just keep the imageProvider always valid, so we don't need update it in this component. useEffect(() => { setProvider(imageProvider) }, [imageProvider, setProvider]) @@ -40,12 +49,17 @@ const OcrImageSettings = ({ setProvider }: Props) => { const platformSupport = isMac || isWin const options = useMemo(() => { + if (!validProviders) return [] const platformFilter = platformSupport ? () => true : (p: ImageOcrProvider) => p.id !== BuiltinOcrProviderIds.system - return imageProviders.filter(platformFilter).map((p) => ({ - value: p.id, - label: getOcrProviderName(p) - })) - }, [getOcrProviderName, imageProviders, platformSupport]) + const validFilter = (p: ImageOcrProvider) => validProviders.includes(p.id) + return imageProviders + .filter(platformFilter) + .filter(validFilter) + .map((p) => ({ + value: p.id, + label: getOcrProviderName(p) + })) + }, [getOcrProviderName, imageProviders, platformSupport, validProviders]) const isSystem = imageProvider.id === BuiltinOcrProviderIds.system @@ -55,12 +69,23 @@ const OcrImageSettings = ({ setProvider }: Props) => { {t('settings.tool.ocr.image_provider')}
          {!platformSupport && isSystem && } - setImageProvider(id)} + options={options} + /> + )} + {error && ( + + )} +
          diff --git a/src/renderer/src/pages/settings/DocProcessSettings/OcrOVSettings.tsx b/src/renderer/src/pages/settings/DocProcessSettings/OcrOVSettings.tsx new file mode 100644 index 000000000..507576608 --- /dev/null +++ b/src/renderer/src/pages/settings/DocProcessSettings/OcrOVSettings.tsx @@ -0,0 +1,32 @@ +import { useOcrProvider } from '@renderer/hooks/useOcrProvider' +import { BuiltinOcrProviderIds, isOcrOVProvider } from '@renderer/types' +import { Flex, Tag } from 'antd' +import { useTranslation } from 'react-i18next' + +import { SettingRow, SettingRowTitle } from '..' + +export const OcrOVSettings = () => { + const { t } = useTranslation() + const { provider } = useOcrProvider(BuiltinOcrProviderIds.ovocr) + + if (!isOcrOVProvider(provider)) { + throw new Error('Not OV OCR provider.') + } + + return ( + <> + + + + {t('settings.tool.ocr.common.langs')} + + +
          + 🇬🇧 {t('languages.english')} + 🇨🇳 {t('languages.chinese')} + 🇭🇰 {t('languages.chinese-traditional')} +
          +
          + + ) +} diff --git a/src/renderer/src/pages/settings/DocProcessSettings/OcrProviderSettings.tsx b/src/renderer/src/pages/settings/DocProcessSettings/OcrProviderSettings.tsx index 120e5a9e4..482ff2b9e 100644 --- a/src/renderer/src/pages/settings/DocProcessSettings/OcrProviderSettings.tsx +++ b/src/renderer/src/pages/settings/DocProcessSettings/OcrProviderSettings.tsx @@ -8,6 +8,7 @@ import { Divider, Flex } from 'antd' import styled from 'styled-components' import { SettingGroup, SettingTitle } from '..' +import { OcrOVSettings } from './OcrOVSettings' import { OcrPpocrSettings } from './OcrPpocrSettings' import { OcrSystemSettings } from './OcrSystemSettings' import { OcrTesseractSettings } from './OcrTesseractSettings' @@ -35,6 +36,8 @@ const OcrProviderSettings = ({ provider }: Props) => { return case 'paddleocr': return + case 'ovocr': + return default: return null } diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 362396ca2..5cca66b47 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -2667,6 +2667,15 @@ const migrateConfig = { logger.error('migrate 162 error', error as Error) return state } + }, + '163': (state: RootState) => { + try { + addOcrProvider(state, BUILTIN_OCR_PROVIDERS_MAP.ovocr) + return state + } catch (error) { + logger.error('migrate 163 error', error as Error) + return state + } } } diff --git a/src/renderer/src/types/ocr.ts b/src/renderer/src/types/ocr.ts index d67cba958..765c4e411 100644 --- a/src/renderer/src/types/ocr.ts +++ b/src/renderer/src/types/ocr.ts @@ -5,7 +5,8 @@ import { FileMetadata, ImageFileMetadata, isImageFileMetadata, TranslateLanguage export const BuiltinOcrProviderIds = { tesseract: 'tesseract', system: 'system', - paddleocr: 'paddleocr' + paddleocr: 'paddleocr', + ovocr: 'ovocr' } as const export type BuiltinOcrProviderId = keyof typeof BuiltinOcrProviderIds @@ -188,3 +189,19 @@ export type OcrPpocrProvider = { export const isOcrPpocrProvider = (p: OcrProvider): p is OcrPpocrProvider => { return p.id === BuiltinOcrProviderIds.paddleocr } + +// OV OCR Types +export type OcrOvConfig = OcrProviderBaseConfig & { + langs?: TranslateLanguageCode[] +} + +export type OcrOvProvider = { + id: 'ovocr' + config: OcrOvConfig +} & ImageOcrProvider & + // PdfOcrProvider & + BuiltinOcrProvider + +export const isOcrOVProvider = (p: OcrProvider): p is OcrOvProvider => { + return p.id === BuiltinOcrProviderIds.ovocr +} From d1a9dfa3e60b1834e4948c14643247e84f2fde6f Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Fri, 17 Oct 2025 17:19:16 +0800 Subject: [PATCH 32/39] feat: add Greek language option to spell checker options (#10793) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat: add Greek language option to GeneralSettings component - Added support for Greek (Ελληνικά) language in the language selection dropdown of the GeneralSettings component. --- src/renderer/src/pages/settings/GeneralSettings.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/pages/settings/GeneralSettings.tsx b/src/renderer/src/pages/settings/GeneralSettings.tsx index 84f54168a..3bfdd5968 100644 --- a/src/renderer/src/pages/settings/GeneralSettings.tsx +++ b/src/renderer/src/pages/settings/GeneralSettings.tsx @@ -147,7 +147,8 @@ const GeneralSettings: FC = () => { { value: 'pt', label: 'Português', flag: '🇵🇹' }, { value: 'ru', label: 'Русский', flag: '🇷🇺' }, { value: 'nl', label: 'Nederlands', flag: '🇳🇱' }, - { value: 'pl', label: 'Polski', flag: '🇵🇱' } + { value: 'pl', label: 'Polski', flag: '🇵🇱' }, + { value: 'el', label: 'Ελληνικά', flag: '🇬🇷' } ] const handleSpellCheckLanguagesChange = (selectedLanguages: string[]) => { From 4eb3aa31eeedba0fa4d35c20097e149f1f219f64 Mon Sep 17 00:00:00 2001 From: Phantom Date: Fri, 17 Oct 2025 19:44:47 +0800 Subject: [PATCH 33/39] feat: session settings (#10773) * fix(home/Tabs): remove redundant isTopicView check in tab rendering * refactor(runtime): rename activeSessionId to activeSessionIdMap for clarity Update variable name to better reflect its purpose as a mapping structure * refactor(agent): add CreateAgentSessionResponse type and schema Add new type and schema for create session response to better reflect API contract * fix(useSessions): return null instead of undefined on session creation error Returning null provides better type safety and aligns with the response type Promise * refactor(useSessions): add return type to deleteSession callback * fix(useSessions): return session data or null from getSession Ensure getSession callback always returns a value (session data or null) to handle error cases properly and improve type safety * feat(hooks): add useActiveSession hook and handle null agentId in useSessions Add new hook to get active session from runtime and sessions data. Update useSessions to handle null agentId cases by returning early and adding null checks. * feat(hooks): add useActiveAgent hook to get active agent Expose active agent data by combining useRuntime and useAgent hooks * fix(agents): remove fake agent id handling and improve null checks - Replace fake agent id with null in HomePage - Remove fake id check in useAgent hook and throw error for null id - Simplify agent session initialization by removing fake id checks * refactor(hooks): replace useAgent with useActiveAgent for active agent state * feat(home): add session settings tab component Replace AgentSettingsTab with SessionSettingsTab to better handle session-specific settings. The new component includes essential and advanced settings sections with a more settings button. * refactor(settings): consolidate agent and session essential settings into single component Replace AgentEssentialSettings and SessionEssentialSettings with a unified EssentialSettings component that handles both agent and session types. This reduces code duplication and improves maintainability. * style(SelectAgentModelButton): improve model name display with truncation Add overflow-x-hidden to container and truncate to model name span to prevent text overflow * refactor(AgentSettings): replace Ellipsis with truncate for text overflow Use CSS truncate instead of Ellipsis component for better performance and consistency * refactor(chat-navbar): replace useAgent and useSession with useActiveAgent and useActiveSession Simplify component logic by using dedicated hooks for active agent and session * feat(ChatNavbar): add session settings button to breadcrumb Add clickable session label chip that opens session settings popup when active session exists * refactor(agents): improve session update hook and type definitions - Extract UpdateAgentBaseOptions type to shared types file - Update useUpdateSession to return both updateSession and updateModel functions - Modify components to use destructured updateSession from hook - Add null check for agentId in useUpdateSession - Add success toast option to session updates * refactor(components): rename agent prop to agentBase for clarity Update component name and prop to better reflect its purpose and improve code readability * refactor(ChatNavbar): rename SelectAgentModelButton to SelectAgentBaseModelButton and update usage Update component name to better reflect its purpose and adjust props to use activeSession instead of activeAgent for consistency * feat(i18n): add null id error message for agent retrieval Add error message for when agent ID is null across all supported languages * refactor(hooks): simplify agent and session hooks by returning destructured values Remove unnecessary intermediate variables and directly return hook results Update useSession to handle null agentId and sessionId cases * feat(i18n): add null session ID error message for all locales * refactor(home): rename SelectAgentModelButton to SelectAgentBaseModelButton The component was renamed to better reflect its purpose of selecting base models for agents. The functionality remains unchanged. * refactor(session): rename useUpdateAgent to useUpdateSession for clarity * refactor(home-tabs): replace useUpdateAgent with useUpdateSession hook Update session settings tab to use the new useUpdateSession hook which requires activeAgentId * style(AgentSettings): remove unnecessary gap class from ModelSetting * refactor(agents): improve error handling and remove duplicate code - Replace formatErrorMessageWithPrefix with getErrorMessage for better error handling - Move updateSession logic to useUpdateSession hook to avoid duplication * fix(ChatNavbar): prevent model update when activeAgent is missing Add activeAgent to dependency array and check its existence before updating model to avoid potential errors * feat(home/Tabs): add loading and error states for session settings Add Skeleton loader and Alert component to handle loading and error states when fetching session data in the settings tab * fix(home/Tabs): add h-full class to Skeleton for proper height * fix(AssistantsTab): remove weird effect hook for agent selection * refactor(chat-navbar): clean up unused code and update session handling remove commented out code in ChatNavbar.tsx and update ChatNavbarContent to use active agent/session hooks * style(home): remove negative margin from model name span * refactor(Agents): mark Agents component as deprecated * refactor: remove unused Agents and Assistants code --------- Co-authored-by: dev --- src/renderer/src/api/agent.ts | 6 +- .../components/Popups/agent/SessionModal.tsx | 2 +- src/renderer/src/hooks/agents/types.ts | 4 + .../src/hooks/agents/useActiveAgent.ts | 8 + .../src/hooks/agents/useActiveSession.ts | 9 + src/renderer/src/hooks/agents/useAgent.ts | 4 +- .../agents/useAgentSessionInitializer.ts | 14 +- src/renderer/src/hooks/agents/useSession.ts | 28 +-- src/renderer/src/hooks/agents/useSessions.ts | 20 +- .../src/hooks/agents/useUpdateAgent.ts | 10 +- .../src/hooks/agents/useUpdateSession.ts | 36 ++- src/renderer/src/i18n/locales/en-us.json | 6 +- src/renderer/src/i18n/locales/zh-cn.json | 6 +- src/renderer/src/i18n/locales/zh-tw.json | 6 +- src/renderer/src/i18n/translate/el-gr.json | 6 +- src/renderer/src/i18n/translate/es-es.json | 6 +- src/renderer/src/i18n/translate/fr-fr.json | 6 +- src/renderer/src/i18n/translate/ja-jp.json | 6 +- src/renderer/src/i18n/translate/pt-pt.json | 6 +- src/renderer/src/i18n/translate/ru-ru.json | 6 +- src/renderer/src/pages/home/Chat.tsx | 19 +- src/renderer/src/pages/home/ChatNavbar.tsx | 8 + src/renderer/src/pages/home/HomePage.tsx | 2 +- .../src/pages/home/Tabs/AgentSettingsTab.tsx | 40 ---- .../src/pages/home/Tabs/AssistantsTab.tsx | 8 +- .../pages/home/Tabs/SessionSettingsTab.tsx | 43 ++++ .../src/pages/home/Tabs/components/Agents.tsx | 71 ------ .../pages/home/Tabs/components/Assistants.tsx | 208 ------------------ .../home/Tabs/components/SessionItem.tsx | 4 +- .../pages/home/Tabs/components/Sessions.tsx | 14 +- src/renderer/src/pages/home/Tabs/index.tsx | 30 ++- .../home/components/ChatNavbarContent.tsx | 80 ++++--- ...ton.tsx => SelectAgentBaseModelButton.tsx} | 10 +- .../AgentSettings/AdvancedSettings.tsx | 2 +- .../AgentSettings/AgentEssentialSettings.tsx | 47 ---- .../AgentSettings/AgentSettingsPopup.tsx | 4 +- .../AgentSettings/EssentialSettings.tsx | 56 +++++ .../settings/AgentSettings/ModelSetting.tsx | 6 +- .../settings/AgentSettings/PromptSettings.tsx | 2 +- .../SessionEssentialSettings.tsx | 29 --- .../AgentSettings/SessionSettingsPopup.tsx | 6 +- .../pages/settings/AgentSettings/shared.tsx | 9 +- src/renderer/src/store/runtime.ts | 6 +- src/renderer/src/types/agent.ts | 4 + 44 files changed, 348 insertions(+), 555 deletions(-) create mode 100644 src/renderer/src/hooks/agents/types.ts create mode 100644 src/renderer/src/hooks/agents/useActiveAgent.ts create mode 100644 src/renderer/src/hooks/agents/useActiveSession.ts delete mode 100644 src/renderer/src/pages/home/Tabs/AgentSettingsTab.tsx create mode 100644 src/renderer/src/pages/home/Tabs/SessionSettingsTab.tsx delete mode 100644 src/renderer/src/pages/home/Tabs/components/Agents.tsx delete mode 100644 src/renderer/src/pages/home/Tabs/components/Assistants.tsx rename src/renderer/src/pages/home/components/{SelectAgentModelButton.tsx => SelectAgentBaseModelButton.tsx} (87%) delete mode 100644 src/renderer/src/pages/settings/AgentSettings/AgentEssentialSettings.tsx create mode 100644 src/renderer/src/pages/settings/AgentSettings/EssentialSettings.tsx delete mode 100644 src/renderer/src/pages/settings/AgentSettings/SessionEssentialSettings.tsx diff --git a/src/renderer/src/api/agent.ts b/src/renderer/src/api/agent.ts index 2b31873ce..a16e4eb60 100644 --- a/src/renderer/src/api/agent.ts +++ b/src/renderer/src/api/agent.ts @@ -9,6 +9,8 @@ import { CreateAgentRequest, CreateAgentResponse, CreateAgentResponseSchema, + CreateAgentSessionResponse, + CreateAgentSessionResponseSchema, CreateSessionForm, CreateSessionRequest, GetAgentResponse, @@ -171,12 +173,12 @@ export class AgentApiClient { } } - public async createSession(agentId: string, session: CreateSessionForm): Promise { + public async createSession(agentId: string, session: CreateSessionForm): Promise { const url = this.getSessionPaths(agentId).base try { const payload = session satisfies CreateSessionRequest const response = await this.axios.post(url, payload) - const data = GetAgentSessionResponseSchema.parse(response.data) + const data = CreateAgentSessionResponseSchema.parse(response.data) return data } catch (error) { throw processError(error, 'Failed to add session.') diff --git a/src/renderer/src/components/Popups/agent/SessionModal.tsx b/src/renderer/src/components/Popups/agent/SessionModal.tsx index aaf0029ee..80ca25f47 100644 --- a/src/renderer/src/components/Popups/agent/SessionModal.tsx +++ b/src/renderer/src/components/Popups/agent/SessionModal.tsx @@ -98,7 +98,7 @@ export const SessionModal: React.FC = ({ const loadingRef = useRef(false) // const { setTimeoutTimer } = useTimer() const { createSession } = useSessions(agentId) - const updateSession = useUpdateSession(agentId) + const { updateSession } = useUpdateSession(agentId) const { agent } = useAgent(agentId) const isEditing = (session?: AgentSessionEntity) => session !== undefined diff --git a/src/renderer/src/hooks/agents/types.ts b/src/renderer/src/hooks/agents/types.ts new file mode 100644 index 000000000..9cf5769f5 --- /dev/null +++ b/src/renderer/src/hooks/agents/types.ts @@ -0,0 +1,4 @@ +export type UpdateAgentBaseOptions = { + /** Whether to show success toast after updating. Defaults to true. */ + showSuccessToast?: boolean +} diff --git a/src/renderer/src/hooks/agents/useActiveAgent.ts b/src/renderer/src/hooks/agents/useActiveAgent.ts new file mode 100644 index 000000000..f522ac7f2 --- /dev/null +++ b/src/renderer/src/hooks/agents/useActiveAgent.ts @@ -0,0 +1,8 @@ +import { useRuntime } from '../useRuntime' +import { useAgent } from './useAgent' + +export const useActiveAgent = () => { + const { chat } = useRuntime() + const { activeAgentId } = chat + return useAgent(activeAgentId) +} diff --git a/src/renderer/src/hooks/agents/useActiveSession.ts b/src/renderer/src/hooks/agents/useActiveSession.ts new file mode 100644 index 000000000..dd744b6a5 --- /dev/null +++ b/src/renderer/src/hooks/agents/useActiveSession.ts @@ -0,0 +1,9 @@ +import { useRuntime } from '../useRuntime' +import { useSession } from './useSession' + +export const useActiveSession = () => { + const { chat } = useRuntime() + const { activeSessionIdMap, activeAgentId } = chat + const activeSessionId = activeAgentId ? activeSessionIdMap[activeAgentId] : null + return useSession(activeAgentId, activeSessionId) +} diff --git a/src/renderer/src/hooks/agents/useAgent.ts b/src/renderer/src/hooks/agents/useAgent.ts index a6755a429..e1c682046 100644 --- a/src/renderer/src/hooks/agents/useAgent.ts +++ b/src/renderer/src/hooks/agents/useAgent.ts @@ -11,8 +11,8 @@ export const useAgent = (id: string | null) => { const key = id ? client.agentPaths.withId(id) : null const { apiServerConfig, apiServerRunning } = useApiServer() const fetcher = useCallback(async () => { - if (!id || id === 'fake') { - return null + if (!id) { + throw new Error(t('agent.get.error.null_id')) } if (!apiServerConfig.enabled) { throw new Error(t('apiServer.messages.notEnabled')) diff --git a/src/renderer/src/hooks/agents/useAgentSessionInitializer.ts b/src/renderer/src/hooks/agents/useAgentSessionInitializer.ts index 23161b422..98609f5b2 100644 --- a/src/renderer/src/hooks/agents/useAgentSessionInitializer.ts +++ b/src/renderer/src/hooks/agents/useAgentSessionInitializer.ts @@ -17,7 +17,7 @@ export const useAgentSessionInitializer = () => { const dispatch = useAppDispatch() const client = useAgentClient() const { chat } = useRuntime() - const { activeAgentId, activeSessionId } = chat + const { activeAgentId, activeSessionIdMap } = chat /** * Initialize session for the given agent by loading its sessions @@ -25,11 +25,11 @@ export const useAgentSessionInitializer = () => { */ const initializeAgentSession = useCallback( async (agentId: string) => { - if (!agentId || agentId === 'fake') return + if (!agentId) return try { // Check if this agent already has an active session - const currentSessionId = activeSessionId[agentId] + const currentSessionId = activeSessionIdMap[agentId] if (currentSessionId) { // Session already exists, just switch to session view dispatch(setActiveTopicOrSessionAction('session')) @@ -58,21 +58,21 @@ export const useAgentSessionInitializer = () => { dispatch(setActiveTopicOrSessionAction('session')) } }, - [client, dispatch, activeSessionId] + [client, dispatch, activeSessionIdMap] ) /** * Auto-initialize when activeAgentId changes */ useEffect(() => { - if (activeAgentId && activeAgentId !== 'fake') { + if (activeAgentId) { // Check if we need to initialize this agent's session - const hasActiveSession = activeSessionId[activeAgentId] + const hasActiveSession = activeSessionIdMap[activeAgentId] if (!hasActiveSession) { initializeAgentSession(activeAgentId) } } - }, [activeAgentId, activeSessionId, initializeAgentSession]) + }, [activeAgentId, activeSessionIdMap, initializeAgentSession]) return { initializeAgentSession diff --git a/src/renderer/src/hooks/agents/useSession.ts b/src/renderer/src/hooks/agents/useSession.ts index ded5b9aab..1c6ebd608 100644 --- a/src/renderer/src/hooks/agents/useSession.ts +++ b/src/renderer/src/hooks/agents/useSession.ts @@ -1,21 +1,24 @@ import { useAppDispatch } from '@renderer/store' import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk' -import { UpdateSessionForm } from '@renderer/types' import { buildAgentSessionTopicId } from '@renderer/utils/agentSession' -import { useCallback, useEffect, useMemo } from 'react' +import { useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' import useSWR from 'swr' import { useAgentClient } from './useAgentClient' +import { useUpdateSession } from './useUpdateSession' -export const useSession = (agentId: string, sessionId: string) => { +export const useSession = (agentId: string | null, sessionId: string | null) => { const { t } = useTranslation() const client = useAgentClient() - const key = client.getSessionPaths(agentId).withId(sessionId) + const key = agentId && sessionId ? client.getSessionPaths(agentId).withId(sessionId) : null const dispatch = useAppDispatch() - const sessionTopicId = useMemo(() => buildAgentSessionTopicId(sessionId), [sessionId]) + const sessionTopicId = useMemo(() => (sessionId ? buildAgentSessionTopicId(sessionId) : null), [sessionId]) + const { updateSession } = useUpdateSession(agentId) const fetcher = async () => { + if (!agentId) throw new Error(t('agent.get.error.null_id')) + if (!sessionId) throw new Error(t('agent.session.get.error.null_id')) const data = await client.getSession(agentId, sessionId) return data } @@ -24,26 +27,13 @@ export const useSession = (agentId: string, sessionId: string) => { // Use loadTopicMessagesThunk to load messages (with caching mechanism) // This ensures messages are preserved when switching between sessions/tabs useEffect(() => { - if (sessionId) { + if (sessionTopicId) { // loadTopicMessagesThunk will check if messages already exist in Redux // and skip loading if they do (unless forceReload is true) dispatch(loadTopicMessagesThunk(sessionTopicId)) } }, [dispatch, sessionId, sessionTopicId]) - const updateSession = useCallback( - async (form: UpdateSessionForm) => { - if (!agentId) return - try { - const result = await client.updateSession(agentId, form) - mutate(result) - } catch (error) { - window.toast.error(t('agent.session.update.error.failed')) - } - }, - [agentId, client, mutate, t] - ) - return { session: data, error, diff --git a/src/renderer/src/hooks/agents/useSessions.ts b/src/renderer/src/hooks/agents/useSessions.ts index fce29b6bf..2be85d5eb 100644 --- a/src/renderer/src/hooks/agents/useSessions.ts +++ b/src/renderer/src/hooks/agents/useSessions.ts @@ -1,4 +1,4 @@ -import { CreateSessionForm } from '@renderer/types' +import { CreateAgentSessionResponse, CreateSessionForm, GetAgentSessionResponse } from '@renderer/types' import { formatErrorMessageWithPrefix } from '@renderer/utils/error' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' @@ -6,46 +6,50 @@ import useSWR from 'swr' import { useAgentClient } from './useAgentClient' -export const useSessions = (agentId: string) => { +export const useSessions = (agentId: string | null) => { const { t } = useTranslation() const client = useAgentClient() - const key = client.getSessionPaths(agentId).base + const key = agentId ? client.getSessionPaths(agentId).base : null const fetcher = async () => { + if (!agentId) throw new Error('No active agent.') const data = await client.listSessions(agentId) return data.data } const { data, error, isLoading, mutate } = useSWR(key, fetcher) const createSession = useCallback( - async (form: CreateSessionForm) => { + async (form: CreateSessionForm): Promise => { + if (!agentId) return null try { const result = await client.createSession(agentId, form) await mutate((prev) => [result, ...(prev ?? [])], { revalidate: false }) return result } catch (error) { window.toast.error(formatErrorMessageWithPrefix(error, t('agent.session.create.error.failed'))) - return undefined + return null } }, [agentId, client, mutate, t] ) - // TODO: including messages field const getSession = useCallback( - async (id: string) => { + async (id: string): Promise => { + if (!agentId) return null try { const result = await client.getSession(agentId, id) mutate((prev) => prev?.map((session) => (session.id === result.id ? result : session))) + return result } catch (error) { window.toast.error(formatErrorMessageWithPrefix(error, t('agent.session.get.error.failed'))) + return null } }, [agentId, client, mutate, t] ) const deleteSession = useCallback( - async (id: string) => { + async (id: string): Promise => { if (!agentId) return false try { await client.deleteSession(agentId, id) diff --git a/src/renderer/src/hooks/agents/useUpdateAgent.ts b/src/renderer/src/hooks/agents/useUpdateAgent.ts index b2589095e..db3825b9b 100644 --- a/src/renderer/src/hooks/agents/useUpdateAgent.ts +++ b/src/renderer/src/hooks/agents/useUpdateAgent.ts @@ -4,20 +4,16 @@ import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { mutate } from 'swr' +import { UpdateAgentBaseOptions } from './types' import { useAgentClient } from './useAgentClient' -export type UpdateAgentOptions = { - /** Whether to show success toast after updating. Defaults to true. */ - showSuccessToast?: boolean -} - export const useUpdateAgent = () => { const { t } = useTranslation() const client = useAgentClient() const listKey = client.agentPaths.base const updateAgent = useCallback( - async (form: UpdateAgentForm, options?: UpdateAgentOptions) => { + async (form: UpdateAgentForm, options?: UpdateAgentBaseOptions) => { try { const itemKey = client.agentPaths.withId(form.id) // may change to optimistic update @@ -35,7 +31,7 @@ export const useUpdateAgent = () => { ) const updateModel = useCallback( - async (agentId: string, modelId: string, options?: UpdateAgentOptions) => { + async (agentId: string, modelId: string, options?: UpdateAgentBaseOptions) => { updateAgent({ id: agentId, model: modelId }, options) }, [updateAgent] diff --git a/src/renderer/src/hooks/agents/useUpdateSession.ts b/src/renderer/src/hooks/agents/useUpdateSession.ts index 4e91c2fda..cf52a6463 100644 --- a/src/renderer/src/hooks/agents/useUpdateSession.ts +++ b/src/renderer/src/hooks/agents/useUpdateSession.ts @@ -1,19 +1,21 @@ import { ListAgentSessionsResponse, UpdateSessionForm } from '@renderer/types' -import { formatErrorMessageWithPrefix } from '@renderer/utils/error' +import { getErrorMessage } from '@renderer/utils/error' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { mutate } from 'swr' +import { UpdateAgentBaseOptions } from './types' import { useAgentClient } from './useAgentClient' -export const useUpdateSession = (agentId: string) => { +export const useUpdateSession = (agentId: string | null) => { const { t } = useTranslation() const client = useAgentClient() - const paths = client.getSessionPaths(agentId) - const listKey = paths.base const updateSession = useCallback( - async (form: UpdateSessionForm) => { + async (form: UpdateSessionForm, options?: UpdateAgentBaseOptions) => { + if (!agentId) return + const paths = client.getSessionPaths(agentId) + const listKey = paths.base const sessionId = form.id try { const itemKey = paths.withId(sessionId) @@ -24,13 +26,29 @@ export const useUpdateSession = (agentId: string) => { (prev) => prev?.map((session) => (session.id === result.id ? result : session)) ?? [] ) mutate(itemKey, result) - window.toast.success(t('common.update_success')) + if (options?.showSuccessToast ?? true) { + window.toast.success(t('common.update_success')) + } } catch (error) { - window.toast.error(formatErrorMessageWithPrefix(error, t('agent.update.error.failed'))) + window.toast.error({ title: t('agent.session.update.error.failed'), description: getErrorMessage(error) }) } }, - [agentId, client, listKey, paths, t] + [agentId, client, t] ) - return updateSession + const updateModel = useCallback( + async (sessionId: string, modelId: string, options?: UpdateAgentBaseOptions) => { + if (!agentId) return + return updateSession( + { + id: sessionId, + model: modelId + }, + options + ) + }, + [agentId, updateSession] + ) + + return { updateSession, updateModel } } diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 98cce3a51..9dbdd32da 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -22,7 +22,8 @@ }, "get": { "error": { - "failed": "Failed to get the agent." + "failed": "Failed to get the agent.", + "null_id": "Agent ID is null." } }, "list": { @@ -73,7 +74,8 @@ }, "get": { "error": { - "failed": "Failed to get the session" + "failed": "Failed to get the session", + "null_id": "Session ID is null" } }, "label_one": "Session", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 8e5e4ebe3..9b146596f 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -22,7 +22,8 @@ }, "get": { "error": { - "failed": "获取智能体失败" + "failed": "获取智能体失败", + "null_id": "智能体 ID 为空。" } }, "list": { @@ -73,7 +74,8 @@ }, "get": { "error": { - "failed": "获取会话失败" + "failed": "获取会话失败", + "null_id": "会话 ID 为空" } }, "label_one": "会话", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 0b8842466..57dc3480b 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -22,7 +22,8 @@ }, "get": { "error": { - "failed": "無法取得代理程式。" + "failed": "無法取得代理程式。", + "null_id": "代理程式 ID 為空。" } }, "list": { @@ -73,7 +74,8 @@ }, "get": { "error": { - "failed": "無法取得工作階段" + "failed": "無法取得工作階段", + "null_id": "工作階段 ID 為空" } }, "label_one": "會議", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index c1d289316..259d64c91 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -22,7 +22,8 @@ }, "get": { "error": { - "failed": "Αποτυχία λήψης του πράκτορα." + "failed": "Αποτυχία λήψης του πράκτορα.", + "null_id": "Το ID του πράκτορα είναι null." } }, "list": { @@ -73,7 +74,8 @@ }, "get": { "error": { - "failed": "Αποτυχία λήψης της συνεδρίας" + "failed": "Αποτυχία λήψης της συνεδρίας", + "null_id": "Το ID της συνεδρίας είναι null" } }, "label_one": "Συνεδρία", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 5825c72fd..8b9b863e0 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -22,7 +22,8 @@ }, "get": { "error": { - "failed": "No se pudo obtener el agente." + "failed": "No se pudo obtener el agente.", + "null_id": "El ID del agente es nulo." } }, "list": { @@ -73,7 +74,8 @@ }, "get": { "error": { - "failed": "Error al obtener la sesión" + "failed": "Error al obtener la sesión", + "null_id": "El ID de sesión es nulo" } }, "label_one": "Sesión", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index e82bbe99e..f40ffc816 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -22,7 +22,8 @@ }, "get": { "error": { - "failed": "Échec de l'obtention de l'agent." + "failed": "Échec de l'obtention de l'agent.", + "null_id": "L'ID de l'agent est nul." } }, "list": { @@ -73,7 +74,8 @@ }, "get": { "error": { - "failed": "Échec de l'obtention de la session" + "failed": "Échec de l'obtention de la session", + "null_id": "L'ID de session est nul" } }, "label_one": "Session", diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index 5600675de..03c5218c3 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -22,7 +22,8 @@ }, "get": { "error": { - "failed": "エージェントの取得に失敗しました。" + "failed": "エージェントの取得に失敗しました。", + "null_id": "エージェント ID が null です。" } }, "list": { @@ -73,7 +74,8 @@ }, "get": { "error": { - "failed": "セッションの取得に失敗しました" + "failed": "セッションの取得に失敗しました", + "null_id": "セッション ID が null です" } }, "label_one": "セッション", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index c732b90f1..4ea6d25f5 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -22,7 +22,8 @@ }, "get": { "error": { - "failed": "Falha ao obter o agente." + "failed": "Falha ao obter o agente.", + "null_id": "O ID do agente é nulo." } }, "list": { @@ -73,7 +74,8 @@ }, "get": { "error": { - "failed": "Falha ao obter a sessão" + "failed": "Falha ao obter a sessão", + "null_id": "O ID da sessão é nulo" } }, "label_one": "Sessão", diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 394cc9163..8f751d8d9 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -22,7 +22,8 @@ }, "get": { "error": { - "failed": "Не удалось получить агента." + "failed": "Не удалось получить агента.", + "null_id": "ID агента равен null." } }, "list": { @@ -73,7 +74,8 @@ }, "get": { "error": { - "failed": "Не удалось получить сеанс" + "failed": "Не удалось получить сеанс", + "null_id": "ID сессии равен null" } }, "label_one": "Сессия", diff --git a/src/renderer/src/pages/home/Chat.tsx b/src/renderer/src/pages/home/Chat.tsx index 12ba9d757..0775a4f71 100644 --- a/src/renderer/src/pages/home/Chat.tsx +++ b/src/renderer/src/pages/home/Chat.tsx @@ -49,7 +49,8 @@ const Chat: FC = (props) => { const { isTopNavbar } = useNavbarPosition() const chatMaxWidth = useChatMaxWidth() const { chat } = useRuntime() - const { activeTopicOrSession, activeAgentId, activeSessionId } = chat + const { activeTopicOrSession, activeAgentId, activeSessionIdMap } = chat + const activeSessionId = activeAgentId ? activeSessionIdMap[activeAgentId] : null const { apiServer } = useSettings() const mainRef = React.useRef(null) @@ -147,8 +148,7 @@ const Chat: FC = (props) => { if (activeAgentId === null) { return () =>
          Active Agent ID is invalid.
          } - const sessionId = activeSessionId[activeAgentId] - if (!sessionId) { + if (!activeSessionId) { return () =>
          Active Session ID is invalid.
          } if (!apiServer.enabled) { @@ -158,18 +158,17 @@ const Chat: FC = (props) => {
          ) } - return () => + return () => }, [activeAgentId, activeSessionId, apiServer.enabled, t]) const SessionInputBar = useMemo(() => { if (activeAgentId === null) { return () =>
          Active Agent ID is invalid.
          } - const sessionId = activeSessionId[activeAgentId] - if (!sessionId) { + if (!activeSessionId) { return () =>
          Active Session ID is invalid.
          } - return () => + return () => }, [activeAgentId, activeSessionId]) // TODO: more info @@ -235,10 +234,8 @@ const Chat: FC = (props) => { )} {activeTopicOrSession === 'session' && !activeAgentId && } - {activeTopicOrSession === 'session' && activeAgentId && !activeSessionId[activeAgentId] && ( - - )} - {activeTopicOrSession === 'session' && activeAgentId && activeSessionId[activeAgentId] && ( + {activeTopicOrSession === 'session' && activeAgentId && !activeSessionId && } + {activeTopicOrSession === 'session' && activeAgentId && activeSessionId && ( <> diff --git a/src/renderer/src/pages/home/ChatNavbar.tsx b/src/renderer/src/pages/home/ChatNavbar.tsx index 093475413..c44447da8 100644 --- a/src/renderer/src/pages/home/ChatNavbar.tsx +++ b/src/renderer/src/pages/home/ChatNavbar.tsx @@ -64,6 +64,14 @@ const HeaderNavbar: FC = ({ activeAssistant, setActiveAssistant, activeTo }) } + // const handleUpdateModel = useCallback( + // async (model: ApiModel) => { + // if (!activeSession || !activeAgent) return + // return updateModel(activeSession.id, model.id, { showSuccessToast: false }) + // }, + // [activeAgent, activeSession, updateModel] + // ) + return (
          diff --git a/src/renderer/src/pages/home/HomePage.tsx b/src/renderer/src/pages/home/HomePage.tsx index 32f1ec397..d387af12b 100644 --- a/src/renderer/src/pages/home/HomePage.tsx +++ b/src/renderer/src/pages/home/HomePage.tsx @@ -117,7 +117,7 @@ const HomePage: FC = () => { type: 'chat' }) } else if (activeTopicOrSession === 'topic') { - dispatch(setActiveAgentId('fake')) + dispatch(setActiveAgentId(null)) } }, [activeTopicOrSession, dispatch, setActiveAssistant]) diff --git a/src/renderer/src/pages/home/Tabs/AgentSettingsTab.tsx b/src/renderer/src/pages/home/Tabs/AgentSettingsTab.tsx deleted file mode 100644 index 7aab18816..000000000 --- a/src/renderer/src/pages/home/Tabs/AgentSettingsTab.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Button, Divider } from '@heroui/react' -import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent' -import { AgentSettingsPopup } from '@renderer/pages/settings/AgentSettings' -import AdvancedSettings from '@renderer/pages/settings/AgentSettings/AdvancedSettings' -import AgentEssentialSettings from '@renderer/pages/settings/AgentSettings/AgentEssentialSettings' -import { GetAgentResponse } from '@renderer/types/agent' -import { FC } from 'react' -import { useTranslation } from 'react-i18next' - -interface Props { - agent: GetAgentResponse | undefined | null - update: ReturnType['updateAgent'] -} - -const AgentSettingsTab: FC = ({ agent, update }) => { - const { t } = useTranslation() - - const onMoreSetting = () => { - if (agent?.id) { - AgentSettingsPopup.show({ agentId: agent.id! }) - } - } - - if (!agent) { - return null - } - - return ( -
          - - - - -
          - ) -} - -export default AgentSettingsTab diff --git a/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx b/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx index f4588f60a..af02796d7 100644 --- a/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx @@ -11,7 +11,7 @@ import { useAppDispatch } from '@renderer/store' import { addIknowAction } from '@renderer/store/runtime' import { Assistant, AssistantsSortType } from '@renderer/types' import { getErrorMessage } from '@renderer/utils' -import { FC, useCallback, useEffect, useRef, useState } from 'react' +import { FC, useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -80,12 +80,6 @@ const AssistantsTab: FC = (props) => { updateAssistants }) - useEffect(() => { - if (!agentsLoading && agents.length > 0 && !activeAgentId && apiServerConfig.enabled) { - setActiveAgentId(agents[0].id) - } - }, [agentsLoading, agents, activeAgentId, setActiveAgentId, apiServerConfig.enabled]) - const onDeleteAssistant = useCallback( (assistant: Assistant) => { const remaining = assistants.filter((a) => a.id !== assistant.id) diff --git a/src/renderer/src/pages/home/Tabs/SessionSettingsTab.tsx b/src/renderer/src/pages/home/Tabs/SessionSettingsTab.tsx new file mode 100644 index 000000000..34b41a560 --- /dev/null +++ b/src/renderer/src/pages/home/Tabs/SessionSettingsTab.tsx @@ -0,0 +1,43 @@ +import { Button, Divider } from '@heroui/react' +import { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession' +import { SessionSettingsPopup } from '@renderer/pages/settings/AgentSettings' +import AdvancedSettings from '@renderer/pages/settings/AgentSettings/AdvancedSettings' +import EssentialSettings from '@renderer/pages/settings/AgentSettings/EssentialSettings' +import { GetAgentSessionResponse } from '@renderer/types' +import { FC } from 'react' +import { useTranslation } from 'react-i18next' + +interface Props { + session: GetAgentSessionResponse | undefined | null + update: ReturnType['updateSession'] +} + +const SessionSettingsTab: FC = ({ session, update }) => { + const { t } = useTranslation() + + const onMoreSetting = () => { + if (session?.id) { + SessionSettingsPopup.show({ + agentId: session.agent_id, + sessionId: session.id + }) + } + } + + if (!session) { + return null + } + + return ( +
          + + + + +
          + ) +} + +export default SessionSettingsTab diff --git a/src/renderer/src/pages/home/Tabs/components/Agents.tsx b/src/renderer/src/pages/home/Tabs/components/Agents.tsx deleted file mode 100644 index 07154aed4..000000000 --- a/src/renderer/src/pages/home/Tabs/components/Agents.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { Alert, Button, Spinner } from '@heroui/react' -import { AgentModal } from '@renderer/components/Popups/agent/AgentModal' -import { useAgents } from '@renderer/hooks/agents/useAgents' -import { useAgentSessionInitializer } from '@renderer/hooks/agents/useAgentSessionInitializer' -import { useRuntime } from '@renderer/hooks/useRuntime' -import { useAppDispatch } from '@renderer/store' -import { setActiveAgentId as setActiveAgentIdAction } from '@renderer/store/runtime' -import { Plus } from 'lucide-react' -import { FC, useCallback, useEffect } from 'react' -import { useTranslation } from 'react-i18next' - -import AgentItem from './AgentItem' - -interface AssistantsTabProps {} - -export const Agents: FC = () => { - const { agents, deleteAgent, isLoading, error } = useAgents() - const { t } = useTranslation() - const { chat } = useRuntime() - const { activeAgentId } = chat - const { initializeAgentSession } = useAgentSessionInitializer() - - const dispatch = useAppDispatch() - - const setActiveAgentId = useCallback( - async (id: string) => { - dispatch(setActiveAgentIdAction(id)) - // Initialize the session for this agent - await initializeAgentSession(id) - }, - [dispatch, initializeAgentSession] - ) - - useEffect(() => { - if (!isLoading && agents.length > 0 && !activeAgentId) { - setActiveAgentId(agents[0].id) - } - }, [isLoading, agents, activeAgentId, setActiveAgentId]) - - return ( - <> - {isLoading && } - {error && } - {!isLoading && - !error && - agents.map((agent) => ( - deleteAgent(agent.id)} - onPress={() => { - setActiveAgentId(agent.id) - }} - /> - ))} - e.continuePropagation()} - startContent={} - className="w-full justify-start bg-transparent text-foreground-500 hover:bg-[var(--color-list-item)]"> - {t('agent.add.title')} - - ) - }} - /> - - ) -} diff --git a/src/renderer/src/pages/home/Tabs/components/Assistants.tsx b/src/renderer/src/pages/home/Tabs/components/Assistants.tsx deleted file mode 100644 index c21ec92ae..000000000 --- a/src/renderer/src/pages/home/Tabs/components/Assistants.tsx +++ /dev/null @@ -1,208 +0,0 @@ -import { DownOutlined, RightOutlined } from '@ant-design/icons' -import { Button } from '@heroui/react' -import { DraggableList } from '@renderer/components/DraggableList' -import { useAssistants } from '@renderer/hooks/useAssistant' -import { useAssistantPresets } from '@renderer/hooks/useAssistantPresets' -import { useAssistantsTabSortType } from '@renderer/hooks/useStore' -import { useTags } from '@renderer/hooks/useTags' -import { Assistant, AssistantsSortType } from '@renderer/types' -import { Tooltip } from 'antd' -import { Plus } from 'lucide-react' -import { FC, useCallback, useMemo, useState } from 'react' -import { useTranslation } from 'react-i18next' -import styled from 'styled-components' - -import AssistantItem from './AssistantItem' -import { SectionName } from './SectionName' - -interface AssistantsProps { - activeAssistant: Assistant - setActiveAssistant: (assistant: Assistant) => void - onCreateAssistant: () => void - onCreateDefaultAssistant: () => void -} - -const Assistants: FC = ({ - activeAssistant, - setActiveAssistant, - onCreateAssistant, - onCreateDefaultAssistant -}) => { - const { assistants, removeAssistant, copyAssistant, updateAssistants } = useAssistants() - const [dragging, setDragging] = useState(false) - const { addAssistantPreset } = useAssistantPresets() - const { t } = useTranslation() - const { getGroupedAssistants, collapsedTags, toggleTagCollapse } = useTags() - const { assistantsTabSortType = 'list', setAssistantsTabSortType } = useAssistantsTabSortType() - - const onDelete = useCallback( - (assistant: Assistant) => { - const remaining = assistants.filter((a) => a.id !== assistant.id) - if (assistant.id === activeAssistant?.id) { - const newActive = remaining[remaining.length - 1] - newActive ? setActiveAssistant(newActive) : onCreateDefaultAssistant() - } - removeAssistant(assistant.id) - }, - [activeAssistant, assistants, removeAssistant, setActiveAssistant, onCreateDefaultAssistant] - ) - - const handleSortByChange = useCallback( - (sortType: AssistantsSortType) => { - setAssistantsTabSortType(sortType) - }, - [setAssistantsTabSortType] - ) - - const handleGroupReorder = useCallback( - (tag: string, newGroupList: Assistant[]) => { - let insertIndex = 0 - const newGlobal = assistants.map((a) => { - const tags = a.tags?.length ? a.tags : [t('assistants.tags.untagged')] - if (tags.includes(tag)) { - const replaced = newGroupList[insertIndex] - insertIndex += 1 - return replaced - } - return a - }) - updateAssistants(newGlobal) - }, - [assistants, t, updateAssistants] - ) - - const renderAddAssistantButton = useMemo(() => { - return ( - - ) - }, [onCreateAssistant, t]) - - if (assistantsTabSortType === 'tags') { - return ( - <> - -
          - {getGroupedAssistants.map((group) => ( - - {group.tag !== t('assistants.tags.untagged') && ( - toggleTagCollapse(group.tag)}> - - - {collapsedTags[group.tag] ? ( - - ) : ( - - )} - {group.tag} - - - - - )} - {!collapsedTags[group.tag] && ( -
          - handleGroupReorder(group.tag, newList)} - onDragStart={() => setDragging(true)} - onDragEnd={() => setDragging(false)}> - {(assistant) => ( - - )} - -
          - )} -
          - ))} - {renderAddAssistantButton} -
          - - ) - } - - return ( -
          - - setDragging(true)} - onDragEnd={() => setDragging(false)}> - {(assistant) => ( - - )} - - {!dragging && renderAddAssistantButton} -
          -
          - ) -} - -// 样式组件 - -const TagsContainer = styled.div` - display: flex; - flex-direction: column; - gap: 8px; -` - -const GroupTitle = styled.div` - color: var(--color-text-2); - font-size: 12px; - font-weight: 500; - cursor: pointer; - display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; - height: 24px; - margin: 5px 0; -` - -const GroupTitleName = styled.div` - max-width: 50%; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - box-sizing: border-box; - padding: 0 4px; - color: var(--color-text); - font-size: 13px; - line-height: 24px; - margin-right: 5px; - display: flex; -` - -const GroupTitleDivider = styled.div` - flex: 1; - border-top: 1px solid var(--color-border); -` - -export default Assistants diff --git a/src/renderer/src/pages/home/Tabs/components/SessionItem.tsx b/src/renderer/src/pages/home/Tabs/components/SessionItem.tsx index 31d05aff4..9e16b1de6 100644 --- a/src/renderer/src/pages/home/Tabs/components/SessionItem.tsx +++ b/src/renderer/src/pages/home/Tabs/components/SessionItem.tsx @@ -33,8 +33,8 @@ interface SessionItemProps { const SessionItem: FC = ({ session, agentId, isDisabled, isLoading, onDelete, onPress }) => { const { t } = useTranslation() const { chat } = useRuntime() - const updateSession = useUpdateSession(agentId) - const activeSessionId = chat.activeSessionId[agentId] + const { updateSession } = useUpdateSession(agentId) + const activeSessionId = chat.activeSessionIdMap[agentId] const [isConfirmingDeletion, setIsConfirmingDeletion] = useState(false) const { setTimeoutTimer } = useTimer() const dispatch = useAppDispatch() diff --git a/src/renderer/src/pages/home/Tabs/components/Sessions.tsx b/src/renderer/src/pages/home/Tabs/components/Sessions.tsx index 8f7d9aa94..50020b168 100644 --- a/src/renderer/src/pages/home/Tabs/components/Sessions.tsx +++ b/src/renderer/src/pages/home/Tabs/components/Sessions.tsx @@ -30,7 +30,7 @@ const Sessions: React.FC = ({ agentId }) => { const { agent } = useAgent(agentId) const { sessions, isLoading, error, deleteSession, createSession } = useSessions(agentId) const { chat } = useRuntime() - const { activeSessionId, sessionWaiting } = chat + const { activeSessionIdMap, sessionWaiting } = chat const dispatch = useAppDispatch() const setActiveSessionId = useCallback( @@ -75,24 +75,24 @@ const Sessions: React.FC = ({ agentId }) => { [agentId, deleteSession, dispatch, sessions, t] ) - const currentActiveSessionId = activeSessionId[agentId] + const activeSessionId = activeSessionIdMap[agentId] useEffect(() => { - if (!isLoading && sessions.length > 0 && !currentActiveSessionId) { + if (!isLoading && sessions.length > 0 && !activeSessionId) { setActiveSessionId(agentId, sessions[0].id) } - }, [isLoading, sessions, currentActiveSessionId, agentId, setActiveSessionId]) + }, [isLoading, sessions, activeSessionId, agentId, setActiveSessionId]) useEffect(() => { - if (currentActiveSessionId) { + if (activeSessionId) { dispatch( newMessagesActions.setTopicFulfilled({ - topicId: buildAgentSessionTopicId(currentActiveSessionId), + topicId: buildAgentSessionTopicId(activeSessionId), fulfilled: false }) ) } - }, [currentActiveSessionId, dispatch]) + }, [activeSessionId, dispatch]) if (isLoading) { return ( diff --git a/src/renderer/src/pages/home/Tabs/index.tsx b/src/renderer/src/pages/home/Tabs/index.tsx index 1e2559c08..c43c6d93a 100644 --- a/src/renderer/src/pages/home/Tabs/index.tsx +++ b/src/renderer/src/pages/home/Tabs/index.tsx @@ -1,6 +1,7 @@ +import { Alert, Skeleton } from '@heroui/react' import AddAssistantPopup from '@renderer/components/Popups/AddAssistantPopup' -import { useAgent } from '@renderer/hooks/agents/useAgent' -import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent' +import { useActiveSession } from '@renderer/hooks/agents/useActiveSession' +import { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession' import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant' import { useRuntime } from '@renderer/hooks/useRuntime' import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings' @@ -8,13 +9,13 @@ import { useShowTopics } from '@renderer/hooks/useStore' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { Assistant, Topic } from '@renderer/types' import { Tab } from '@renderer/types/chat' -import { classNames, uuid } from '@renderer/utils' +import { classNames, getErrorMessage, uuid } from '@renderer/utils' import { FC, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' -import AgentSettingsTab from './AgentSettingsTab' import Assistants from './AssistantsTab' +import SessionSettingsTab from './SessionSettingsTab' import Settings from './SettingsTab' import Topics from './TopicsTab' @@ -47,8 +48,8 @@ const HomeTabs: FC = ({ const { t } = useTranslation() const { chat } = useRuntime() const { activeTopicOrSession, activeAgentId } = chat - const { agent } = useAgent(activeAgentId) - const { updateAgent } = useUpdateAgent() + const { session, isLoading: isSessionLoading, error: sessionError } = useActiveSession() + const { updateSession } = useUpdateSession(activeAgentId) const isSessionView = activeTopicOrSession === 'session' const isTopicView = activeTopicOrSession === 'topic' @@ -125,7 +126,7 @@ const HomeTabs: FC = ({ )} - {position === 'right' && topicPosition === 'right' && isTopicView && ( + {position === 'right' && topicPosition === 'right' && ( setTab('topic')}> {t('common.topics')} @@ -154,7 +155,20 @@ const HomeTabs: FC = ({ /> )} {tab === 'settings' && isTopicView && } - {tab === 'settings' && isSessionView && } + {tab === 'settings' && isSessionView && !sessionError && ( + + + + )} + {tab === 'settings' && isSessionView && sessionError && ( +
          + +
          + )} ) diff --git a/src/renderer/src/pages/home/components/ChatNavbarContent.tsx b/src/renderer/src/pages/home/components/ChatNavbarContent.tsx index 5d89bfd94..154226973 100644 --- a/src/renderer/src/pages/home/components/ChatNavbarContent.tsx +++ b/src/renderer/src/pages/home/components/ChatNavbarContent.tsx @@ -1,18 +1,18 @@ import { BreadcrumbItem, Breadcrumbs, Chip, cn } from '@heroui/react' import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer' import { permissionModeCards } from '@renderer/constants/permissionModes' -import { useAgent } from '@renderer/hooks/agents/useAgent' -import { useSession } from '@renderer/hooks/agents/useSession' -import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent' +import { useActiveAgent } from '@renderer/hooks/agents/useActiveAgent' +import { useActiveSession } from '@renderer/hooks/agents/useActiveSession' +import { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession' import { useRuntime } from '@renderer/hooks/useRuntime' -import { ApiModel, Assistant, PermissionMode } from '@renderer/types' +import { AgentEntity, AgentSessionEntity, ApiModel, Assistant, PermissionMode } from '@renderer/types' import { formatErrorMessageWithPrefix } from '@renderer/utils/error' import { t } from 'i18next' import { FC, ReactNode, useCallback } from 'react' -import { AgentSettingsPopup } from '../../settings/AgentSettings' -import { AgentLabel } from '../../settings/AgentSettings/shared' -import SelectAgentModelButton from './SelectAgentModelButton' +import { AgentSettingsPopup, SessionSettingsPopup } from '../../settings/AgentSettings' +import { AgentLabel, SessionLabel } from '../../settings/AgentSettings/shared' +import SelectAgentBaseModelButton from './SelectAgentBaseModelButton' import SelectModelButton from './SelectModelButton' interface Props { @@ -21,41 +21,67 @@ interface Props { const ChatNavbarContent: FC = ({ assistant }) => { const { chat } = useRuntime() - const { activeTopicOrSession, activeAgentId } = chat - const sessionId = activeAgentId ? (chat.activeSessionId[activeAgentId] ?? null) : null - const { agent } = useAgent(activeAgentId) - const { updateModel } = useUpdateAgent() + const { activeTopicOrSession } = chat + const { agent: activeAgent } = useActiveAgent() + const { session: activeSession } = useActiveSession() + const { updateModel } = useUpdateSession(activeAgent?.id ?? null) const handleUpdateModel = useCallback( async (model: ApiModel) => { - if (!agent) return - return updateModel(agent.id, model.id, { showSuccessToast: false }) + if (!activeAgent || !activeSession) return + return updateModel(activeSession.id, model.id, { showSuccessToast: false }) }, - [agent, updateModel] + [activeAgent, activeSession, updateModel] ) return ( <> {activeTopicOrSession === 'topic' && } - {activeTopicOrSession === 'session' && agent && ( + {activeTopicOrSession === 'session' && activeAgent && ( - + AgentSettingsPopup.show({ agentId: agent.id })} - classNames={{ base: 'self-stretch', item: 'h-full' }}> + onPress={() => AgentSettingsPopup.show({ agentId: activeAgent.id })} + classNames={{ + base: 'self-stretch', + item: 'h-full' + }}> - - - - {activeAgentId && sessionId && ( + {activeSession && ( + + SessionSettingsPopup.show({ + agentId: activeAgent.id, + sessionId: activeSession.id + }) + } + classNames={{ + base: 'self-stretch', + item: 'h-full' + }}> + + + + + )} + {activeSession && ( - + + + )} + {activeAgent && activeSession && ( + + )} @@ -65,9 +91,7 @@ const ChatNavbarContent: FC = ({ assistant }) => { ) } -const SessionWorkspaceMeta: FC<{ agentId: string; sessionId: string }> = ({ agentId, sessionId }) => { - const { agent } = useAgent(agentId) - const { session } = useSession(agentId, sessionId) +const SessionWorkspaceMeta: FC<{ agent: AgentEntity; session: AgentSessionEntity }> = ({ agent, session }) => { if (!session || !agent) { return null } diff --git a/src/renderer/src/pages/home/components/SelectAgentModelButton.tsx b/src/renderer/src/pages/home/components/SelectAgentBaseModelButton.tsx similarity index 87% rename from src/renderer/src/pages/home/components/SelectAgentModelButton.tsx rename to src/renderer/src/pages/home/components/SelectAgentBaseModelButton.tsx index 53607cd1d..7e21d1b47 100644 --- a/src/renderer/src/pages/home/components/SelectAgentModelButton.tsx +++ b/src/renderer/src/pages/home/components/SelectAgentBaseModelButton.tsx @@ -12,12 +12,12 @@ import { FC } from 'react' import { useTranslation } from 'react-i18next' interface Props { - agent: AgentBaseWithId + agentBase: AgentBaseWithId onSelect: (model: ApiModel) => Promise isDisabled?: boolean } -const SelectAgentModelButton: FC = ({ agent, onSelect, isDisabled }) => { +const SelectAgentBaseModelButton: FC = ({ agentBase: agent, onSelect, isDisabled }) => { const { t } = useTranslation() const model = useApiModel({ id: agent?.model }) @@ -42,9 +42,9 @@ const SelectAgentModelButton: FC = ({ agent, onSelect, isDisabled }) => { className="nodrag rounded-2xl px-1 py-3" onPress={onSelectModel} isDisabled={isDisabled}> -
          +
          - + {model ? model.name : t('button.select_model')} {providerName ? ' | ' + providerName : ''}
          @@ -53,4 +53,4 @@ const SelectAgentModelButton: FC = ({ agent, onSelect, isDisabled }) => { ) } -export default SelectAgentModelButton +export default SelectAgentBaseModelButton diff --git a/src/renderer/src/pages/settings/AgentSettings/AdvancedSettings.tsx b/src/renderer/src/pages/settings/AgentSettings/AdvancedSettings.tsx index d49dac8bc..209258ce6 100644 --- a/src/renderer/src/pages/settings/AgentSettings/AdvancedSettings.tsx +++ b/src/renderer/src/pages/settings/AgentSettings/AdvancedSettings.tsx @@ -23,7 +23,7 @@ type AdvancedSettingsProps = } | { agentBase: GetAgentSessionResponse | undefined | null - update: ReturnType + update: ReturnType['updateSession'] } const defaultConfiguration: AgentConfigurationState = AgentConfigurationSchema.parse({}) diff --git a/src/renderer/src/pages/settings/AgentSettings/AgentEssentialSettings.tsx b/src/renderer/src/pages/settings/AgentSettings/AgentEssentialSettings.tsx deleted file mode 100644 index e0329404d..000000000 --- a/src/renderer/src/pages/settings/AgentSettings/AgentEssentialSettings.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { Avatar } from '@heroui/react' -import { getAgentTypeAvatar } from '@renderer/config/agent' -import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent' -import { getAgentTypeLabel } from '@renderer/i18n/label' -import { GetAgentResponse } from '@renderer/types' -import { FC } from 'react' -import { useTranslation } from 'react-i18next' - -import { AccessibleDirsSetting } from './AccessibleDirsSetting' -import { AvatarSetting } from './AvatarSetting' -import { DescriptionSetting } from './DescriptionSetting' -import { ModelSetting } from './ModelSetting' -import { NameSetting } from './NameSetting' -import { SettingsContainer, SettingsItem, SettingsTitle } from './shared' - -// const logger = loggerService.withContext('AgentEssentialSettings') - -interface AgentEssentialSettingsProps { - agent: GetAgentResponse | undefined | null - update: ReturnType['updateAgent'] - showModelSetting?: boolean -} - -const AgentEssentialSettings: FC = ({ agent, update, showModelSetting = true }) => { - const { t } = useTranslation() - - if (!agent) return null - - return ( - - - {t('agent.type.label')} -
          - - {(agent?.name ?? agent?.type) ? getAgentTypeLabel(agent.type) : ''} -
          -
          - - - {showModelSetting && } - - -
          - ) -} - -export default AgentEssentialSettings diff --git a/src/renderer/src/pages/settings/AgentSettings/AgentSettingsPopup.tsx b/src/renderer/src/pages/settings/AgentSettings/AgentSettingsPopup.tsx index 400504f8b..937b72eca 100644 --- a/src/renderer/src/pages/settings/AgentSettings/AgentSettingsPopup.tsx +++ b/src/renderer/src/pages/settings/AgentSettings/AgentSettingsPopup.tsx @@ -6,7 +6,7 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import AdvancedSettings from './AdvancedSettings' -import AgentEssentialSettings from './AgentEssentialSettings' +import EssentialSettings from './EssentialSettings' import PromptSettings from './PromptSettings' import { AgentLabel, LeftMenu, Settings, StyledMenu, StyledModal } from './shared' import ToolingSettings from './ToolingSettings' @@ -87,7 +87,7 @@ const AgentSettingPopupContainer: React.FC = ({ tab, ag /> - {menu === 'essential' && } + {menu === 'essential' && } {menu === 'prompt' && } {menu === 'tooling' && } {menu === 'advanced' && } diff --git a/src/renderer/src/pages/settings/AgentSettings/EssentialSettings.tsx b/src/renderer/src/pages/settings/AgentSettings/EssentialSettings.tsx new file mode 100644 index 000000000..0977e1f5c --- /dev/null +++ b/src/renderer/src/pages/settings/AgentSettings/EssentialSettings.tsx @@ -0,0 +1,56 @@ +import { Avatar } from '@heroui/react' +import { getAgentTypeAvatar } from '@renderer/config/agent' +import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent' +import { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession' +import { getAgentTypeLabel } from '@renderer/i18n/label' +import { GetAgentResponse, GetAgentSessionResponse, isAgentEntity } from '@renderer/types' +import { FC } from 'react' +import { useTranslation } from 'react-i18next' + +import { AccessibleDirsSetting } from './AccessibleDirsSetting' +import { AvatarSetting } from './AvatarSetting' +import { DescriptionSetting } from './DescriptionSetting' +import { ModelSetting } from './ModelSetting' +import { NameSetting } from './NameSetting' +import { SettingsContainer, SettingsItem, SettingsTitle } from './shared' + +// const logger = loggerService.withContext('AgentEssentialSettings') + +type EssentialSettingsProps = + | { + agentBase: GetAgentResponse | undefined | null + update: ReturnType['updateAgent'] + } + | { + agentBase: GetAgentSessionResponse | undefined | null + update: ReturnType['updateSession'] + } + +const EssentialSettings: FC = ({ agentBase, update }) => { + const { t } = useTranslation() + + if (!agentBase) return null + + const isAgent = isAgentEntity(agentBase) + + return ( + + {isAgent && ( + + {t('agent.type.label')} +
          + + {(agentBase?.name ?? agentBase?.type) ? getAgentTypeLabel(agentBase.type) : ''} +
          +
          + )} + {isAgent && } + + + + +
          + ) +} + +export default EssentialSettings diff --git a/src/renderer/src/pages/settings/AgentSettings/ModelSetting.tsx b/src/renderer/src/pages/settings/AgentSettings/ModelSetting.tsx index 09ee7d823..aed615b86 100644 --- a/src/renderer/src/pages/settings/AgentSettings/ModelSetting.tsx +++ b/src/renderer/src/pages/settings/AgentSettings/ModelSetting.tsx @@ -1,4 +1,4 @@ -import SelectAgentModelButton from '@renderer/pages/home/components/SelectAgentModelButton' +import SelectAgentBaseModelButton from '@renderer/pages/home/components/SelectAgentBaseModelButton' import { AgentBaseWithId, ApiModel, UpdateAgentBaseForm } from '@renderer/types' import { useTranslation } from 'react-i18next' @@ -21,9 +21,9 @@ export const ModelSetting: React.FC = ({ base, update, isDisa if (!base) return null return ( - + {t('common.model')} - + ) } diff --git a/src/renderer/src/pages/settings/AgentSettings/PromptSettings.tsx b/src/renderer/src/pages/settings/AgentSettings/PromptSettings.tsx index 15f2f9be6..f93195012 100644 --- a/src/renderer/src/pages/settings/AgentSettings/PromptSettings.tsx +++ b/src/renderer/src/pages/settings/AgentSettings/PromptSettings.tsx @@ -22,7 +22,7 @@ type AgentPromptSettingsProps = } | { agentBase: AgentSessionEntity | undefined | null - update: ReturnType + update: ReturnType['updateSession'] } const PromptSettings: FC = ({ agentBase, update }) => { diff --git a/src/renderer/src/pages/settings/AgentSettings/SessionEssentialSettings.tsx b/src/renderer/src/pages/settings/AgentSettings/SessionEssentialSettings.tsx deleted file mode 100644 index 67b83dc64..000000000 --- a/src/renderer/src/pages/settings/AgentSettings/SessionEssentialSettings.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent' -import { GetAgentSessionResponse } from '@renderer/types' -import { FC } from 'react' - -import { AccessibleDirsSetting } from './AccessibleDirsSetting' -import { DescriptionSetting } from './DescriptionSetting' -import { NameSetting } from './NameSetting' -import { SettingsContainer } from './shared' - -// const logger = loggerService.withContext('AgentEssentialSettings') - -interface SessionEssentialSettingsProps { - session: GetAgentSessionResponse | undefined | null - update: ReturnType['updateAgent'] -} - -const SessionEssentialSettings: FC = ({ session, update }) => { - if (!session) return null - - return ( - - - - - - ) -} - -export default SessionEssentialSettings diff --git a/src/renderer/src/pages/settings/AgentSettings/SessionSettingsPopup.tsx b/src/renderer/src/pages/settings/AgentSettings/SessionSettingsPopup.tsx index 576f1b100..af5b2e71d 100644 --- a/src/renderer/src/pages/settings/AgentSettings/SessionSettingsPopup.tsx +++ b/src/renderer/src/pages/settings/AgentSettings/SessionSettingsPopup.tsx @@ -6,8 +6,8 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import AdvancedSettings from './AdvancedSettings' +import EssentialSettings from './EssentialSettings' import PromptSettings from './PromptSettings' -import SessionEssentialSettings from './SessionEssentialSettings' import { LeftMenu, SessionLabel, Settings, StyledMenu, StyledModal } from './shared' import ToolingSettings from './ToolingSettings' @@ -30,7 +30,7 @@ const SessionSettingPopupContainer: React.FC = ({ tab const { session, isLoading, error } = useSession(agentId, sessionId) - const updateSession = useUpdateSession(agentId) + const { updateSession } = useUpdateSession(agentId) const onOk = () => { setOpen(false) @@ -89,7 +89,7 @@ const SessionSettingPopupContainer: React.FC = ({ tab /> - {menu === 'essential' && } + {menu === 'essential' && } {menu === 'prompt' && } {menu === 'tooling' && } {menu === 'advanced' && } diff --git a/src/renderer/src/pages/settings/AgentSettings/shared.tsx b/src/renderer/src/pages/settings/AgentSettings/shared.tsx index eb26fcf0a..2443ed8f7 100644 --- a/src/renderer/src/pages/settings/AgentSettings/shared.tsx +++ b/src/renderer/src/pages/settings/AgentSettings/shared.tsx @@ -1,5 +1,4 @@ import { cn } from '@heroui/react' -import Ellipsis from '@renderer/components/Ellipsis' import EmojiIcon from '@renderer/components/EmojiIcon' import { getAgentTypeLabel } from '@renderer/i18n/label' import { AgentEntity, AgentSessionEntity } from '@renderer/types' @@ -35,11 +34,11 @@ export const AgentLabel: React.FC = ({ agent, classNames }) => const emoji = agent?.configuration?.avatar return ( -
          +
          - + {agent?.name ?? (agent?.type ? getAgentTypeLabel(agent.type) : '')} - +
          ) } @@ -53,7 +52,7 @@ export const SessionLabel: React.FC = ({ session, className } const displayName = session?.name ?? session?.id return ( <> - {displayName} + {displayName} ) } diff --git a/src/renderer/src/store/runtime.ts b/src/renderer/src/store/runtime.ts index fe556b9a9..f86a79cec 100644 --- a/src/renderer/src/store/runtime.ts +++ b/src/renderer/src/store/runtime.ts @@ -11,7 +11,7 @@ export interface ChatState { activeAgentId: string | null /** UI state. Map agent id to active session id. * null represents no active session */ - activeSessionId: Record + activeSessionIdMap: Record /** meanwhile active Assistants or Agents */ activeTopicOrSession: 'topic' | 'session' /** topic ids that are currently being renamed */ @@ -90,7 +90,7 @@ const initialState: RuntimeState = { activeTopic: null, activeAgentId: null, activeTopicOrSession: 'topic', - activeSessionId: {}, + activeSessionIdMap: {}, renamingTopics: [], newlyRenamedTopics: [], sessionWaiting: {} @@ -163,7 +163,7 @@ const runtimeSlice = createSlice({ }, setActiveSessionIdAction: (state, action: PayloadAction<{ agentId: string; sessionId: string | null }>) => { const { agentId, sessionId } = action.payload - state.chat.activeSessionId[agentId] = sessionId + state.chat.activeSessionIdMap[agentId] = sessionId }, setActiveTopicOrSessionAction: (state, action: PayloadAction<'topic' | 'session'>) => { state.chat.activeTopicOrSession = action.payload diff --git a/src/renderer/src/types/agent.ts b/src/renderer/src/types/agent.ts index 96a8e70b0..ca1e88cbd 100644 --- a/src/renderer/src/types/agent.ts +++ b/src/renderer/src/types/agent.ts @@ -266,8 +266,12 @@ export const GetAgentSessionResponseSchema = AgentSessionEntitySchema.extend({ slash_commands: z.array(SlashCommandSchema).optional() // Array of slash commands to trigger the agent }) +export const CreateAgentSessionResponseSchema = GetAgentSessionResponseSchema + export type GetAgentSessionResponse = z.infer +export type CreateAgentSessionResponse = GetAgentSessionResponse + export const ListAgentSessionsResponseSchema = z.object({ data: z.array(AgentSessionEntitySchema), total: z.int(), From 33ce41704daa05892cf83d106a250d17f6e9b167 Mon Sep 17 00:00:00 2001 From: Phantom Date: Fri, 17 Oct 2025 20:24:13 +0800 Subject: [PATCH 34/39] fix(message): adjust layout and overflow properties for better display (#10746) * style(CodeBlockView): reduce min-width from 45ch to 35ch to fix layout issues * style(messages): adjust overflow properties and clean up commented code Remove commented overflow properties and adjust overflow behavior for better scroll handling in message containers * style: remove commented overflow css properties --- .../src/components/CodeBlockView/view.tsx | 2 +- .../src/pages/home/Messages/MessageGroup.tsx | 21 +++---------------- 2 files changed, 4 insertions(+), 19 deletions(-) diff --git a/src/renderer/src/components/CodeBlockView/view.tsx b/src/renderer/src/components/CodeBlockView/view.tsx index a89e545bc..939095262 100644 --- a/src/renderer/src/components/CodeBlockView/view.tsx +++ b/src/renderer/src/components/CodeBlockView/view.tsx @@ -326,7 +326,7 @@ const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>` * 一是 CodeViewer 在气泡样式下的用户消息中无法撑开气泡, * 二是 代码块内容过少时 toolbar 会和 title 重叠。 */ - min-width: 45ch; + min-width: 35ch; .code-toolbar { background-color: ${(props) => (props.$isInSpecialView ? 'transparent' : 'var(--color-background-mute)')}; diff --git a/src/renderer/src/pages/home/Messages/MessageGroup.tsx b/src/renderer/src/pages/home/Messages/MessageGroup.tsx index 223124f3b..fde7ccd13 100644 --- a/src/renderer/src/pages/home/Messages/MessageGroup.tsx +++ b/src/renderer/src/pages/home/Messages/MessageGroup.tsx @@ -337,29 +337,18 @@ const GroupContainer = styled.div` const GridContainer = styled(Scrollbar)<{ $count: number; $gridColumns: number }>` width: 100%; display: grid; + overflow-y: visible; gap: 16px; &.horizontal { padding-bottom: 4px; grid-template-columns: repeat(${({ $count }) => $count}, minmax(420px, 1fr)); - overflow-y: hidden; overflow-x: auto; - &::-webkit-scrollbar { - height: 6px; - } - &::-webkit-scrollbar-thumb { - background: var(--color-scrollbar-thumb); - border-radius: var(--scrollbar-thumb-radius); - } - &::-webkit-scrollbar-thumb:hover { - background: var(--color-scrollbar-thumb-hover); - } } &.fold, &.vertical { grid-template-columns: repeat(1, minmax(0, 1fr)); gap: 8px; - overflow: hidden; } &.grid { grid-template-columns: repeat( @@ -367,15 +356,11 @@ const GridContainer = styled(Scrollbar)<{ $count: number; $gridColumns: number } minmax(0, 1fr) ); grid-template-rows: auto; - overflow-y: auto; - overflow-x: hidden; } &.multi-select-mode { grid-template-columns: repeat(1, minmax(0, 1fr)); gap: 10px; - overflow-y: auto; - overflow-x: hidden; .grid { height: auto; } @@ -401,7 +386,7 @@ interface MessageWrapperProps { const MessageWrapper = styled.div` &.horizontal { padding: 1px; - /* overflow-y: auto; */ + overflow-y: auto; .message { height: 100%; border: 0.5px solid var(--color-border); @@ -423,7 +408,7 @@ const MessageWrapper = styled.div` &.grid { display: block; height: 300px; - overflow: hidden; + overflow-y: hidden; border: 0.5px solid var(--color-border); border-radius: 10px; cursor: pointer; From c8ab0b94286f38cb2773c013a736eb69a09d8de8 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Fri, 17 Oct 2025 20:27:16 +0800 Subject: [PATCH 35/39] fix: resolve gpt-5-codex streaming response issue (#10781) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: resolve gpt-5-codex streaming response issue - Add patch for @opeoginni/github-copilot-openai-compatible to fix text part ID mismatch - Fix text-end event to use currentTextId instead of value.item.id for proper ID matching - Add COPILOT_DEFAULT_HEADERS to OpenAI client for GitHub Copilot compatibility 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * format code --------- Co-authored-by: Claude --- ...nai-compatible-npm-0.1.18-3f65760532.patch | 44 +++++++++++++++++++ package.json | 2 +- .../legacy/clients/openai/OpenAIBaseClient.ts | 4 +- yarn.lock | 14 +++++- 4 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 .yarn/patches/@opeoginni-github-copilot-openai-compatible-npm-0.1.18-3f65760532.patch diff --git a/.yarn/patches/@opeoginni-github-copilot-openai-compatible-npm-0.1.18-3f65760532.patch b/.yarn/patches/@opeoginni-github-copilot-openai-compatible-npm-0.1.18-3f65760532.patch new file mode 100644 index 000000000..cebfdd00a --- /dev/null +++ b/.yarn/patches/@opeoginni-github-copilot-openai-compatible-npm-0.1.18-3f65760532.patch @@ -0,0 +1,44 @@ +diff --git a/dist/index.js b/dist/index.js +index 53f411e55a4c9a06fd29bb4ab8161c4ad15980cd..71b91f196c8b886ed90dd237dec5625d79d5677e 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -12676,10 +12676,13 @@ var OpenAIResponsesLanguageModel = class { + } + }); + } else if (value.item.type === "message") { +- controller.enqueue({ +- type: "text-end", +- id: value.item.id +- }); ++ // Fix for gpt-5-codex: use currentTextId to ensure text-end matches text-start ++ if (currentTextId) { ++ controller.enqueue({ ++ type: "text-end", ++ id: currentTextId ++ }); ++ } + currentTextId = null; + } else if (isResponseOutputItemDoneReasoningChunk(value)) { + const activeReasoningPart = activeReasoning[value.item.id]; +diff --git a/dist/index.mjs b/dist/index.mjs +index 7719264da3c49a66c2626082f6ccaae6e3ef5e89..090fd8cf142674192a826148428ed6a0c4a54e35 100644 +--- a/dist/index.mjs ++++ b/dist/index.mjs +@@ -12670,10 +12670,13 @@ var OpenAIResponsesLanguageModel = class { + } + }); + } else if (value.item.type === "message") { +- controller.enqueue({ +- type: "text-end", +- id: value.item.id +- }); ++ // Fix for gpt-5-codex: use currentTextId to ensure text-end matches text-start ++ if (currentTextId) { ++ controller.enqueue({ ++ type: "text-end", ++ id: currentTextId ++ }); ++ } + currentTextId = null; + } else if (isResponseOutputItemDoneReasoningChunk(value)) { + const activeReasoningPart = activeReasoning[value.item.id]; diff --git a/package.json b/package.json index e82153922..1dad6a9f5 100644 --- a/package.json +++ b/package.json @@ -154,7 +154,7 @@ "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", - "@opeoginni/github-copilot-openai-compatible": "0.1.18", + "@opeoginni/github-copilot-openai-compatible": "patch:@opeoginni/github-copilot-openai-compatible@npm%3A0.1.18#~/.yarn/patches/@opeoginni-github-copilot-openai-compatible-npm-0.1.18-3f65760532.patch", "@playwright/test": "^1.52.0", "@radix-ui/react-context-menu": "^2.2.16", "@reduxjs/toolkit": "^2.2.5", diff --git a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIBaseClient.ts b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIBaseClient.ts index 79762e947..71ed1c95f 100644 --- a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIBaseClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIBaseClient.ts @@ -1,4 +1,5 @@ import { loggerService } from '@logger' +import { COPILOT_DEFAULT_HEADERS } from '@renderer/aiCore/provider/constants' import { isClaudeReasoningModel, isOpenAIReasoningModel, @@ -166,7 +167,8 @@ export abstract class OpenAIBaseClient< baseURL: this.getBaseURL(), defaultHeaders: { ...this.defaultHeaders(), - ...this.provider.extra_headers + ...this.provider.extra_headers, + ...(this.provider.id === 'copilot' ? COPILOT_DEFAULT_HEADERS : {}) } }) as TSdkInstance } diff --git a/yarn.lock b/yarn.lock index 4fcea693b..2d5bffcfd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7421,6 +7421,18 @@ __metadata: languageName: node linkType: hard +"@opeoginni/github-copilot-openai-compatible@patch:@opeoginni/github-copilot-openai-compatible@npm%3A0.1.18#~/.yarn/patches/@opeoginni-github-copilot-openai-compatible-npm-0.1.18-3f65760532.patch": + version: 0.1.18 + resolution: "@opeoginni/github-copilot-openai-compatible@patch:@opeoginni/github-copilot-openai-compatible@npm%3A0.1.18#~/.yarn/patches/@opeoginni-github-copilot-openai-compatible-npm-0.1.18-3f65760532.patch::version=0.1.18&hash=1cf9d0" + dependencies: + "@ai-sdk/openai": "npm:^2.0.42" + "@ai-sdk/openai-compatible": "npm:^1.0.19" + "@ai-sdk/provider": "npm:^2.1.0-beta.4" + "@ai-sdk/provider-utils": "npm:^3.0.10" + checksum: 10c0/cfffc031d2742068d20baed0e0ade6e9182c29ee7a425fa64262c04023ae75220b8b944ad2c9554255681e325fa1a70ec5e1f961b5f7370c871e70cbaeac0e79 + languageName: node + linkType: hard + "@oxc-project/runtime@npm:0.71.0": version: 0.71.0 resolution: "@oxc-project/runtime@npm:0.71.0" @@ -13993,7 +14005,7 @@ __metadata: "@opentelemetry/sdk-trace-base": "npm:^2.0.0" "@opentelemetry/sdk-trace-node": "npm:^2.0.0" "@opentelemetry/sdk-trace-web": "npm:^2.0.0" - "@opeoginni/github-copilot-openai-compatible": "npm:0.1.18" + "@opeoginni/github-copilot-openai-compatible": "patch:@opeoginni/github-copilot-openai-compatible@npm%3A0.1.18#~/.yarn/patches/@opeoginni-github-copilot-openai-compatible-npm-0.1.18-3f65760532.patch" "@playwright/test": "npm:^1.52.0" "@radix-ui/react-context-menu": "npm:^2.2.16" "@reduxjs/toolkit": "npm:^2.2.5" From 1e1d5c4a14a724521e30535ee6c95607b824954e Mon Sep 17 00:00:00 2001 From: SuYao Date: Fri, 17 Oct 2025 20:34:53 +0800 Subject: [PATCH 36/39] feat: add Mistral provider configuration to AI Providers (#10795) --- .../src/aiCore/provider/providerInitialization.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/renderer/src/aiCore/provider/providerInitialization.ts b/src/renderer/src/aiCore/provider/providerInitialization.ts index 2a19729d7..9942ffa40 100644 --- a/src/renderer/src/aiCore/provider/providerInitialization.ts +++ b/src/renderer/src/aiCore/provider/providerInitialization.ts @@ -55,6 +55,14 @@ export const NEW_PROVIDER_CONFIGS: ProviderConfig[] = [ creatorFunctionName: 'createPerplexity', supportsImageGeneration: false, aliases: ['perplexity'] + }, + { + id: 'mistral', + name: 'Mistral', + import: () => import('@ai-sdk/mistral'), + creatorFunctionName: 'createMistral', + supportsImageGeneration: false, + aliases: ['mistral'] } ] as const From ab3083f943182a1102498e4799d7926993a57b06 Mon Sep 17 00:00:00 2001 From: defi-failure <159208748+defi-failure@users.noreply.github.com> Date: Fri, 17 Oct 2025 20:48:40 +0800 Subject: [PATCH 37/39] fix: fail to create assistant (#10796) --- src/renderer/src/store/assistants.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/renderer/src/store/assistants.ts b/src/renderer/src/store/assistants.ts index 6fa570be1..162a8edd4 100644 --- a/src/renderer/src/store/assistants.ts +++ b/src/renderer/src/store/assistants.ts @@ -193,8 +193,7 @@ const assistantsSlice = createSlice({ }) }, addAssistantPreset: (state, action: PayloadAction) => { - // @ts-ignore ts-2589 false positive - state.agents.push(action.payload) + state.presets.push(action.payload) }, removeAssistantPreset: (state, action: PayloadAction<{ id: string }>) => { state.presets = state.presets.filter((c) => c.id !== action.payload.id) From 131444ac52f9ea31a162518daa6460bd18484fbf Mon Sep 17 00:00:00 2001 From: defi-failure <159208748+defi-failure@users.noreply.github.com> Date: Fri, 17 Oct 2025 20:52:15 +0800 Subject: [PATCH 38/39] fix: agent supported model filter (#10788) * Revert "fix: make anthropic model provided by cherryin visible to agent (#10695)" This reverts commit 7b3b73d390d665582cc2cdfab1fd673f56c4b7bd. * fix: agent supported model filter --- src/main/apiServer/services/models.ts | 35 +++++++++---------- src/main/apiServer/utils/index.ts | 24 ++++++++----- .../agents/services/claudecode/index.ts | 20 +++-------- .../components/Popups/agent/AgentModal.tsx | 29 +++++++++------ src/renderer/src/config/models/utils.ts | 7 +++- src/renderer/src/config/providers.ts | 3 +- .../components/SelectAgentBaseModelButton.tsx | 7 ++-- src/renderer/src/store/index.ts | 2 +- src/renderer/src/store/migrate.ts | 5 +++ src/renderer/src/types/apiModels.ts | 1 - src/renderer/src/utils/agentSession.ts | 2 +- 11 files changed, 73 insertions(+), 62 deletions(-) diff --git a/src/main/apiServer/services/models.ts b/src/main/apiServer/services/models.ts index 6c0056b27..660686ef4 100644 --- a/src/main/apiServer/services/models.ts +++ b/src/main/apiServer/services/models.ts @@ -2,7 +2,12 @@ import { isEmpty } from 'lodash' import { ApiModel, ApiModelsFilter, ApiModelsResponse } from '../../../renderer/src/types/apiModels' import { loggerService } from '../../services/LoggerService' -import { getAvailableProviders, listAllAvailableModels, transformModelToOpenAI } from '../utils' +import { + getAvailableProviders, + getProviderAnthropicModelChecker, + listAllAvailableModels, + transformModelToOpenAI +} from '../utils' const logger = loggerService.withContext('ModelsService') @@ -10,10 +15,6 @@ const logger = loggerService.withContext('ModelsService') export type ModelsFilter = ApiModelsFilter -const isAnthropicProvider = (provider: { type: string; anthropicApiHost?: string }) => { - return provider.type === 'anthropic' || !isEmpty(provider.anthropicApiHost?.trim()) -} - export class ModelsService { async getModels(filter: ModelsFilter): Promise { try { @@ -22,7 +23,7 @@ export class ModelsService { let providers = await getAvailableProviders() if (filter.providerType === 'anthropic') { - providers = providers.filter(isAnthropicProvider) + providers = providers.filter((p) => p.type === 'anthropic' || !isEmpty(p.anthropicApiHost?.trim())) } const models = await listAllAvailableModels(providers) @@ -31,22 +32,18 @@ export class ModelsService { for (const model of models) { const provider = providers.find((p) => p.id === model.provider) - logger.debug(`Processing model ${model.id} from provider ${model.provider}`, { - isAnthropicModel: provider?.isAnthropicModel - }) - if ( - !provider || - (filter.providerType === 'anthropic' && provider.isAnthropicModel && !provider.isAnthropicModel(model)) - ) { - continue - } - // Special case: For "aihubmix", it should be covered by above condition, but just in case - if (provider.id === 'aihubmix' && filter.providerType === 'anthropic' && !model.id.includes('claude')) { + logger.debug(`Processing model ${model.id}`) + if (!provider) { + logger.debug(`Skipping model ${model.id} . Reason: Provider not found.`) continue } - if (filter.supportAnthropic && model.endpoint_type !== 'anthropic' && !isAnthropicProvider(provider)) { - continue + if (filter.providerType === 'anthropic') { + const checker = getProviderAnthropicModelChecker(provider.id) + if (!checker(model)) { + logger.debug(`Skipping model ${model.id} from ${model.provider}. Reason: Not an Anthropic model.`) + continue + } } const openAIModel = transformModelToOpenAI(model, provider) diff --git a/src/main/apiServer/utils/index.ts b/src/main/apiServer/utils/index.ts index 865f961db..7fb0c3511 100644 --- a/src/main/apiServer/utils/index.ts +++ b/src/main/apiServer/utils/index.ts @@ -1,7 +1,7 @@ import { CacheService } from '@main/services/CacheService' import { loggerService } from '@main/services/LoggerService' import { reduxService } from '@main/services/ReduxService' -import { ApiModel, EndpointType, Model, Provider } from '@types' +import { ApiModel, Model, Provider } from '@types' const logger = loggerService.withContext('ApiServerUtils') @@ -114,7 +114,6 @@ export async function validateModelId(model: string): Promise<{ error?: ModelValidationError provider?: Provider modelId?: string - modelEndpointType?: EndpointType }> { try { if (!model || typeof model !== 'string') { @@ -167,8 +166,7 @@ export async function validateModelId(model: string): Promise<{ } // Check if model exists in provider - const modelInProvider = provider.models?.find((m) => m.id === modelId) - const modelExists = !!modelInProvider + const modelExists = provider.models?.some((m) => m.id === modelId) if (!modelExists) { const availableModels = provider.models?.map((m) => m.id).join(', ') || 'none' return { @@ -181,13 +179,10 @@ export async function validateModelId(model: string): Promise<{ } } - const modelEndpointType = modelInProvider?.endpoint_type - return { valid: true, provider, - modelId, - modelEndpointType + modelId } } catch (error: any) { logger.error('Error validating model ID', { error, model }) @@ -284,3 +279,16 @@ export function validateProvider(provider: Provider): boolean { return false } } + +export const getProviderAnthropicModelChecker = (providerId: string): ((m: Model) => boolean) => { + switch (providerId) { + case 'cherryin': + case 'new-api': + return (m: Model) => m.endpoint_type === 'anthropic' + case 'aihubmix': + return (m: Model) => m.id.includes('claude') + default: + // allow all models when checker not configured + return () => true + } +} diff --git a/src/main/services/agents/services/claudecode/index.ts b/src/main/services/agents/services/claudecode/index.ts index 7dae2f9e9..7b2f119af 100644 --- a/src/main/services/agents/services/claudecode/index.ts +++ b/src/main/services/agents/services/claudecode/index.ts @@ -8,7 +8,6 @@ import { config as apiConfigService } from '@main/apiServer/config' import { validateModelId } from '@main/apiServer/utils' import getLoginShellEnvironment from '@main/utils/shell-env' import { app } from 'electron' -import { isEmpty } from 'lodash' import { GetAgentSessionResponse } from '../..' import { AgentServiceInterface, AgentStream, AgentStreamEvent } from '../../interfaces/AgentStreamInterface' @@ -61,20 +60,11 @@ class ClaudeCodeService implements AgentServiceInterface { }) return aiStream } - - const validateModelInfo: (m: typeof modelInfo) => boolean = (m) => { - const { provider, modelEndpointType } = m - if (!provider) return false - if (isEmpty(provider.apiKey?.trim())) return false - - const isAnthropicType = provider.type === 'anthropic' - const isAnthropicEndpoint = modelEndpointType === 'anthropic' - const hasValidApiHost = !isEmpty(provider.anthropicApiHost?.trim()) - - return !(!isAnthropicType && !isAnthropicEndpoint && !hasValidApiHost) - } - - if (!modelInfo.provider || !validateModelInfo(modelInfo)) { + if ( + (modelInfo.provider?.type !== 'anthropic' && + (modelInfo.provider?.anthropicApiHost === undefined || modelInfo.provider.anthropicApiHost.trim() === '')) || + modelInfo.provider.apiKey === '' + ) { logger.error('Anthropic provider configuration is missing', { modelInfo }) diff --git a/src/renderer/src/components/Popups/agent/AgentModal.tsx b/src/renderer/src/components/Popups/agent/AgentModal.tsx index 63b614944..ea772fb1e 100644 --- a/src/renderer/src/components/Popups/agent/AgentModal.tsx +++ b/src/renderer/src/components/Popups/agent/AgentModal.tsx @@ -17,7 +17,7 @@ import { import { loggerService } from '@logger' import type { Selection } from '@react-types/shared' import ClaudeIcon from '@renderer/assets/images/models/claude.png' -import { getModelLogo } from '@renderer/config/models' +import { agentModelFilter, getModelLogo } from '@renderer/config/models' import { permissionModeCards } from '@renderer/constants/permissionModes' import { useAgents } from '@renderer/hooks/agents/useAgents' import { useApiModels } from '@renderer/hooks/agents/useModels' @@ -100,7 +100,7 @@ export const AgentModal: React.FC = ({ agent, trigger, isOpen: _isOpen, o const { addAgent } = useAgents() const { updateAgent } = useUpdateAgent() // hard-coded. We only support anthropic for now. - const { models } = useApiModels({ supportAnthropic: true }) + const { models } = useApiModels({ providerType: 'anthropic' }) const isEditing = (agent?: AgentWithTools) => agent !== undefined const [form, setForm] = useState(() => buildAgentForm(agent)) @@ -245,14 +245,23 @@ export const AgentModal: React.FC = ({ agent, trigger, isOpen: _isOpen, o const modelOptions = useMemo(() => { // mocked data. not final version - return (models ?? []).map((model) => ({ - type: 'model', - key: model.id, - label: model.name, - avatar: getModelLogo(model.id), - providerId: model.provider, - providerName: model.provider_name - })) satisfies ModelOption[] + return (models ?? []) + .filter((m) => + agentModelFilter({ + id: m.id, + provider: m.provider || '', + name: m.name, + group: '' + }) + ) + .map((model) => ({ + type: 'model', + key: model.id, + label: model.name, + avatar: getModelLogo(model.id), + providerId: model.provider, + providerName: model.provider_name + })) satisfies ModelOption[] }, [models]) const onModelChange = useCallback((e: ChangeEvent) => { diff --git a/src/renderer/src/config/models/utils.ts b/src/renderer/src/config/models/utils.ts index 39078e292..1759a93d1 100644 --- a/src/renderer/src/config/models/utils.ts +++ b/src/renderer/src/config/models/utils.ts @@ -1,3 +1,4 @@ +import { isEmbeddingModel, isRerankModel } from '@renderer/config/models/embedding' import { Model } from '@renderer/types' import { getLowerBaseModelName } from '@renderer/utils' import OpenAI from 'openai' @@ -5,7 +6,7 @@ import OpenAI from 'openai' import { WEB_SEARCH_PROMPT_FOR_OPENROUTER } from '../prompts' import { getWebSearchTools } from '../tools' import { isOpenAIReasoningModel } from './reasoning' -import { isGenerateImageModel, isVisionModel } from './vision' +import { isGenerateImageModel, isTextToImageModel, isVisionModel } from './vision' import { isOpenAIWebSearchChatCompletionOnlyModel } from './websearch' export const NOT_SUPPORTED_REGEX = /(?:^tts|whisper|speech)/i @@ -246,3 +247,7 @@ export const isOpenAIOpenWeightModel = (model: Model) => { // zhipu 视觉推理模型用这组 special token 标记推理结果 export const ZHIPU_RESULT_TOKENS = ['<|begin_of_box|>', '<|end_of_box|>'] as const + +export const agentModelFilter = (model: Model): boolean => { + return !isEmbeddingModel(model) && !isRerankModel(model) && !isTextToImageModel(model) +} diff --git a/src/renderer/src/config/providers.ts b/src/renderer/src/config/providers.ts index 22faf0fb0..7f8d95dcd 100644 --- a/src/renderer/src/config/providers.ts +++ b/src/renderer/src/config/providers.ts @@ -58,7 +58,6 @@ import ZhipuProviderLogo from '@renderer/assets/images/providers/zhipu.png' import { AtLeast, isSystemProvider, - Model, OpenAIServiceTiers, Provider, ProviderType, @@ -88,6 +87,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record = type: 'openai', apiKey: '', apiHost: 'https://open.cherryin.net', + anthropicApiHost: 'https://open.cherryin.net', models: [], isSystem: true, enabled: true @@ -109,7 +109,6 @@ export const SYSTEM_PROVIDERS_CONFIG: Record = apiKey: '', apiHost: 'https://aihubmix.com', anthropicApiHost: 'https://aihubmix.com/anthropic', - isAnthropicModel: (m: Model) => m.id.includes('claude'), models: SYSTEM_MODELS.aihubmix, isSystem: true, enabled: false diff --git a/src/renderer/src/pages/home/components/SelectAgentBaseModelButton.tsx b/src/renderer/src/pages/home/components/SelectAgentBaseModelButton.tsx index 7e21d1b47..74f69cf15 100644 --- a/src/renderer/src/pages/home/components/SelectAgentBaseModelButton.tsx +++ b/src/renderer/src/pages/home/components/SelectAgentBaseModelButton.tsx @@ -1,10 +1,10 @@ import { Button } from '@heroui/react' import ModelAvatar from '@renderer/components/Avatar/ModelAvatar' import { SelectApiModelPopup } from '@renderer/components/Popups/SelectModelPopup' -import { isEmbeddingModel, isRerankModel, isTextToImageModel } from '@renderer/config/models' +import { agentModelFilter } from '@renderer/config/models' import { useApiModel } from '@renderer/hooks/agents/useModel' import { getProviderNameById } from '@renderer/services/ProviderService' -import { AgentBaseWithId, ApiModel, isAgentEntity, Model } from '@renderer/types' +import { AgentBaseWithId, ApiModel, isAgentEntity } from '@renderer/types' import { getModelFilterByAgentType } from '@renderer/utils/agentSession' import { apiModelAdapter } from '@renderer/utils/model' import { ChevronsUpDown } from 'lucide-react' @@ -22,12 +22,11 @@ const SelectAgentBaseModelButton: FC = ({ agentBase: agent, onSelect, isD const model = useApiModel({ id: agent?.model }) const apiFilter = isAgentEntity(agent) ? getModelFilterByAgentType(agent.type) : undefined - const modelFilter = (model: Model) => !isEmbeddingModel(model) && !isRerankModel(model) && !isTextToImageModel(model) if (!agent) return null const onSelectModel = async () => { - const selectedModel = await SelectApiModelPopup.show({ model, apiFilter: apiFilter, modelFilter }) + const selectedModel = await SelectApiModelPopup.show({ model, apiFilter: apiFilter, modelFilter: agentModelFilter }) if (selectedModel && selectedModel.id !== agent.model) { onSelect(selectedModel) } diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 0d53c2ca5..0fee3196b 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -65,7 +65,7 @@ const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 162, + version: 163, blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'], migrate }, diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 5cca66b47..46ac128c6 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -2671,6 +2671,11 @@ const migrateConfig = { '163': (state: RootState) => { try { addOcrProvider(state, BUILTIN_OCR_PROVIDERS_MAP.ovocr) + state.llm.providers.forEach((provider) => { + if (provider.id === 'cherryin') { + provider.anthropicApiHost = 'https://open.cherryin.net' + } + }) return state } catch (error) { logger.error('migrate 163 error', error as Error) diff --git a/src/renderer/src/types/apiModels.ts b/src/renderer/src/types/apiModels.ts index 7b4ec96c9..68141bf68 100644 --- a/src/renderer/src/types/apiModels.ts +++ b/src/renderer/src/types/apiModels.ts @@ -6,7 +6,6 @@ import { ProviderTypeSchema } from './provider' // Request schema for /v1/models export const ApiModelsFilterSchema = z.object({ providerType: ProviderTypeSchema.optional(), - supportAnthropic: z.coerce.boolean().optional(), offset: z.coerce.number().min(0).default(0).optional(), limit: z.coerce.number().min(1).default(20).optional() }) diff --git a/src/renderer/src/utils/agentSession.ts b/src/renderer/src/utils/agentSession.ts index b2cf14d17..df3441364 100644 --- a/src/renderer/src/utils/agentSession.ts +++ b/src/renderer/src/utils/agentSession.ts @@ -18,7 +18,7 @@ export const getModelFilterByAgentType = (type: AgentType): ApiModelsFilter => { switch (type) { case 'claude-code': return { - supportAnthropic: true + providerType: 'anthropic' } default: return {} From 8470e252d6cae65e4de3f4989968f9da9995c6bc Mon Sep 17 00:00:00 2001 From: defi-failure <159208748+defi-failure@users.noreply.github.com> Date: Fri, 17 Oct 2025 20:53:08 +0800 Subject: [PATCH 39/39] feat: auto-start API server when agents exist (#10772) * feat: auto-start API server when agents exist * fix: only display not running alert when enabled --- src/main/index.ts | 19 +++++++++++++++++-- src/renderer/src/hooks/agents/useAgents.ts | 3 ++- .../src/pages/home/Tabs/AssistantsTab.tsx | 4 ++-- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index da9736c2b..fa83dc72b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -159,11 +159,26 @@ if (!app.requestSingleInstanceLock()) { logger.error('Failed to initialize Agent service:', error) } - // Start API server if enabled + // Start API server if enabled or if agents exist try { const config = await apiServerService.getCurrentConfig() logger.info('API server config:', config) - if (config.enabled) { + + // Check if there are any agents + let shouldStart = config.enabled + if (!shouldStart) { + try { + const { total } = await agentService.listAgents({ limit: 1 }) + if (total > 0) { + shouldStart = true + logger.info(`Detected ${total} agent(s), auto-starting API server`) + } + } catch (error: any) { + logger.warn('Failed to check agent count:', error) + } + } + + if (shouldStart) { await apiServerService.start() } } catch (error: any) { diff --git a/src/renderer/src/hooks/agents/useAgents.ts b/src/renderer/src/hooks/agents/useAgents.ts index 6af38228c..bf23c2c9c 100644 --- a/src/renderer/src/hooks/agents/useAgents.ts +++ b/src/renderer/src/hooks/agents/useAgents.ts @@ -26,7 +26,8 @@ export const useAgents = () => { const key = client.agentPaths.base const { apiServerConfig, apiServerRunning } = useApiServer() const fetcher = useCallback(async () => { - if (!apiServerConfig.enabled) { + // API server will start on startup if enabled OR there are agents + if (!apiServerConfig.enabled && !apiServerRunning) { throw new Error(t('apiServer.messages.notEnabled')) } if (!apiServerRunning) { diff --git a/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx b/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx index af02796d7..dc272b869 100644 --- a/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx @@ -101,7 +101,7 @@ const AssistantsTab: FC = (props) => { return ( - {!apiServerConfig.enabled && !iknow[ALERT_KEY] && ( + {!apiServerConfig.enabled && !apiServerRunning && !iknow[ALERT_KEY] && ( = (props) => { {apiServerConfig.enabled && !apiServerRunning && ( )} - {apiServerConfig.enabled && apiServerRunning && agentsError && ( + {apiServerRunning && agentsError && (