From f07078ee1a7be74b8bb55aef693d6d5e10e9545e Mon Sep 17 00:00:00 2001 From: Ashwin Nethan <45286@yenepoya.edu.in> Date: Mon, 3 Aug 2026 14:38:18 +0530 Subject: [PATCH] update project dependencies and install missing packages in node_modules --- README.md | 10 +- chat.py | 51 + frontend/.gitignore | 24 + frontend/README.md | 75 + frontend/eslint.config.js | 22 + frontend/index.html | 15 + frontend/package.json | 44 + frontend/pnpm-lock.yaml | 3316 +++++++++++++++++ frontend/public/favicon.svg | 1 + frontend/public/icons.svg | 24 + frontend/src/App.css | 1 + frontend/src/App.tsx | 561 +++ frontend/src/assets/hero.png | Bin 0 -> 13057 bytes frontend/src/assets/react.svg | 1 + frontend/src/assets/vite.svg | 1 + frontend/src/components/ChatWindow.tsx | 468 +++ frontend/src/components/EmptyChat.tsx | 111 + frontend/src/components/LoadingScreen.tsx | 100 + frontend/src/components/MessageInput.tsx | 324 ++ frontend/src/components/ModelSelector.tsx | 254 ++ frontend/src/components/SettingsDialogs.tsx | 359 ++ frontend/src/components/Sidebar.tsx | 709 ++++ frontend/src/components/TopBar.tsx | 274 ++ frontend/src/components/WelcomeScreen.tsx | 39 + .../components/content/StructuredContent.tsx | 506 +++ frontend/src/components/ui/Modal.tsx | 132 + frontend/src/components/ui/Notifications.tsx | 115 + frontend/src/index.css | 181 + frontend/src/lib/api.ts | 187 + frontend/src/lib/messageContent.ts | 294 ++ frontend/src/lib/storage.ts | 124 + frontend/src/lib/utils.ts | 110 + frontend/src/main.tsx | 15 + frontend/src/store/AppContext.tsx | 416 +++ frontend/src/types.ts | 86 + frontend/tsconfig.app.json | 26 + frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 23 + frontend/vite.config.ts | 8 + test_openai.py | 15 + 40 files changed, 9024 insertions(+), 5 deletions(-) create mode 100644 chat.py create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/pnpm-lock.yaml create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icons.svg create mode 100644 frontend/src/App.css create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/assets/hero.png create mode 100644 frontend/src/assets/react.svg create mode 100644 frontend/src/assets/vite.svg create mode 100644 frontend/src/components/ChatWindow.tsx create mode 100644 frontend/src/components/EmptyChat.tsx create mode 100644 frontend/src/components/LoadingScreen.tsx create mode 100644 frontend/src/components/MessageInput.tsx create mode 100644 frontend/src/components/ModelSelector.tsx create mode 100644 frontend/src/components/SettingsDialogs.tsx create mode 100644 frontend/src/components/Sidebar.tsx create mode 100644 frontend/src/components/TopBar.tsx create mode 100644 frontend/src/components/WelcomeScreen.tsx create mode 100644 frontend/src/components/content/StructuredContent.tsx create mode 100644 frontend/src/components/ui/Modal.tsx create mode 100644 frontend/src/components/ui/Notifications.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/messageContent.ts create mode 100644 frontend/src/lib/storage.ts create mode 100644 frontend/src/lib/utils.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/store/AppContext.tsx create mode 100644 frontend/src/types.ts create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 test_openai.py diff --git a/README.md b/README.md index 9b4a469..0c18c81 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ -# gemini-web2api +# OmniAI
-
+
mI|13!gBV?X`Ozp7x>?6jr`>Qz=^4ea35!$*f}) zS$i+x_k+@P2q1RFUH^ZTTk7=n?cjfR>hTq3l3SY~#w+I8SSutXGyhw;Ws~=zMQ%Vc z>$On~47Ut?P*_!TOQ&PFmLAyJieB2X4_Fd_!WxI-AY`q1Lc-oK?+qcOTzlQ?@~x@OT}*9jTVNfl@3rGvZpWI=eKg>T zZb@6YWz)J=IhP7CF|c?G62vMEG%#U}?#86$0jR4sG~i(jRd#jmn`7b(O#?N;3a;1t zhXLssmUwGhp79luw#(*V8W L0|8+E z6=YZ_O@er~$LrD_PYGc(kJgB=;yw#+Z3X6LDUZ(NcwN=B-hjdiHm!JFar%m{(5bEW z@@_VEtG$5;`EJZ|OkJ@l&G9n((w@uNFwmU%bG|s#TbcJJos!{e +bjCjrCq_}LcN!UFgKtgg7siV*7# z!}1whTRRi*-avJPu->C}Z8EiuK$#886+H_#_!btv+rsiBbv2jAJvJ+O0{#}y(%L3H zfjU-kq_-L@2XrL*ae{{qYJkD{@dw%*bkh2P&YS-0!Xt!PRz7KHV0+~j(t9W8lAVWR zt@B*DgURgEz4>WuN>o?_iKc w$?k{||Pg7{Q2o4|VmJ)mg?{VQJA<}zEr^YAAS zgGm5RT4T3p)U;yz-tfBO^kw8?IoG!IVmc+Z3m#}AOQ?5MRa>)OcU!$N^_+yK6ayn? zK>~WK0!#ysuj^oNLakm)Zvu+J)OSubX^kv!c*xgdIvs;kln!rgG4*uZ;w0mQQO4XD zO9P{GNdv!=cQ(CAL{S(% KtuV^zC& Q{%g)PoXnp^gn^>c*`E>$hLYg2HjnbVGtWLa{7zHdG1jT@B{|Dm16 z7K2(jsfG+m*Zxof)iXxu+!H5M o-0$pkyV3VV4B@Qms46M zuBxGRV@HxU 7Wwx-6CB zaU*HO <_qn$5GH>&@?nRy1{z zkik!sLfWQ)r#75)vVwCBU*r_)Q6mp?!j85{#Xqse)ApRdE$V0%I0*~e(_{)5H)`Mk z#rExC>yjhZxuL@|+#v4#< Axw$+VpV zuT;!2Vww$je$DpAW`$FX_Ab|Ip%$;&T$-lW8jS~B$>G}rd>eQG+$h9lQx4Mx0w={m zx9?T6VU`>sR}XC lkAhHEShOUe8awiq zmizhL+}5UKs3}6~It7vBTig9dfQ2Q8coo+Miiaw7n~>4ybv2Ptt0^^=VqX(t*Yya9 zr`FxxFX8(v*H=+uJ#JJWIB2A(==HDYx~^zZ2nu?2`}|Wsa*f3h3ixc+U|FDtAG$Y! z*lc_7se5Oso-Cgqe0){{!8H4g$3<8!R<6JOurD;((({c$1(pwb>(#TT!sge@4>r2@ zVL7>U`0`nsWAYErezk4(Z!gMI2?UTo{J3Ajo(u4)KYIRd>BRcG4BoS3G0EXyEp@tw z%P7__?A^a>Q&AKL@ayDO9D*Qkc!NHnO9l}kpp_6hXbMppYL(X1L?njdFT|-h2<_$; zAtDZ!1Rf%|yb!qbWKd}%0b`LzBeyNy43|QO(&h2mxQLUL)|0%agVOW)6TV!&Ip^Ls z`PG2cygM8)IecQx=Fc+nqYRo4hS^^-nM_&-y8?EJXUczP=DIw(GkTJdpEdh<_STs{ z|A)4n1GKdE=Wu!!nYoZHcUQ4S&R;oDOKX2lrkdF(mK>hz<$Pp>igjOcvoRIjlN=W8 zu8Gx5(roqn8$>gEE5vy{GiGeW8Tq{vnf3hS-V=$tZkQuftUVuU8o6k&dn= Yg3)6MOIH>nlK^-2+C6BZITr~1@So?NvG#TwL)|~=1YXGMTLpS<)ziK_CSOabe z=cB#5)yz|@0i9dSo?*CX)}UP=s6)B+F@~Em(u@Q(I9J9i_V{LmMu8BfXYMh~*oPP+ z!3~xTv|(>|=n6ZOtT~C@V!z!w%18*8T2t6}U2S##rC)mekBql&VsBX;$~ByGE$oA9 z`0Wzq8p?R{4)$l*on;!cLa}Dh^Xe?owiQZt9nH1fxxh$pN9K%CtOw?u3>85L7rr!d zXs)l{TZ{xXP&U8exz?9cv~dNNibOmt*K4I$?RxqIBZ0(?Mg-9FS{*9Bc49Qc1`=sIF-rye`aNT1G@4NwXcnyc@+bw_mTsR>5< zF<2;X0QesG_pw|TonqVBhRtfqI>ty(SIu&VOXd0CrLlfp+;WH7HYjhqnu^oAY!9cB z=B6#R?Rfz9BP`dJ=@v_?70s3HxQPk+{6Y+lM85f2NF^00*^OcM0~?JOZfR9ZPYF+# zYSs}(_BUYV8{n@2a1hD^SV41bwmi2uztR;PeBgF1F-`9>` zoNss-@3LaF2sjl~>OaaVmp7PNp+UT`6@}gR%uzqHDVeEZ14{Yt?n%JeQm+t(1_u zSc}oj^{b;+rlS|ME%+LjzSI&xu0Bblxo$MJ-J$kJ?Qu_XUXh}*@*-x@ny|}wVM%Lg z3tNB`yvr*}N?ClGL;H2cglcvErIccU3(eP7>@~4nOIcI~-`P8tSQnx=jI&{9)!1}l z;gQ%_h>ZlPSV@o@Azq1 R$C6ja5!^ZGh;YRhhxs58qJWo9@Bc eac&yy(pET1hnn`~7@}2L0&dfPKYs$ih7m2}R!25!(hxqA(!UIw; zK4+~Jowy3=R NC6n E=ncU{LH5?*9@W24lacJlvCZXB$CYtE@>c+~H zkV=(5I&gb{xn2!~f&fs2NQgAL6`p|kyt6kpWk}iVlqIp(H;ig`{_U9yxs1jzu^ETM z7~)Rg8C-Nue qTYP&U8l{DY=Y47cR zOR@U%$KQV{mkRF|4)z9Y^t3K`@p>duY&QLUFeh6VoV`a`$U@)(z!-N*5Cj<1 1$EZW&hJLX83TO{lJYP74rlDZQPkm@t<=U^I)x@|UnHHkdQlh?!ltZwl92rE;;^ zZuIappj4dhld1}kttYYV-j|KF1Kus zWBnzttD^00%LFK(wrwNragFub6xiV8QE2rm<`&fcR4SLFcdtLxVuN!Aal-g6dE4%k zARZ}|xeo;K{0yf7@9aua%2j5o)CPcIOc6uLHFJOcgtB5owlcNAwyAHc0QB0Dts?c@ zUemG~j_E&W7R%+x-IO4FJl8e&*2Blmp1S#RA|)geVrxvP)NHdYuxi~g&Etn?QdN K8ZDKZ?QFLU? zh30 G|t9G>a_X4zk}Ygw<^$7K!GIn(Io$>(d4ODJQ2XSd%jpK zm7>ptl$a3GyB}5-%p4>Q*p#VL^B{yQMuFCM^#l#+N!Ne z5_PrJWB=@Iy+t)H`g1lX`{bm($KE5I?0c(JEYm#t{F}j!xtsb ob0{xu@0TB_*>G7w0ICn zr#V oBktqHZ~XxhiKD*lcG|b;H *|Ny3P^8ceV`sfBRfrhwZ!T+MFZ!F1Bt{q$8d9i6o?~ zODj^POr}&ivSa^R^YFIq7o0giLBKCycH_aU`F6)O6JX%nPTwh~Q`eq6*0iE#Srj2^ z*_hN3%*b83zfafy60@Cp3{J({RlSaEn &E?mrxRNC9GQ7#+f=s! z0KBf-9Ny_v2VbE%aB|Di)5kNJ^t&C`4D(>t7zYUWUFtbxt+Oq=!@O7BU)}>d*R72o zFF)3jQD_lLe4is&xzyJYC1-c{8TX$RU>&>P$%)ufpez0XSAukmh!xcekg`s$c<>-q zI#zn^JU0zzF}V60)o$_gY}PQH>b2M9&8fR Za#OauglPb zeQ@pMm&=!vNgos4CluQjLMV!pfkmxK+35bi^k&=k>9h02?l+u+m0agG ;(h2|Jslc-llvtEwn~*w3bx7qnvZACG<8}AGeaDVvcHbKd2>3G^ zSFPULUn-?Pmo^-_`mLZr??uNH`2=I&yajlrF{DtUxMy#Nu}z=3y7qbUA;5`)hibMR zhXL@@uKyV0-2&A@t@!xyrBnMJl&^o@Gx$&5_q6?D=ji5grd-~=?dlg;ur(_V0wjh! zA=JV^C1m+DDkOsgr<%O9ZQFg!0}pD(#PSz4Dr_EyS5$`)VIAv);4n-SFP~YtC7sH= z7&*MfpH;gd*FHbkmD#)hVxb6xjc9~`t?_{=JS+@ip_cTicXxG<=7m9 & zPX+Z8IC*GSAXuGCrZDHgR$r%jyk-fctis2Kx4HvZ|B~8uC@o)m^>Hy-O!&TKA?$&n zkP2Xc54w~!=z2?^NafyL*L0V9cbYrugHBBUj`xVyZmGFR&kvk#>1J*Z~i zNTz}?IAdJ$gkqd2!Gw(%LzE!O5s4C7q4%T~e_P{+z=DNDKrG**p=U`d5yg^vp`;Zn zsU=8gd0a9s4s0FPJePWR9eH5=+O^Kks&kC-iblNqTh2&Pw*^(4384f+D8N|fewZu_ zg2ejQ)ov;ztz;NQl7yj;A`(!H!XQu_$sqY9h_IrH*}_%1{L&_YLDvO?%R5Z-t+ClW z_qERbL?HKUZ!nt+!E9S`uoh^5A|DaIHe*_gf1`E_Vq+}{&T@t$EGhMnRjJ4z2w_W8 zp+qjs7as22^&S3wY1?+}^j-I=RcCE>#|39)g( lU7v_8;?=qK(9D8-*pPdiy)P3lIblG`+?%ea| zYoD3dopYt!tKgFicfNmNi(EWE=E4hC6(r|PYtanqJlmt57YOVrr2^tfrG(eG9C##X zu&1t@%L$RIvpj!w UA z8i>Pqot#_+Cnp6L2XPcZy1ar|9MnY+7eNvK1E)@Tr#2KsXq1*>)uUCozT7L##ok?o zhA6ofP4E|b*9tAfG?u f$#}>TIR&1A!yslP8}i7w-EzW(x#9VEvx18k%Tn=-$VV zkOtUr0b2!w3t>h?#8AZl^Az*(6KCGlD;4j~yx};`#2gN1_gv=%7KVzecIRakN{f*4 zeaI>yH;-o4OGhvGTU)(quWI )-q?V*(sVesSMv|wMUQ3hLEt=lBB$KZ9TyHr> )f7o%) zPYeU<3P)*P10*7vE)nA5#{c=6-E-_>r_u4e3i!I2+UksELwDqwMeBZ9FSP$;^Ajro z_@M#_Ss$?ejoB@!wN|kbGKs(0zLo%0QpQXW#t;oC$B0MZYZ&Ej?8~fNhcCVvPo3vo zFn0WWZaPliF^8_}yzb`*f@yg0uWv6HgNI)xa=pO%Ck(C<=-60l#uD3(wXP~c7!NoX z0&^6=N`zcc90F#qt@=Rn@r!3(*1v(Tl{B!m?Mc7yIA+nEHpY{YWr$=)F7rhR1P}(v zt{YhY#;jsW6G>#xhP*B`OCk|Pf+NN;ju1rxa*HAgoGq*rvqw&xe~;t1JA31$s?GBb z*g7&@cbKo4n<`>)!UlIAgR6q&))B0KYU8r66GbFj?8Guw4E%&}Qi_lT003LtoIZei zwD~=XZmeo+yZ2Pq3KYCF-R&11^p = z@H%s+=G`}wrbJ{()Mh71#2SP3Zy3m>l1n?0N-N1Q;z6?oSxr-G(H5m4EO>~&;}VKi zfY}3w+9z> vp#d)hVuu`)vG_aaH%3b=WKMnSu&c31;<3O;bz2iD=w+o4#oBb36 z5ZCF*Gu?zjZIR0S>_%pHY2$k8D^n7Sz_K8tCDeXM+dO<#LSg%h6`~dnVG1N@T7v&e z%wEd1!k{^zfz_1BTW{!$!B%g)J^2b87!9Y>>100X1SgT7s0z$o>^lAA=G p_cC1(h=*5Tmf8z&LGJJ>$|K^~s`z9*OWz5MFUr?>Bi?_PGBB)#psD5?>n+q{o_ zz7~ez&;t#h8l$jwGPCC&xq2YetXYQT+0F3j(`xmNGf8dj#an|p#I*pvI*kwW4iuB> z+q3_7xB8y;pLzHG-S%+UHQ A zvqp;$kmGJY>lL sN4C~&TcvAS1SErTcwcw0r@wngk zShAUA1M9b#g}^pL-zH7Q#z^&j#r9F8BTVfkR&qF<=e35goTu7c|GN)0mokj4m0%~0 zXJ8j4Hc_l;HJ&uU*Iw`8d_EscJ``s0tk9mkKo^TYXm-EoAzTQObxa@^u~g2t#T) zJz|rE!I_?i4dCJC=B8(_pZ{YR>|V?0iCcnU;E@$239^x?SYCfNaMHN;CtHIS_zHN9 zTkQc1v@O35okiFtq5_u+5FkY55ap@pi)O?}x0D1c*qB0KpY R}>Ul+B0Vmr}Z@+%mJ|As}sis_=ROPbov@*2thpE&?!V#Qgu$snYvCZ zrkhmkMU+fSf-s8(L37 fPr&M*jRs{{THb!aXQu|P9l_-vJhHvLzMGH zE?1U0H_+Pm NABp9`|KzkGfrrZ%XvdGo6*<{d5m9~L7 z_ ^`M;X6xDo=m6LY6RfvJEvsTK1!u8d2HPx| $S}p;sRy!I zWL55Yxu~_B`OP@~(q6&W3#)~I&+MGL%GWR$#udC151^wsswhqlii;rP9jJpiI7o&Z zAb})=HY7?4HA|re3ns`%$)FuvKCFWjhb~?IE)F6dF2K5}poj-NK6Gf;hw$t3=1txY zoxQxZWrQU6K!%|~!m?~Bnw-6Rr!F3BZ{u5!LqnZTDON}Coj9^@&le)V!NYrVwS~B% zEL+>Sr@}qGwGvu|HrOo|gSt__ezN^&%~{*)a=rf7y1HujUcr`zZB<4#l@T#eN)si} z)lZA<{=tKx8E%c9>A(##6 }_p+~EZpKsl5a4pj`E*;_-6`ysiv zffA!7=MT1vCz}-m4~tjVey1b2KSR4OEtLd-(_DdUqYZ74LaDkhH?KFh?%WAOP2WbX zp@zT+Dx|5_f%JQiAGvVw!oh +g3e50u!aPfMxdC=E)XB{F5IcEZhePIM- zph6Y`$Oy?JBL<8Ex(SqEhLeQ@XcrdA>a?rx+_~HLA;l14)WmmpH}_w?Pg#HBZs0eS zwypwAW?M-x+3AU-(GGWSJ=ngxUEcEZ5OsX(Qlt !MQ zn^(`S{GHkAv(8@D`EAfSYig%Cxv?z!{=w^F#y)5_d7FuKZH7qlR-#5B0bt806%D0I zT7VdVP_?q*%Rq8UR;JkD4i^RXowt+E%#V2U>TfDqzZSDZ+dR!a#T3I>-z_$q9@k|m zy5~A*m~&JWP@E7a=pc}4kVHTc4h&R;Li7d@f`|hKMLkbb^uhOakNr3&FLj lm~i5NBM< zFaYI{;cpiHCNRdE0dg*>qIm(_t?#$h=(SCw?h3rJV2*ER8{O4^3#=dO)KwklZkoqU zS8i5c%YL*y*4;FY#D=XmkQnYj%LH)?02~gSJH`Qp1XY64g>%c_K$xseI&|e)7vRoL zAqRba$G@%fSGA7X7hQk%_3NVOYVS+$leU_!&6*5uN)8#5ZBz_6ASCA;azYS-Rt@ki zg2NWz(=;t}SC(~Ibl63$5C8FPmhXqb^)5#jaJ~I{Ex3xZ!+2h8$}}h_g@Be>HZ;72 z6#y#>AY3^skuVKF#0WxFBQ()5d5_nWb?c6c>EeMM|Mh+*&wEpPyxHCq{R-Gdr-`hN zF=1sxl&mBoK+#qRLl9#CEN|Fg8>nbmsTg3a1;#M9enQ$RgWk}kp#-5wh=EF&1tl%mJln2V^8o%Qv(*=zEuO7y z=m*8?xpUn-*@h5Cl_3BK3joiGkyaScK+>|MWdMRWm@RT!Q1piAlv5hL@B6>3&GI8) zP!xBc6}ZNIpJLL%2a8Y!+(<=f%WX>_uWVxlga9!D*oYt$l0cxRDMvqfU;Kq_mLK5k z)dvqYcgLa_Lz?3HyeF)@$%$&6lI?r4I>6W#M*<)vq{?& Oqrx``d`mhpVPr> z#q078F6gw_X<=?KR>8%^t%@wbITvNMu!hKiTSkCTJkw>1!e*Y{%31#_yMf=LW7{RJ zYoC^w$6%3cBtVG5)x#{Hg6IVTh9XEcM{gQwXk!R^y95^f-hZ`d{aVa+xW1EO4wDV4 zB?JgD7*?qkvc|$nIykTvNl2x0j3Q!MXoLL^)~}d7jcYf(H8D~c+?$pKL(px>Z3`eb z04RzS6_AgFT6Pn#iZAg$Sl_j8#;6ShF%&(Fag#E2asU@@LaN;=b=Wf7sgPKhfzhBM zC@eFL8^MrnA*9&Khe*Ab@CC9*uyJGXyi(;y2>lQLJZt;ShtJi?3Yf_t `F+$hY!+Q2Ndsx=U+bjTiAy7djLji>7k%k`$9&--f<*BNA3Hy&ZrHH|4 zG5H&9cB?O#zI1_OOf0Ce%mDfQxdtp3vU%(iY6yji3iISS61XLv#z|!zI_sZqza@B+ zyu9st5-h+`H7QUKx9}3w@oU@EO}&cEzG?fu!!bLO->%zkcg;i9^j`S~=WKMnDi1f= P00000NkvXXu0mjft=yBf literal 0 HcmV?d00001 diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/components/ChatWindow.tsx b/frontend/src/components/ChatWindow.tsx new file mode 100644 index 0000000..c3ba106 --- /dev/null +++ b/frontend/src/components/ChatWindow.tsx @@ -0,0 +1,468 @@ +import React, { useEffect, useRef, useState, useCallback, useMemo, memo } from 'react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import remarkMath from 'remark-math'; +import rehypeKatex from 'rehype-katex'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import { + Copy, Check, RefreshCw, Edit2, Trash2, Bookmark, Share2, ThumbsUp, ThumbsDown, + Pin, Brain, ChevronDown, ChevronRight +} from 'lucide-react'; +import { clsx } from 'clsx'; +import type { Message } from '../types'; +import { copyToClipboard, formatTimestamp, formatTime } from '../lib/utils'; +import { parseAssistantContent } from '../lib/messageContent'; +import { useApp } from '../store/AppContext'; + +interface ChatWindowProps { + messages: Message[]; + isGenerating: boolean; + onRegenerate: (messageId: string) => void; + onRetry: (messageId: string) => void; + onEditSubmit: (messageId: string, newContent: string) => void; + onDelete: (messageId: string) => void; + chatId: string; +} + +export const ChatWindow = memo(function ChatWindow({ + messages, + isGenerating, + onRegenerate, + onRetry, + onEditSubmit, + onDelete, + chatId, +}: ChatWindowProps) { + const scrollRef = useRef (null); + const bottomRef = useRef (null); + const autoScrollRef = useRef(true); + + const onScroll = useCallback(() => { + if (!scrollRef.current) return; + const { scrollTop, scrollHeight, clientHeight } = scrollRef.current; + autoScrollRef.current = scrollHeight - scrollTop - clientHeight < 150; + }, []); + + useEffect(() => { + if (autoScrollRef.current && scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [messages]); + + const scrollToBottom = () => { + scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' }); + }; + + return ( + ++ ); +}); + +function EmptyState() { + return ( ++ {messages.length === 0 &&+ + {!autoScrollRef.current && messages.length > 0 && ( + + )} +} + {messages.map((msg, idx) => ( + onRegenerate(msg.id)} + onRetry={() => onRetry(msg.id)} + onEditSubmit={(newContent) => onEditSubmit(msg.id, newContent)} + onDelete={() => onDelete(msg.id)} + chatId={chatId} + /> + ))} + + ++ ); +} + +interface MessageBubbleProps { + message: Message; + isLast: boolean; + isGenerating: boolean; + onRegenerate: () => void; + onRetry: () => void; + onEditSubmit: (content: string) => void; + onDelete: () => void; + chatId: string; +} + +function MessageBubble({ message, isLast, isGenerating, onRegenerate, onRetry, onEditSubmit, onDelete, chatId }: MessageBubbleProps) { + const { notify, pinChat } = useApp(); + const [showActions, setShowActions] = useState(false); + const [copied, setCopied] = useState(false); + const [editing, setEditing] = useState(false); + const isExplicitJson = message.role === 'assistant' && message.renderMode === 'json'; + const contentSegments = useMemo(() => { + if (message.role !== 'assistant' || isExplicitJson) { + return [{ type: 'text' as const, text: message.content }]; + } + return parseAssistantContent(message.content); + }, [message.content, message.role, isExplicitJson]); + const visibleContent = useMemo(() => { + if (message.role !== 'assistant' || isExplicitJson) return message.content; + return contentSegments + .filter((segment): segment is Extract+ O ++No messages yet. Start a conversation!
+=> segment.type === 'text' && Boolean(segment.text.trim())) + .map(segment => segment.text.trim()) + .join('\n\n'); + }, [contentSegments, message.content, message.role, isExplicitJson]); + const [editInput, setEditInput] = useState(visibleContent); + const [showReasoning, setShowReasoning] = useState(false); + const [bookmarked, setBookmarked] = useState(false); + const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); + + const onCopy = async () => { + const ok = await copyToClipboard(visibleContent); + if (ok) { + setCopied(true); + notify('success', 'Copied to clipboard'); + setTimeout(() => setCopied(false), 2000); + } + }; + + const onContextMenu = (e: React.MouseEvent) => { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY }); + }; + + const onShare = async () => { + try { + if (navigator.share) { + await navigator.share({ title: 'OmniAI Chat', text: visibleContent }); + } else { + await copyToClipboard(visibleContent); + notify('info', 'Link copied to clipboard'); + } + } catch { /* ignore */ } + }; + + const reasoningContent = (message as any).reasoning || ''; + + return ( + setShowActions(true)} + onMouseLeave={() => setShowActions(false)} + onContextMenu={onContextMenu} + > + {message.role === 'assistant' && ( ++ + {message.role === 'user' && ( ++ O ++ )} + +++ ++ {message.role === 'assistant' ? 'OmniAI' : 'You'} + · + {formatTimestamp(message.timestamp)} · {formatTime(message.timestamp)} + {message.edited && (edited)} ++ ++ {reasoningContent && ( ++ )} + + {message.tokenUsage && ( ++ + {showReasoning && ( ++ )} + + {editing ? ( ++ {reasoningContent} ++ )} +++ ) : ( ++ {isExplicitJson ? ( ++ ) : ( ++ {message.content} ++ ) : ( + visibleContent ? ( ++ + +++ + {children} ++ ); + }, + p(props: any) { + return{props.children}
; + }, + }} + > + {visibleContent} + + ) : null + )} + {isGenerating &&} + + Tokens: {message.tokenUsage.totalTokens.toLocaleString()} + Prompt: {message.tokenUsage.promptTokens.toLocaleString()} + Completion: {message.tokenUsage.completionTokens.toLocaleString()} ++ )} +++setEditing(true)} + onDelete={onDelete} + onShare={onShare} + bookmarked={bookmarked} + onBookmark={() => { setBookmarked(b => !b); notify(!bookmarked ? 'success' : 'info', !bookmarked ? 'Bookmarked' : 'Removed bookmark'); }} + onPin={() => { pinChat(chatId); notify('info', 'Toggled pin'); }} + /> + + )} + + {contextMenu && ( + setContextMenu(null)} + items={[ + { icon: , label: 'Copy', onClick: onCopy }, + ...(message.role === 'user' ? [{ icon: , label: 'Edit', onClick: () => setEditing(true) }] : []), + ...(message.role === 'assistant' ? [{ icon: , label: 'Regenerate', onClick: onRegenerate }] : []), + { icon: , label: 'Delete', onClick: onDelete, danger: true }, + ]} + /> + )} + + ); +} + +function ActionBar({ + message, isGenerating, onCopy, copied, onRegenerate, onRetry, onEdit, onDelete, onShare, bookmarked, onBookmark, onPin +}: any) { + return ( + ++ ); +} + +function IconBtn({ children, onClick, label, danger }: { children: React.ReactNode; onClick: () => void; label: string; danger?: boolean }) { + return ( + + ); +} + +function TypingIndicator() { + return ( + + + + + + ); +} + +function UserAvatar() { + const { profile } = useApp(); + const initials = profile.name.split(' ').map(n => n[0]).filter(Boolean).slice(0, 2).join('').toUpperCase(); + return ( ++ {copied ? + {message.role === 'assistant' && !isGenerating && ( + <> +: } + + ++ + + ++ + > + )} + {message.role === 'user' && ( ++ + + )} ++ + ++ + + ++ {}}> + ++ {}}> + + ++ + ++ + {profile.avatar ? ( ++ ); +} + +function ContextMenuOverlay({ x, y, items, onClose }: { x: number; y: number; items: Array<{ icon?: React.ReactNode; label: string; onClick: () => void; danger?: boolean }>; onClose: () => void }) { + return ( + <> + ++ ) : initials || 'U'} +
+ {items.map((item, i) => ( + + ))} ++ > + ); +} + +export type { Message }; diff --git a/frontend/src/components/EmptyChat.tsx b/frontend/src/components/EmptyChat.tsx new file mode 100644 index 0000000..9b5a545 --- /dev/null +++ b/frontend/src/components/EmptyChat.tsx @@ -0,0 +1,111 @@ +import { + Code2, Lightbulb, FileText, Terminal, BookOpen, Database, + Calculator, Palette, Briefcase, Send, Heart +} from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; +import { clsx } from 'clsx'; + +interface Props { + onPrompt?: (prompt: string) => void; +} + +const suggestions: Array<{ icon: LucideIcon; title: string; prompt: string; tag?: string }> = [ + { icon: Code2, title: 'Build a React dashboard', prompt: 'Build a beautiful React dashboard UI with charts, stats cards, and a responsive sidebar. Use Tailwind CSS, TypeScript, and lucide-react icons.' }, + { icon: Lightbulb, title: 'Explain quantum computing', prompt: 'Explain quantum computing in simple terms. Compare qubits to classical bits, explain superposition and entanglement with real-world analogies, and list practical applications today.' }, + { icon: Terminal, title: 'Generate Python code', prompt: 'Write a Python script that downloads all images from a given URL, with progress reporting, error handling, and support for retries. Use requests and BeautifulSoup.' }, + { icon: FileText, title: 'Write a blog article', prompt: 'Write a blog article titled "10 Habits of Highly Effective Developers". Make it engaging, with personal anecdotes, actionable tips, and an inspiring conclusion. ~1000 words.' }, + { icon: BookOpen, title: 'Summarize a PDF', prompt: 'I will paste text from a PDF. Summarize it with: key ideas in bullet points, a 3-sentence TL;DR, and 3 critical questions for deeper discussion. First wait for me to paste the content.' }, + { icon: Database, title: 'Create SQL queries', prompt: 'You are a SQL expert. I will describe a schema and a question. Write an optimized PostgreSQL query, explain the plan, and suggest indexes if useful.' }, + { icon: Calculator, title: 'Solve math problems', prompt: 'Act as a patient math tutor. Walk me through problems step-by-step, explain the reasoning, check for common mistakes, and use LaTeX for formulas when helpful.' }, + { icon: Palette, title: 'Design critique', prompt: 'Act as a senior UI/UX designer. I will describe a screen or paste a wireframe. Give specific, actionable feedback on layout, hierarchy, color, accessibility, and micro-interactions.' }, + { icon: Briefcase, title: 'Interview prep', prompt: 'You are my interview coach for a Senior Full-Stack Engineer role. Ask me a mix of system design, algorithm, behavioral, and domain questions — then grade my answers thoroughly.' }, +]; + +export function EmptyChat({ onPrompt }: Props) { + return ( +++ ); +} + +export function WelcomeScreen({ onStart }: { onStart: () => void }) { + return ( ++++ ++ ++ O +++ What would you like to accomplish today? +
++ Start with a prompt below, or describe your own idea. OmniAI streams responses in real time. +
+ ++ {suggestions.map((item, index) => ( + + ))} ++ ++++ Built with Gemini · Streaming · Markdown · Voice + + ++ ); +} diff --git a/frontend/src/components/LoadingScreen.tsx b/frontend/src/components/LoadingScreen.tsx new file mode 100644 index 0000000..70f9bee --- /dev/null +++ b/frontend/src/components/LoadingScreen.tsx @@ -0,0 +1,100 @@ +import { WifiOff, RefreshCw, ServerCog } from 'lucide-react'; +import { useApp } from '../store/AppContext'; + +interface Props { + onRetry: () => void; + retryCount: number; + nextRetrySec: number; +} + +export function BackendOfflineScreen({ onRetry, retryCount, nextRetrySec }: Props) { + const { notify } = useApp(); + return ( ++++ O +++ OmniAI +
++ One Platform. Every AI. +
++ Choose the best AI model for every task—from coding and research to writing and brainstorming—all in one elegant workspace. +
++ + ++++ ); +} + +export function SkeletonMessages() { + return ( ++++ +++++++ Cannot reach backend
++ OmniAI couldn't connect to the Gemini API server at
+http://localhost:8081/v1. + Make sure the backend is running, or wait for auto-reconnect. ++ + +++ Setup backend docs + + +++ Auto-reconnect + + {nextRetrySec > 0 ? `Retrying in ${nextRetrySec}s` : 'Retrying now...'} + +++ +++ Attempt {retryCount + 1} · Backoff: {(Math.min(retryCount * 2, 15) || 2)}s +++ Tip: Start the backend with+python -m gemini_web2apiin the backend folder. ++ {[0, 1].map(i => ( ++ ); +} + +export function NewChatSkeleton() { + return ( ++ ++ ))} ++ + + + +++ + ++ ); +} diff --git a/frontend/src/components/MessageInput.tsx b/frontend/src/components/MessageInput.tsx new file mode 100644 index 0000000..4de20a5 --- /dev/null +++ b/frontend/src/components/MessageInput.tsx @@ -0,0 +1,324 @@ +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import { Paperclip, Mic, MicOff, Send, Zap, X, FileText, Image, File, Upload } from 'lucide-react'; +import { clsx } from 'clsx'; +import type { UploadedFile } from '../types'; +import { cn, generateId, formatFileSize, copyToClipboard } from '../lib/utils'; +import { useApp } from '../store/AppContext'; + +interface MessageInputProps { + onSend: (message: string, files: UploadedFile[]) => void; + isGenerating: boolean; + onStop: () => void; + modelName: string; + disabled?: boolean; + autoFocus?: boolean; +} + +const MAX_FILE_SIZE = 25 * 1024 * 1024; +const ACCEPTED_IMAGES = /^image\/(png|jpeg|jpg|gif|webp|bmp)$/i; + +export function MessageInput({ onSend, isGenerating, onStop, modelName, disabled, autoFocus }: MessageInputProps) { + const { notify, settings } = useApp(); + const [input, setInput] = useState(''); + const [files, setFiles] = useState+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} ++([]); + const [isDragging, setIsDragging] = useState(false); + const [isListening, setIsListening] = useState(false); + const textareaRef = useRef (null); + const fileInputRef = useRef (null); + const recognitionRef = useRef (null); + + useEffect(() => { + if (autoFocus) { + setTimeout(() => textareaRef.current?.focus(), 100); + } + }, [autoFocus]); + + useEffect(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = 'auto'; + const target = Math.min(Math.max(el.scrollHeight, 60), 280); + el.style.height = `${target}px`; + }, [input]); + + const handleSend = useCallback(() => { + const trimmed = input.trim(); + if ((!trimmed && files.length === 0) || isGenerating || disabled) return; + const hasBinary = files.some(f => ACCEPTED_IMAGES.test(f.type) || !['txt', 'json', 'csv'].includes((f.type.split('/').pop() || ''))); + if (hasBinary && files.length > 0) { + notify('info', 'File attached', 'File content will be sent as text if readable; binary files will be referenced by name. The local backend may not process these yet.'); + } + onSend(trimmed, files); + setInput(''); + setFiles([]); + }, [input, files, isGenerating, disabled, onSend, notify]); + + const handleKeyDown = (e: React.KeyboardEvent ) => { + if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { + e.preventDefault(); + handleSend(); + } + }; + + const addFiles = useCallback((fileList: FileList | File[]) => { + const items = Array.from(fileList); + const added: UploadedFile[] = []; + for (const f of items) { + if (f.size > MAX_FILE_SIZE) { + notify('error', 'File too large', `${f.name} exceeds ${formatFileSize(MAX_FILE_SIZE)} limit`); + continue; + } + const isText = f.type.startsWith('text/') || f.name.match(/\.(txt|md|json|csv|log|py|js|ts|tsx|jsx|html|css)$/i); + const uf: UploadedFile = { + id: generateId(), + name: f.name, + size: f.size, + type: f.type, + progress: isText ? 0 : 100, + }; + if (isText) { + const reader = new FileReader(); + reader.onprogress = (e) => { + if (e.lengthComputable) { + setFiles(prev => prev.map(x => x.id === uf.id ? { ...x, progress: Math.round((e.loaded / e.total) * 100) } : x)); + } + }; + reader.onload = () => { + const text = String(reader.result || ''); + setFiles(prev => prev.map(x => x.id === uf.id ? { ...x, progress: 100, data: text.slice(0, 80000) } : x)); + }; + reader.onerror = () => { + setFiles(prev => prev.map(x => x.id === uf.id ? { ...x, progress: 100, error: 'Failed to read' } : x)); + }; + reader.readAsText(f); + } else if (ACCEPTED_IMAGES.test(f.type)) { + const reader = new FileReader(); + reader.onprogress = (e) => { + if (e.lengthComputable) { + setFiles(prev => prev.map(x => x.id === uf.id ? { ...x, progress: Math.round((e.loaded / e.total) * 100) } : x)); + } + }; + reader.onload = () => { + setFiles(prev => prev.map(x => x.id === uf.id ? { ...x, progress: 100, data: String(reader.result || '') } : x)); + }; + reader.readAsDataURL(f); + } + added.push(uf); + } + if (added.length) { + setFiles(prev => [...prev, ...added]); + notify('success', added.length === 1 ? 'File attached' : `${added.length} files attached`); + } + }, [notify]); + + const onDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + if (e.dataTransfer.files?.length) addFiles(e.dataTransfer.files); + }; + + const toggleVoice = () => { + const SpeechRec = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition; + if (!SpeechRec) { + notify('warning', 'Speech recognition not supported', 'Your browser does not support the Web Speech API.'); + return; + } + if (isListening) { + recognitionRef.current?.stop(); + return; + } + try { + const rec = new SpeechRec(); + rec.lang = settings.language === 'zh' ? 'zh-CN' : 'en-US'; + rec.continuous = false; + rec.interimResults = true; + rec.onstart = () => setIsListening(true); + rec.onresult = (e: any) => { + let finalText = ''; + let interimText = ''; + for (let i = e.resultIndex; i < e.results.length; i++) { + const res = e.results[i]; + if (res.isFinal) finalText += res[0].transcript; + else interimText += res[0].transcript; + } + setInput(prev => prev ? `${prev} ${finalText || interimText}`.trim() : (finalText || interimText)); + if (finalText) rec.stop(); + }; + rec.onerror = (e: any) => { + console.warn('Speech error', e); + if (e.error === 'not-allowed') notify('error', 'Microphone permission denied'); + setIsListening(false); + }; + rec.onend = () => setIsListening(false); + recognitionRef.current = rec; + rec.start(); + } catch (err: any) { + notify('error', 'Voice error', err?.message || 'Unable to start microphone'); + } + }; + + const onPaste = (e: React.ClipboardEvent) => { + if (e.clipboardData.files?.length) { + addFiles(e.clipboardData.files); + } + }; + + const removeFile = (id: string) => { + setFiles(prev => prev.filter(f => f.id !== id)); + }; + + return ( + ++ ); +} + +function FileChip({ file, onRemove }: { file: UploadedFile; onRemove: () => void }) { + const { notify } = useApp(); + const isImg = ACCEPTED_IMAGES.test(file.type); + const Icon = isImg ? Image : (file.name.match(/\.pdf$/i) ? FileText : File); + const done = file.progress === 100; + + return ( +{ e.preventDefault(); setIsDragging(true); }} + onDragLeave={() => setIsDragging(false)} + onDrop={onDrop} + className={clsx( + 'relative flex flex-col w-full bg-[var(--card)] border rounded-2xl shadow-lg focus-within:border-primary/50 focus-within:ring-1 focus-within:ring-primary/30 transition-all', + isDragging ? 'border-primary ring-2 ring-primary/30' : 'border-[var(--border)]' + )} + > + {isDragging && ( ++++ )} + + {files.length > 0 && ( ++++ Drop files here++ {files.map(f =>+ )} + +removeFile(f.id)} />)} + + OmniAI can make mistakes. Verify important information. · Enter send · Shift+Enter newline +++ {isImg && done && file.data ? ( ++ ); +} diff --git a/frontend/src/components/ModelSelector.tsx b/frontend/src/components/ModelSelector.tsx new file mode 100644 index 0000000..cbfea72 --- /dev/null +++ b/frontend/src/components/ModelSelector.tsx @@ -0,0 +1,254 @@ +import { useState, useRef, useEffect } from 'react'; +import { ChevronDown, Zap, Sparkles, Code2, Brain, Check, Lock } from 'lucide-react'; +import clsx from 'clsx'; + +export type Model = { + id: string; + name: string; + provider: string; + description: string; + speed: number; + reasoning: number; + coding: number; + badge?: string; + disabled?: boolean; +}; + +const models: Model[] = [ + { + id: 'gemini-3.6-flash', + name: 'Gemini 3.6 Flash', + provider: 'Google', + description: 'Next-gen fast model for general tasks', + speed: 98, + reasoning: 85, + coding: 88, + badge: 'New' + }, + { + id: 'gemini-3.5-flash', + name: 'Gemini 3.5 Flash', + provider: 'Google', + description: 'Fast and versatile model', + speed: 95, + reasoning: 80, + coding: 82 + }, + { + id: 'gemini-3.5-flash-thinking', + name: 'Gemini Flash Thinking', + provider: 'Google', + description: 'Advanced reasoning and problem solving', + speed: 80, + reasoning: 98, + coding: 95, + badge: 'Default' + }, + { + id: 'gemini-flash-lite', + name: 'Gemini Flash Lite', + provider: 'Google', + description: 'Ultra-fast lightweight responses', + speed: 100, + reasoning: 70, + coding: 75 + }, + { + id: 'gemini-auto', + name: 'Gemini Auto', + provider: 'Google', + description: 'Automatically routes to the best model', + speed: 90, + reasoning: 90, + coding: 90, + badge: 'Smart' + }, + // Future Models + { + id: 'gpt-4o', + name: 'GPT-4o', + provider: 'OpenAI', + description: 'Powerful multimodal capabilities', + speed: 85, + reasoning: 95, + coding: 96, + disabled: true + }, + { + id: 'claude-3-5-sonnet', + name: 'Claude 3.5 Sonnet', + provider: 'Anthropic', + description: 'Exceptional coding and writing', + speed: 90, + reasoning: 96, + coding: 98, + disabled: true + }, + { + id: 'deepseek-coder', + name: 'DeepSeek Coder', + provider: 'DeepSeek', + description: 'Specialized for development', + speed: 88, + reasoning: 85, + coding: 99, + disabled: true + }, + { + id: 'qwen-max', + name: 'Qwen Max', + provider: 'Alibaba', + description: 'Top-tier open weights model', + speed: 85, + reasoning: 90, + coding: 88, + disabled: true + }, + { + id: 'mistral-large', + name: 'Mistral Large', + provider: 'Mistral AI', + description: 'Top-tier reasoning and logic', + speed: 85, + reasoning: 92, + coding: 89, + disabled: true + }, + { + id: 'llama-3', + name: 'Llama 3', + provider: 'Meta', + description: 'Fast open source model', + speed: 92, + reasoning: 85, + coding: 85, + disabled: true + } +]; + +export function ModelSelector({ + currentModelId, + onSelect +}: { + currentModelId: string, + onSelect: (id: string) => void +}) { + const [isOpen, setIsOpen] = useState(false); + const currentModel = models.find(m => m.id === currentModelId) || models[2]; + const ref = useRef+ ) : ( +
+ )} + ++ {file.data && !isImg && ( + + )} + +{file.name}++ {file.error ? file.error : (!done ? `Processing ${file.progress}%` : formatFileSize(file.size))} ++ {!done && ( ++ ++ )} +(null); + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (ref.current && !ref.current.contains(event.target as Node)) { + setIsOpen(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + return ( + + + + {isOpen && ( ++ ); +} + +function ModelItem({ model, isActive, onClick }: { model: Model, isActive: boolean, onClick?: () => void }) { + return ( + + ); +} + +function Stat({ icon, label, value }: { icon: React.ReactNode, label: string, value: number }) { + return ( +++ )} +++Available Models+ {models.filter(m => !m.disabled).map(model => ( +{ + onSelect(model.id); + setIsOpen(false); + }} + /> + ))} + + Coming Soon+ {models.filter(m => m.disabled).map(model => ( ++ ))} + + {icon} +++ ); +} diff --git a/frontend/src/components/SettingsDialogs.tsx b/frontend/src/components/SettingsDialogs.tsx new file mode 100644 index 0000000..0c3b334 --- /dev/null +++ b/frontend/src/components/SettingsDialogs.tsx @@ -0,0 +1,359 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { User, Mail, Upload, LogOut, Save, Palette, Globe, Clock, Gauge, MessageSquarePlus, Volume2, Bell as BellIcon, Keyboard, Wand2 } from 'lucide-react'; +import { clsx } from 'clsx'; +import type { Profile, Settings as SettingsType } from '../types'; +import { Modal } from './ui/Modal'; +import { useApp } from '../store/AppContext'; +import { getInitials } from '../lib/utils'; + +export function ProfileDialog({ open, onClose }: { open: boolean; onClose: () => void }) { + const { profile, dispatch, notify } = useApp(); + const [draft, setDraft] = useState= 90 ? "bg-success" : value >= 80 ? "bg-accent" : "bg-warning" + )} + style={{ width: `${value}%` }} + /> ++(profile); + const fileRef = useRef (null); + + useEffect(() => { setDraft(profile); }, [open, profile]); + + const onSave = () => { + if (!draft.name.trim()) { notify('error', 'Name required'); return; } + dispatch({ type: 'UPDATE_PROFILE', payload: draft }); + notify('success', 'Profile saved'); + onClose(); + }; + + const onAvatarFile = (e: React.ChangeEvent ) => { + const f = e.target.files?.[0]; + if (!f) return; + if (f.size > 2 * 1024 * 1024) { notify('error', 'Avatar too large (< 2MB)'); return; } + const r = new FileReader(); + r.onload = () => setDraft(d => ({ ...d, avatar: String(r.result || '') })); + r.readAsDataURL(f); + }; + + return ( + + + ); +} + +export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () => void }) { + const { settings, dispatch, notify } = useApp(); + const [draft, setDraft] = useState++++ ++++ {draft.avatar ?+ + +: getInitials(draft.name)} +
++{draft.name || 'Your Name'}+{draft.email}+++ +}> + setDraft(d => ({ ...d, name: e.target.value }))} + className="w-full bg-transparent outline-none text-[var(--foreground)]" + placeholder="Your name" + /> + + + }> + setDraft(d => ({ ...d, email: e.target.value }))} + className="w-full bg-transparent outline-none text-[var(--foreground)]" + placeholder="you@example.com" + /> + + + + + ++(settings); + const [tab, setTab] = useState<'general' | 'model' | 'notifications' | 'shortcuts'>('general'); + + useEffect(() => { setDraft(settings); }, [open, settings]); + + const onSave = () => { + dispatch({ type: 'UPDATE_SETTINGS', payload: draft }); + notify('success', 'Settings saved'); + onClose(); + }; + + const set = (k: K, v: SettingsType[K]) => setDraft(d => ({ ...d, [k]: v })); + const setDefaults = (k: K, v: SettingsType['modelDefaults'][K]) => + setDraft(d => ({ ...d, modelDefaults: { ...d.modelDefaults, [k]: v } })); + + const tabs = [ + { id: 'general' as const, label: 'General', icon: }, + { id: 'model' as const, label: 'Model', icon: }, + { id: 'notifications' as const, label: 'Alerts', icon: }, + { id: 'shortcuts' as const, label: 'Shortcuts', icon: }, + ]; + + return ( + + + ); +} + +function Field({ label, icon, children }: { label: string; icon: React.ReactNode; children: React.ReactNode }) { + return ( + + ); +} + +function Toggle({ label, hint, value, onChange, disabled, icon }: { label: string; hint?: string; value: boolean; onChange: (v: boolean) => void; disabled?: boolean; icon?: React.ReactNode }) { + return ( ++++ {tabs.map(t => ( + + ))} +++ {tab === 'general' && ( + <> ++++ ++
Appearance ++++Theme++ {([ + { id: 'light' as const, label: 'Light', icon:+}, + { id: 'dark' as const, label: 'Dark', icon: }, + { id: 'system' as const, label: 'System', icon: }, + ]).map(opt => ( + + ))} + set('animations', v)} /> + set('sidebarCollapsed', v)} /> + ++ > + )} + + {tab === 'model' && ( + <> ++
Localization +++++
Default Model setDefaults('model', v)} + icon={ } + options={[ + { v: 'gemini-3.6-flash', l: 'Gemini 3.6 Flash' }, + { v: 'gemini-3.5-flash', l: 'Gemini 3.5 Flash' }, + { v: 'gemini-3.5-flash-thinking', l: 'Gemini Flash Thinking' }, + { v: 'gemini-flash-lite', l: 'Gemini Flash Lite' }, + { v: 'gemini-auto', l: 'Gemini Auto' }, + ]} + /> + ++ > + )} + + {tab === 'notifications' && ( + <> ++
Generation Parameters ++setDefaults('temperature', v)} /> + setDefaults('topP', v)} /> + setDefaults('maxTokens', v)} intOnly /> + set('streaming', v)} /> + ++ > + )} + + {tab === 'shortcuts' && ( + <> ++
Notifications ++set('notifications', { ...draft.notifications, enabled: v })} /> + set('notifications', { ...draft.notifications, sound: v })} disabled={!draft.notifications.enabled} icon={ } /> + { + if (v && 'Notification' in window) { Notification.requestPermission().then(p => notify('info', p === 'granted' ? 'Notifications enabled' : 'Permission denied')); } + set('notifications', { ...draft.notifications, desktop: v }); + }} disabled={!draft.notifications.enabled} /> + ++ > + )} ++
Keyboard Shortcuts + {[ + { k: 'newChat', label: 'New Chat', hint: 'Start a new conversation' }, + { k: 'search', label: 'Search Chats', hint: 'Open search dialog' }, + { k: 'toggleSidebar', label: 'Toggle Sidebar', hint: 'Collapse or expand' }, + { k: 'closeDialog', label: 'Close Dialogs', hint: 'Dismiss any open modal' }, + ].map(s => ( ++++ ))} +++{s.label}+{s.hint}++ {draft.shortcuts[s.k as keyof typeof draft.shortcuts].split('+').map((p, i) => ( +++ {i > 0 && +} + {p} + + ))} ++ + ++++ ); +} + +function Select({ label, value, onChange, options, icon }: { label: string; value: string; onChange: (v: string) => void; options: Array<{ v: string; l: string }>; icon?: React.ReactNode }) { + return ( + + ); +} + +function Slider({ label, hint, min, max, step, value, onChange, intOnly }: { label: string; hint?: string; min: number; max: number; step: number; value: number; onChange: (v: number) => void; intOnly?: boolean }) { + return ( ++ {icon &&+ +{icon}} +++{label}+ {hint &&{hint}} +++ ); +} + +function SunIcon() { return ; } +function MoonIcon() { return ; } +function MonitorIcon() { return ; } diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx new file mode 100644 index 0000000..355c823 --- /dev/null +++ b/frontend/src/components/Sidebar.tsx @@ -0,0 +1,709 @@ +import React, { useState, useMemo, useRef, useEffect } from 'react'; +import { + MessageSquarePlus, Search, Clock, Pin, Folder, FolderPlus, Settings, User, + PanelLeftClose, PanelLeftOpen, ChevronDown, ChevronRight, Trash2, Edit2, + MoreHorizontal, X, Filter, Star, Menu +} from 'lucide-react'; +import { clsx } from 'clsx'; +import type { Conversation, Folder as FolderType } from '../types'; +import { generateId, formatTimestamp, getInitials, highlightText, truncate } from '../lib/utils'; +import { useApp } from '../store/AppContext'; +import { Modal, ConfirmDialog } from './ui/Modal'; + +export function Sidebar() { + const { + conversations, folders, pinnedIds, currentChatId, sidebarCollapsed, + sidebarMobileOpen, profile, selectedModel, + dispatch, createNewChat, selectChat, deleteChat, pinChat, notify, openDialog, closeDialogs, dialog + } = useApp(); + + const [search] = useState(''); + const [folderMenu, setFolderMenu] = useState++ onChange(Number(e.target.value))} + className="w-full accent-primary" + /> +++{label}+ {hint &&{hint}} ++ {intOnly ? Math.round(value) : value.toFixed(2)} +++ {intOnly ? min : min.toFixed(1)} + {intOnly ? max : max.toFixed(1)} ++(null); + const [chatMenu, setChatMenu] = useState<{ id: string; x: number; y: number } | null>(null); + const [draggedChat, setDraggedChat] = useState (null); + const [dragOver, setDragOver] = useState (null); + const searchRef = useRef (null); + + useEffect(() => { + if (dialog.search) searchRef.current?.focus(); + }, [dialog.search]); + + const sortedPinned = useMemo(() => + pinnedIds.map(id => conversations.find(c => c.id === id)).filter(Boolean) as Conversation[], + [pinnedIds, conversations] + ); + + const sortedRecent = useMemo(() => { + const recent = conversations + .filter(c => !pinnedIds.includes(c.id)) + .sort((a, b) => b.updatedAt - a.updatedAt); + if (!search.trim()) return recent.slice(0, 30); + const q = search.toLowerCase(); + return recent.filter(c => + c.title.toLowerCase().includes(q) || + c.messages.some(m => m.content.toLowerCase().includes(q)) || + (c.tags || []).some(t => t.toLowerCase().includes(q)) + ).slice(0, 50); + }, [conversations, pinnedIds, search]); + + const chatsByFolder = useMemo(() => { + const map = new Map (); + for (const f of folders) map.set(f.id, []); + map.set(null, []); + for (const c of conversations) { + if (pinnedIds.includes(c.id)) continue; + const bucket = c.folderId || null; + if (map.has(bucket)) map.get(bucket)!.push(c); + } + return map; + }, [conversations, folders, pinnedIds]); + + const isMobile = typeof window !== 'undefined' && window.matchMedia('(max-width: 768px)').matches; + + const handleNewChat = () => { + createNewChat(selectedModel); + if (isMobile) dispatch({ type: 'TOGGLE_MOBILE_SIDEBAR', payload: false }); + notify('success', 'New chat created'); + }; + + const onToggleFolder = (id: string) => { + const folder = folders.find(f => f.id === id); + if (folder) dispatch({ type: 'UPDATE_FOLDER', payload: { id, updates: { expanded: !folder.expanded } } }); + }; + + const onDeleteFolder = (id: string) => { + dispatch({ type: 'DELETE_FOLDER', payload: id }); + notify('info', 'Folder deleted'); + setFolderMenu(null); + }; + + const onRenameFolder = (id: string, name: string) => { + dispatch({ type: 'UPDATE_FOLDER', payload: { id, updates: { name } } }); + notify('success', 'Folder renamed'); + setFolderMenu(null); + }; + + const addFolder = (name: string) => { + const colors = ['#2563EB', '#22C55E', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4']; + dispatch({ + type: 'ADD_FOLDER', + payload: { id: generateId(), name, color: colors[Math.floor(Math.random() * colors.length)], expanded: true, createdAt: Date.now() } + }); + notify('success', `Folder "${name}" created`); + closeDialogs(); + }; + + const onChatDropToFolder = (chatId: string, folderId: string | null) => { + dispatch({ type: 'MOVE_TO_FOLDER', payload: { chatId, folderId } }); + notify('info', folderId ? 'Chat moved to folder' : 'Chat removed from folder'); + setDragOver(null); + setDraggedChat(null); + }; + + const sidebarContent = ( + <> + ++ +++ {!sidebarCollapsed && ( + + )} ++ O ++ {!sidebarCollapsed && OmniAI} ++ ++ +++ ++ {sidebarCollapsed ? ( + + ) : ( + + )} ++ + {sortedPinned.length > 0 && !search.trim() && ( +++ )} + + {!search.trim() && folders.length > 0 && ( +} label="Pinned" /> + + {sortedPinned.map(c => ( ++{ selectChat(c.id); if (isMobile) dispatch({ type: 'TOGGLE_MOBILE_SIDEBAR', payload: false }); }} + query={search} + pinned + onMenu={(x, y) => setChatMenu({ id: c.id, x, y })} + onPinToggle={() => { pinChat(c.id); notify('info', 'Unpinned'); }} + onDragStart={() => setDraggedChat(c.id)} + onDragEnd={() => { setDraggedChat(null); setDragOver(null); }} + /> + ))} + ++ )} + ++ ++Folders + + + + {folders.map(folder => ( ++{ if (draggedChat && draggedChat !== folder.id) { e.preventDefault(); setDragOver(folder.id); } }} + onDragLeave={() => dragOver === folder.id && setDragOver(null)} + onDrop={() => draggedChat && onChatDropToFolder(draggedChat, folder.id)} + className={clsx('rounded-lg transition-colors', dragOver === folder.id && 'bg-primary/10 ring-1 ring-primary/30')} + > ++ ))} +onToggleFolder(folder.id)} + onMenu={() => setFolderMenu(folder.id)} + /> + {folder.expanded && !sidebarCollapsed && ( + + {(chatsByFolder.get(folder.id) || []).slice(0, 20).map(c => ( ++ )} +{ selectChat(c.id); if (isMobile) dispatch({ type: 'TOGGLE_MOBILE_SIDEBAR', payload: false }); }} + query="" + small + onMenu={(x, y) => setChatMenu({ id: c.id, x, y })} + onPinToggle={() => { pinChat(c.id); notify('info', pinnedIds.includes(c.id) ? 'Unpinned' : 'Pinned'); }} + onDragStart={() => setDraggedChat(c.id)} + onDragEnd={() => { setDraggedChat(null); setDragOver(null); }} + /> + ))} + ++} label={search.trim() ? 'Results' : 'Recent'} /> + { if (draggedChat) { e.preventDefault(); setDragOver('__root__'); } }} + onDragLeave={() => dragOver === '__root__' && setDragOver(null)} + onDrop={() => draggedChat && onChatDropToFolder(draggedChat, null)} + > + {sortedRecent.length === 0 ? ( +++ {search.trim() ? 'No matches' : 'No chats yet'} ++ ) : sortedRecent.map(c => ( +{ selectChat(c.id); if (isMobile) dispatch({ type: 'TOGGLE_MOBILE_SIDEBAR', payload: false }); }} + query={search} + onMenu={(x, y) => setChatMenu({ id: c.id, x, y })} + onPinToggle={() => { pinChat(c.id); notify('info', pinnedIds.includes(c.id) ? 'Unpinned' : 'Pinned'); }} + onDragStart={() => setDraggedChat(c.id)} + onDragEnd={() => { setDraggedChat(null); setDragOver(null); }} + /> + ))} + ++ +} + label="Models" + onClick={() => openDialog({ share: true })} + /> + } + label="Settings" + onClick={() => openDialog({ settings: true })} + /> + } + label="Profile" + onClick={() => openDialog({ profile: true })} + /> + : } + label={sidebarCollapsed ? 'Expand' : 'Collapse'} + onClick={() => dispatch({ type: 'TOGGLE_SIDEBAR' })} + /> + {!sidebarCollapsed && ( + openDialog({ profile: true })}> ++ )} ++ {profile.avatar ?+: getInitials(profile.name)} +
++{profile.name}+{profile.email}++ + {folderMenu && ( + f.id === folderMenu)!} + onClose={() => setFolderMenu(null)} + onRename={(name) => onRenameFolder(folderMenu, name)} + onDelete={() => onDeleteFolder(folderMenu)} + /> + )} + {chatMenu && (() => { + const chat = conversations.find(c => c.id === chatMenu.id); + if (!chat) return null; + return ( + setChatMenu(null)} + onPin={() => pinChat(chat.id)} + onDelete={() => { dispatch({ type: 'OPEN_DIALOG', payload: { confirmDelete: chat.id } }); }} + onMoveToFolder={(fid) => onChatDropToFolder(chat.id, fid)} + onOpen={() => { selectChat(chat.id); setChatMenu(null); }} + onRename={(title) => dispatch({ type: 'UPDATE_CONVERSATION', payload: { id: chat.id, updates: { title } } })} + /> + ); + })()} + { + if (dialog.confirmDelete) { deleteChat(dialog.confirmDelete); notify('success', 'Chat deleted'); } + closeDialogs(); + }} + /> + > + ); + + return ( + <> + + + {sidebarMobileOpen && ( + +dispatch({ type: 'TOGGLE_MOBILE_SIDEBAR', payload: false })} /> + ++ )} + > + ); +} + +function SectionTitle({ icon, label }: { icon: React.ReactNode; label: string }) { + return ( ++ {icon} + {label} ++ ); +} + +function SidebarNavItem({ collapsed, icon, label, onClick, active }: { collapsed: boolean; icon: React.ReactNode; label: string; onClick: () => void; active?: boolean }) { + return ( + + ); +} + +function FolderNavItem({ folder, collapsed, onToggle, onMenu }: { folder: FolderType; collapsed: boolean; onToggle: () => void; onMenu: () => void }) { + return ( ++ + {!collapsed && ( + + )} ++ ); +} + +function ChatNavItem({ + conversation, active, onClick, query, pinned, onMenu, onPinToggle, onDragStart, onDragEnd, small +}: { + conversation: Conversation; active?: boolean; onClick: () => void; query?: string; pinned?: boolean; + onMenu: (x: number, y: number) => void; onPinToggle: () => void; + onDragStart: () => void; onDragEnd: () => void; small?: boolean; +}) { + return ( +{ e.dataTransfer.effectAllowed = 'move'; onDragStart(); }} + onDragEnd={onDragEnd} + className="group relative" + > + ++ ); +} + +function renderHighlightedText(text: string, query: string) { + const segments = query ? highlightText(text, query) : [{ text, highlight: false }]; + return ( + <> + {segments.map((segment, index) => ( + + {segment.text} + + ))} + > + ); +} + +function SearchDialog({ open, onClose }: { open: boolean; onClose: () => void }) { + const { conversations, pinnedIds, selectChat } = useApp(); + const [q, setQ] = useState(''); + const inputRef = useRef+ + ++(null); + + const results = useMemo(() => { + if (!q.trim()) return []; + const query = q.toLowerCase(); + return conversations + .filter(c => + c.title.toLowerCase().includes(query) || + c.messages.some(m => m.content.toLowerCase().includes(query)) + ) + .slice(0, 20); + }, [conversations, q]); + + return ( + + + ); +} + +function FolderCreateDialog({ open, onClose, onCreate }: { open: boolean; onClose: () => void; onCreate: (name: string) => void }) { + const [name, setName] = useState(''); + useEffect(() => { if (open) setName(''); }, [open]); + return ( ++++++ setQ(e.target.value)} + placeholder="Search titles, messages, tags..." + autoFocus + className="w-full pl-10 pr-16 py-3 bg-[var(--surface)] border border-[var(--border)] rounded-xl text-[var(--foreground)] outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50" + /> + + {!q.trim() && ( ++++ )} + {q.trim() && results.length === 0 && ( ++ Type to search across all conversations
+++ )} + {results.map(c => { + const matchMsg = c.messages.find(m => m.content.toLowerCase().includes(q.toLowerCase())); + return ( + + ); + })} ++ No results for "{q}"
++ + ); +} + +function FolderMenuDialog({ folder, onClose, onRename, onDelete }: { folder: FolderType; onClose: () => void; onRename: (name: string) => void; onDelete: () => void }) { + const [renaming, setRenaming] = useState(false); + const [name, setName] = useState(folder.name); + return ( ++++ + setName(e.target.value)} + onKeyDown={e => e.key === 'Enter' && name.trim() && onCreate(name.trim())} + placeholder="Work, Projects, Ideas..." + className="w-full px-3 py-2.5 bg-[var(--surface)] border border-[var(--border)] rounded-xl text-[var(--foreground)] outline-none focus:ring-2 focus:ring-primary/30" + /> +++ + +++ + ); +} + +function ChatContextMenu({ x, y, chat, pinned, folders, onClose, onPin, onDelete, onMoveToFolder, onOpen, onRename }: { + x: number; y: number; chat: Conversation; pinned: boolean; folders: FolderType[]; onClose: () => void; + onPin: () => void; onDelete: () => void; onMoveToFolder: (folderId: string | null) => void; onOpen: () => void; + onRename: (title: string) => void; +}) { + const [renameOpen, setRenameOpen] = useState(false); + const [newTitle, setNewTitle] = useState(chat.title); + if (renameOpen) { + return ( ++ {renaming ? ( + <> + setName(e.target.value)} + className="w-full px-3 py-2.5 bg-[var(--surface)] border border-[var(--border)] rounded-xl text-[var(--foreground)] outline-none focus:ring-2 focus:ring-primary/30" + /> +++ + ++ > + ) : ( + <> +++ + + > + )} ++++ ++{folder.name}+Created {formatTimestamp(folder.createdAt)}+{ setRenameOpen(false); onClose(); }} size="sm" title="Rename Chat"> + + ); + } + return ( + <> + ++ setNewTitle(e.target.value)} + className="w-full px-3 py-2.5 bg-[var(--surface)] border border-[var(--border)] rounded-xl text-[var(--foreground)] outline-none focus:ring-2 focus:ring-primary/30" + /> +++ + +++ } label="Open chat" onClick={onOpen} /> + :+ > + ); +} + +function MenuItem({ icon, label, onClick, danger, color, selected }: { icon?: React.ReactNode; label: string; onClick: () => void; danger?: boolean; color?: string; selected?: boolean }) { + return ( + + ); +} diff --git a/frontend/src/components/TopBar.tsx b/frontend/src/components/TopBar.tsx new file mode 100644 index 0000000..d4fa0b7 --- /dev/null +++ b/frontend/src/components/TopBar.tsx @@ -0,0 +1,274 @@ +import React, { useState, useMemo } from 'react'; +import { Share2, Sun, Moon, Monitor, Bell, Menu, X, Copy, Link as LinkIcon, Check, Clock, Wand2, Sparkles } from 'lucide-react'; +import { clsx } from 'clsx'; +import { ModelSelector } from './ModelSelector'; +import { useApp } from '../store/AppContext'; +import { Modal } from './ui/Modal'; +import { copyToClipboard, formatTimestamp, getInitials } from '../lib/utils'; + +export function TopBar() { + const { + currentChatId, conversations, selectedModel, dispatch, + notify, openDialog, closeDialogs, dialog, settings, profile, notifications, + } = useApp(); + + const currentChat = conversations.find(c => c.id === currentChatId); + const unread = notifications.filter(n => !n.read).length; + + const cycleTheme = () => { + const order: Array<'system' | 'dark' | 'light'> = ['system', 'dark', 'light']; + const cur = order.indexOf(settings.theme); + const next = order[(cur + 1) % order.length]; + dispatch({ type: 'UPDATE_SETTINGS', payload: { theme: next } }); + notify('info', `Theme: ${next.charAt(0).toUpperCase() + next.slice(1)}`); + }; + + const themeIcon = settings.theme === 'dark' + ?} label={pinned ? 'Unpin' : 'Pin'} onClick={onPin} /> + } label="Rename" onClick={() => setRenameOpen(true)} /> + + Move to folder+ } label="No folder" onClick={() => onMoveToFolder(null)} /> + {folders.map(f => ( + } + label={f.name} + color={f.color} + selected={chat.folderId === f.id} + onClick={() => onMoveToFolder(f.id)} + /> + ))} + + } label="Delete" danger onClick={onDelete} /> ++ : settings.theme === 'light' + ? + : ; + + return ( + <> + + + ++ + ++ +{ dispatch({ type: 'SELECT_MODEL', payload: id }); notify('success', 'Model switched', `Using ${id}`); }} /> + + + + + + {currentChat?.title || 'New Chat'} + + {currentChat && ( + + {currentChat.messages.length} messages + + )} ++++openDialog({ share: true })} + tooltip="Share conversation" + icon={ } + /> + + ++ +openDialog({ notifications: true })} + tooltip="Notifications" + icon={ + ++ } + /> ++ {unread > 0 && ( + + {Math.min(unread, 9)} + + )} + + + > + ); +} + +function IconButton({ icon, tooltip, onClick }: { icon: React.ReactNode; tooltip: string; onClick: () => void }) { + return ( + + ); +} + +function ShareDialog({ open, onClose, chatTitle, chatMessagesCount }: { open: boolean; onClose: () => void; chatTitle: string; chatMessagesCount: number }) { + const { notify, profile } = useApp(); + const [copied, setCopied] = useState(false); + const [linkType, setLinkType] = useState<'share' | 'markdown' | 'json'>('share'); + + const onCopyLink = async () => { + const url = `${window.location.origin}${window.location.pathname}`; + await copyToClipboard(url); + setCopied(true); + notify('success', 'Copied share link'); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + + + ); +} + +function NotificationsDialog({ open, onClose }: { open: boolean; onClose: () => void }) { + const { notifications, dispatch } = useApp(); + const items = useMemo(() => notifications.slice(0, 30), [notifications]); + + const icons = { + success:++++ ++++++ ++{chatTitle || 'Untitled'}+{chatMessagesCount} messages · Shared by {profile.name}++ ++ ++ {[ + { id: 'share' as const, label: 'Copy URL', icon:+}, + { id: 'markdown' as const, label: 'Markdown', icon: }, + { id: 'json' as const, label: 'JSON', icon: }, + ].map(opt => ( + + ))} + + ++ ++++++ ++ Public +Anyone with link can view+Shareable (read-only) link++ + ++, + error: , + warning: , + info: , + }; + + const dotColors = { + success: 'bg-success', + error: 'bg-danger', + warning: 'bg-warning', + info: 'bg-primary', + }; + + return ( + + + ); +} diff --git a/frontend/src/components/WelcomeScreen.tsx b/frontend/src/components/WelcomeScreen.tsx new file mode 100644 index 0000000..ec00169 --- /dev/null +++ b/frontend/src/components/WelcomeScreen.tsx @@ -0,0 +1,39 @@ +export function WelcomeScreen({ onStart }: { onStart: () => void }) { + return ( ++ {items.length === 0 ? ( ++++ ) : ( + <> ++ No notifications yet
++ {items.filter(n => !n.read).length} unread + +++ {items.map(n => ( ++ > + )} +dispatch({ type: 'MARK_NOTIFICATION_READ', payload: n.id })} + > ++ ))} ++++ {icons[n.type]} ++ {!n.read && } +++++ {n.message &&{n.title}+{formatTimestamp(n.timestamp)}+{n.message}} ++ {/* Background decoration */} + + ++ ); +} diff --git a/frontend/src/components/content/StructuredContent.tsx b/frontend/src/components/content/StructuredContent.tsx new file mode 100644 index 0000000..c2076ba --- /dev/null +++ b/frontend/src/components/content/StructuredContent.tsx @@ -0,0 +1,506 @@ +import React from 'react'; +import { ExternalLink, Image as ImageIcon, Play, Quote, Table2, Sparkles, BarChart3 } from 'lucide-react'; +import { cn } from '../../lib/utils'; +import type { StructuredPayload } from '../../lib/messageContent'; +import { STRUCTURED_COMPONENTS } from '../../lib/messageContent'; + +export function StructuredContent({ payload }: { payload: StructuredPayload }) { + const items = normalizePayload(payload); + if (items.length === 0) return null; + + return ( ++++ O ++ ++ OmniAI +
+ ++ One Platform. Every AI. +
+ ++ Choose the best AI model for every task—from coding and research to writing and brainstorming—all in one elegant workspace. +
+ ++ + + +++ {items.map((item, index) => ( ++ ); +} + +function StructuredItemView({ item }: { item: unknown }) { + if (Array.isArray(item)) { + return+ ))} + ; + } + if (!item || typeof item !== 'object') { + return null; + } + + const payload = item as Record ; + const component = getComponentName(payload); + const title = getText(payload, ['title', 'heading', 'name', 'label']); + const description = getText(payload, ['description', 'subtitle', 'summary', 'content', 'text', 'body']); + const imageUrl = getUrl(payload, ['image', 'imageUrl', 'src', 'url', 'thumbnail', 'thumbnailUrl', 'poster']); + const items = getArray(payload, ['items', 'cards', 'slides', 'entries', 'rows', 'points']); + const actions = getArray(payload, ['actions']); + const stats = getArray(payload, ['stats', 'metrics']); + const sources = getArray(payload, ['sources', 'references']); + + if (component === 'Image' || (imageUrl && !component)) { + return ( + } + title={title || 'Image'} + description={description} + media={ } + footer={actions} + /> + ); + } + + if (component === 'Video') { + return ( +
} + title={title || 'Video'} + description={description} + media={imageUrl ? ( + + ) : null} + footer={actions} + /> + ); + } + + if (component === 'Quote') { + return ( + ++ ); + } + + if (component === 'Table') { + const table = renderTable(payload); + if (table) return table; + } + + if (component === 'Stat') { + return+++++{description || getText(payload, ['quote']) || title}+ {title &&{title}} +; + } + + if (component === 'Timeline') { + return ; + } + + if (component === 'Gallery') { + return ; + } + + if (component === 'Carousel') { + return ; + } + + if (component === 'Bento' || component === 'Hero' || component === 'Card' || component === 'Table' || component === 'Stat' || component === 'Quote') { + return ( + : } + title={title || component || 'Card'} + description={description} + items={items} + actions={actions} + sources={sources} + /> + ); + } + + if (component && STRUCTURED_COMPONENTS.has(component)) { + return ( + } + title={title || component} + description={description} + items={items} + actions={actions} + sources={sources} + /> + ); + } + + const fallbackText = extractDisplayText(payload); + if (!fallbackText) return null; + return {fallbackText}; +} + +function GenericCard({ + icon, + title, + description, + items, + actions, + sources, +}: { + icon?: React.ReactNode; + title?: string; + description?: string; + items?: unknown[]; + actions?: unknown[]; + sources?: unknown[]; +}) { + return ( +++ ); +} + +function MediaCard({ + icon, + title, + description, + media, + footer, +}: { + icon: React.ReactNode; + title: string; + description?: string; + media?: React.ReactNode; + footer?: unknown[]; +}) { + return ( ++ {icon &&+ {items && items.length > 0 &&{icon}} ++ {title &&+{title}} + {description &&{description}} +} + {actions && actions.length > 0 && } + {sources && sources.length > 0 && } + ++ ); +} + +function StatGrid({ title, description, stats }: { title?: string; description?: string; stats?: unknown[] }) { + const normalized = (stats || []).map(item => normalizeObject(item)).filter(Boolean) as Array++ {media} + {footer && footer.length > 0 &&{icon}+++{title}+ {description &&{description}} +} + >; + return ( + + {(title || description) && ( ++ ); +} + +function TimelineView({ title, description, entries }: { title?: string; description?: string; entries?: unknown[] }) { + const normalized = (entries || []).map(item => normalizeObject(item)).filter(Boolean) as Array+ {title &&+ )} +{title}} + {description &&{description}} ++ {normalized.length > 0 ? normalized.map((stat, index) => ( ++++ )) : ( +{getText(stat, ['label', 'title', 'name']) || `Stat ${index + 1}`}+{getText(stat, ['value', 'amount', 'count']) || '—'}+ {getText(stat, ['description', 'subtitle']) &&{getText(stat, ['description', 'subtitle'])}} +No stats available+ )} +>; + return ( + + {(title || description) && ( ++ ); +} + +function GalleryGrid({ title, description, entries }: { title?: string; description?: string; entries?: unknown[] }) { + return ( ++ {title &&+ )} +{title}} + {description &&{description}} ++ {normalized.length > 0 ? normalized.map((entry, index) => ( ++++ )) : ( ++ {index + 1} ++++{getText(entry, ['title', 'heading', 'name']) || `Step ${index + 1}`}+{getText(entry, ['description', 'summary', 'text', 'content'])}+No timeline entries available+ )} ++ {(title || description) && ( ++ ); +} + +function CarouselStrip({ title, description, entries }: { title?: string; description?: string; entries?: unknown[] }) { + return ( ++ {title &&+ )} +{title}} + {description &&{description}} ++ {entries?.length ? entries.map((entry, index) => { + const item = normalizeObject(entry); + const imageUrl = item ? getUrl(item, ['image', 'imageUrl', 'src', 'url', 'thumbnail', 'thumbnailUrl']) : ''; + const label = item ? getText(item, ['title', 'name', 'label']) : ''; + return ( +++ {imageUrl ? ( ++ ); + }) :+ ) : ( +
++ )} + {(label || item) && ( ++ + {label || extractDisplayText(item)} ++ )} +No gallery items available} ++ {(title || description) && ( ++ ); +} + +function ObjectList({ items, label }: { items: unknown[]; label?: string }) { + const normalized = items.map(item => normalizeObject(item)).filter(Boolean) as Array+ {title &&+ )} +{title}} + {description &&{description}} ++ {entries?.length ? entries.map((entry, index) => { + const item = normalizeObject(entry); + const imageUrl = item ? getUrl(item, ['image', 'imageUrl', 'src', 'url', 'thumbnail', 'thumbnailUrl']) : ''; + const label = item ? getText(item, ['title', 'name', 'label']) : ''; + const text = item ? getText(item, ['description', 'summary', 'text', 'content']) : ''; + return ( +++ {imageUrl &&+ ); + }) :} + {label &&
{label}} + {text &&{text}} + {!label && !text &&{extractDisplayText(item) || `Item ${index + 1}`}} +No carousel items available} +>; + return ( + + {label &&+ ); +} + +function SourceList({ sources }: { sources: unknown[] }) { + const normalized = sources.map(item => normalizeObject(item)).filter(Boolean) as Array{label}} ++ {normalized.map((item, index) => { + const title = getText(item, ['title', 'name', 'label']) || getText(item, ['url', 'href']); + const text = getText(item, ['description', 'summary', 'text', 'content', 'value']); + const url = getUrl(item, ['url', 'href', 'link']); + if (url || title || text) { + return ( + + ); + } + return+{extractDisplayText(item) || `Item ${index + 1}`}; + })} +>; + return ( + ++ ); +} + +function renderTable(payload: RecordSources++ {normalized.map((source, index) => { + const title = getText(source, ['title', 'name', 'label']) || `Source ${index + 1}`; + const url = getUrl(source, ['url', 'href', 'link']); + const text = getText(source, ['description', 'summary', 'text']); + return ( + ++{title}+ {text &&{text}} + {url &&{url}} + + ); + })} +) { + const headers = getArrayOfStrings(payload, ['headers', 'columns']); + const rows = getArray(payload, ['rows', 'data']); + if (!rows || rows.length === 0) return null; + + const normalizedRows = rows.map(row => normalizeRow(row)).filter(row => row.length > 0); + if (normalizedRows.length === 0) return null; + + const effectiveHeaders = headers.length ? headers : normalizedRows[0].map((_, index) => `Column ${index + 1}`); + + return ( + ++ ); +} + +function getComponentName(payload: Record+++ {getText(payload, ['title', 'heading', 'name']) || 'Table'} + +++ +
++ {effectiveHeaders.map((header, index) => ( + + + + {normalizedRows.map((row, rowIndex) => ( ++ {header} + + ))} ++ {row.map((cell, cellIndex) => ( + + ))} + ++ {cell} + + ))} +) { + const component = payload.component; + return typeof component === 'string' ? component : ''; +} + +function getText(payload: Record , keys: string[]) { + for (const key of keys) { + const value = payload[key]; + if (typeof value === 'string' && value.trim()) return value.trim(); + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + } + return ''; +} + +function getUrl(payload: Record , keys: string[]): string { + for (const key of keys) { + const value = payload[key]; + if (typeof value === 'string' && /^https?:\/\//i.test(value.trim())) return value.trim(); + if (value && typeof value === 'object') { + const nested = normalizeObject(value); + if (nested) { + const nestedUrl: string = getUrl(nested, ['url', 'href', 'link', 'src']); + if (nestedUrl) return nestedUrl; + } + } + } + return ''; +} + +function getArray(payload: Record , keys: string[]) { + for (const key of keys) { + const value = payload[key]; + if (Array.isArray(value)) return value; + } + return [] as unknown[]; +} + +function getArrayOfStrings(payload: Record , keys: string[]) { + for (const key of keys) { + const value = payload[key]; + if (Array.isArray(value)) { + return value.filter((item): item is string => typeof item === 'string'); + } + } + return [] as string[]; +} + +function normalizePayload(payload: StructuredPayload): unknown[] { + if (Array.isArray(payload)) return payload; + if (!payload || typeof payload !== 'object') return []; + const record = payload as Record ; + const items = getArray(record, ['items', 'cards', 'slides', 'entries', 'rows', 'points', 'sources', 'references']); + if (items.length > 0) return items; + return [record]; +} + +function normalizeObject(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record ; +} + +function normalizeRow(value: unknown): string[] { + if (Array.isArray(value)) { + return value.map(cell => extractDisplayText(cell)); + } + const record = normalizeObject(value); + if (!record) return []; + const keys = ['title', 'name', 'label', 'value', 'description', 'summary', 'text', 'content']; + const cells = keys.map(key => extractDisplayText(record[key])).filter(Boolean); + return cells.length > 0 ? cells : Object.values(record).map(cell => extractDisplayText(cell)).filter(Boolean); +} + +function extractDisplayText(value: unknown): string { + if (value == null) return ''; + if (typeof value === 'string') return value.trim(); + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (Array.isArray(value)) { + return value.map(extractDisplayText).filter(Boolean).join(' · ').trim(); + } + if (typeof value === 'object') { + const record = value as Record ; + const fields = ['title', 'heading', 'name', 'label', 'description', 'subtitle', 'summary', 'text', 'content', 'value']; + for (const field of fields) { + const text = extractDisplayText(record[field]); + if (text) return text; + } + const nested = Object.entries(record) + .filter(([key]) => !/^(component|props|children|actions|render_json|tool_output|metadata|sources_json|internal_objects)$/i.test(key)) + .map(([, child]) => extractDisplayText(child)) + .filter(Boolean); + return nested.join(' · ').trim(); + } + return String(value).trim(); +} diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx new file mode 100644 index 0000000..ea308aa --- /dev/null +++ b/frontend/src/components/ui/Modal.tsx @@ -0,0 +1,132 @@ +import React, { useEffect, useRef } from 'react'; +import { X } from 'lucide-react'; +import { cn } from '../../lib/utils'; + +interface ModalProps { + open: boolean; + onClose: () => void; + title?: string; + children: React.ReactNode; + size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'; + className?: string; + hideClose?: boolean; +} + +export function Modal({ open, onClose, title, children, size = 'md', className, hideClose }: ModalProps) { + const panelRef = useRef (null); + + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', handler); + const prev = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.removeEventListener('keydown', handler); + document.body.style.overflow = prev; + }; + }, [open, onClose]); + + const onBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) onClose(); + }; + + if (!open) return null; + + const sizeClass = { + sm: 'max-w-sm', + md: 'max-w-lg', + lg: 'max-w-2xl', + xl: 'max-w-4xl', + full: 'max-w-[95vw] max-h-[95vh]', + }[size]; + + return ( + ++ ); +} + +interface ConfirmDialogProps { + open: boolean; + onCancel: () => void; + onConfirm: () => void; + title: string; + message?: string; + confirmText?: string; + cancelText?: string; + danger?: boolean; +} + +export function ConfirmDialog({ + open, + onCancel, + onConfirm, + title, + message, + confirmText = 'Confirm', + cancelText = 'Cancel', + danger, +}: ConfirmDialogProps) { + if (!open) return null; + return ( ++ {(title || !hideClose) && ( +++ {title &&+ )} +{title}
} + {!hideClose && ( + + )} ++ {children} +++ + ); +} diff --git a/frontend/src/components/ui/Notifications.tsx b/frontend/src/components/ui/Notifications.tsx new file mode 100644 index 0000000..951cd96 --- /dev/null +++ b/frontend/src/components/ui/Notifications.tsx @@ -0,0 +1,115 @@ +import React, { useEffect, useState } from 'react'; +import { CheckCircle, XCircle, AlertCircle, Info, X } from 'lucide-react'; +import { useApp } from '../../store/AppContext'; +import type { NotificationItem } from '../../types'; +import { cn } from '../../lib/utils'; + +const icons: Record++++{title}
+ {message &&{message}
} ++ + ++= { + success: , + error: , + warning: , + info: , +}; + +const borderColors: Record = { + success: 'border-l-success', + error: 'border-l-danger', + warning: 'border-l-warning', + info: 'border-l-primary', +}; + +export function Toaster() { + const { notifications, dispatch } = useApp(); + const [toasts, setToasts] = useState ([]); + + useEffect(() => { + const unread = notifications.filter(n => !n.read).slice(0, 5); + setToasts(unread); + + const timers = unread.map(n => + setTimeout(() => { + dispatch({ type: 'MARK_NOTIFICATION_READ', payload: n.id }); + }, 4000) + ); + + return () => { + timers.forEach(clearTimeout); + }; + }, [notifications, dispatch]); + + return ( + + {toasts.map(toast => ( ++ ); +} + +function Toast({ toast, onClose }: { toast: NotificationItem; onClose: () => void }) { + const [leaving, setLeaving] = useState(false); + + useEffect(() => { + const t = setTimeout(() => setLeaving(true), 3800); + return () => clearTimeout(t); + }, []); + + return ( +dispatch({ type: 'MARK_NOTIFICATION_READ', payload: toast.id })} /> + ))} + ++ ); +} + +interface DropdownItem { + icon?: React.ReactNode; + label: string; + onClick: () => void; + danger?: boolean; + disabled?: boolean; +} + +export function ContextMenu({ items, onClose }: { items: DropdownItem[]; onClose: () => void }) { + return ( + <> + +++ +{icons[toast.type]}+++{toast.title}+ {toast.message &&{toast.message}} ++ {items.map((item, i) => ( + + ))} ++ > + ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..9bef949 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,181 @@ +@import "tailwindcss"; +@import "katex/dist/katex.min.css"; + +@theme { + --color-primary: #2563EB; + --color-secondary: #3B82F6; + --color-accent: #60A5FA; + --color-success: #22C55E; + --color-warning: #F59E0B; + --color-danger: #EF4444; + + --color-dark-bg: #09090B; + --color-surface: #111827; + --color-card: #18181B; + + --font-sans: "Inter", system-ui, -apple-system, BlinkMacSystemFont, sans-serif; +} + +@layer base { + :root { + --background: #ffffff; + --foreground: #0a0a0a; + --surface: #f3f4f6; + --card: #ffffff; + --border: rgba(0, 0, 0, 0.08); + } + + .dark { + --background: #09090B; + --foreground: #ffffff; + --surface: #111827; + --card: #18181B; + --border: rgba(255, 255, 255, 0.08); + } + + html, body, #root { + height: 100%; + margin: 0; + padding: 0; + } + + * { + border-color: var(--border); + -webkit-tap-highlight-color: transparent; + } + + body { + background-color: var(--background); + color: var(--foreground); + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + transition: background-color 0.2s ease, color 0.2s ease; + } + + ::selection { + background-color: rgba(37, 99, 235, 0.3); + color: inherit; + } + + button { + font-family: inherit; + } + + textarea, input, select { + font-family: inherit; + } + + input[type="range"] { + height: 4px; + border-radius: 999px; + } + input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: #2563EB; + cursor: pointer; + border: 2px solid white; + box-shadow: 0 1px 4px rgba(0,0,0,0.25); + } + input[type="range"]::-moz-range-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background: #2563EB; + cursor: pointer; + border: 2px solid white; + box-shadow: 0 1px 4px rgba(0,0,0,0.25); + } + + .dark input[type="range"] { + background: rgba(255,255,255,0.1); + } + :root:not(.dark) input[type="range"] { + background: rgba(0,0,0,0.08); + } + + select { + -webkit-appearance: none; + appearance: none; + background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e"); + background-repeat: no-repeat; + background-position: right 0.75rem center; + background-size: 1em; + padding-right: 2.5rem; + } +} + +@layer utilities { + .glass { + background-color: color-mix(in oklab, var(--card) 70%, transparent); + backdrop-filter: blur(12px); + border: 1px solid var(--border); + } + + .scrollbar-hide::-webkit-scrollbar { + display: none; + width: 0; + height: 0; + } + + .scrollbar-hide { + -ms-overflow-style: none; + scrollbar-width: none; + } + + .scrollbar-thin { + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; + } + .scrollbar-thin::-webkit-scrollbar { + width: 6px; + height: 6px; + } + .scrollbar-thin::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 3px; + } + + /* Better prose defaults for markdown in light mode */ + :root:not(.dark) .prose-invert :is(h1,h2,h3,h4,h5,h6,p,li,blockquote,th,td,strong) { + color: inherit; + } + :root:not(.dark) pre { background: #0d0d12 !important; } + + .animate-in { + animation-duration: 200ms; + animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); + animation-fill-mode: both; + animation-name: fadeIn; + } + .animate-out { + animation-duration: 200ms; + animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); + animation-fill-mode: both; + animation-name: fadeOut; + } + .fade-in { animation-name: fadeIn; } + .fade-out { animation-name: fadeOut; } + .zoom-in-95 { animation-name: zoomIn95; } + .zoom-in-95.fade-in { animation-name: fadeIn, zoomIn95; } + .slide-in-from-top-2 { animation-name: slideFromTop2; } + .slide-in-from-top-1 { animation-name: slideFromTop1; } + .slide-in-from-bottom-2 { animation-name: slideFromBottom2; } + .slide-in-from-right { animation-name: slideFromRight; } + .slide-in-from-left { animation-name: slideFromLeft; } + .slide-out-to-right { animation-name: slideOutRight; } + + @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } + @keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } } + @keyframes zoomIn95 { from { transform: scale(0.96); } to { transform: scale(1); } } + @keyframes slideFromTop2 { from { transform: translateY(-8px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } + @keyframes slideFromTop1 { from { transform: translateY(-4px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } + @keyframes slideFromBottom2 { from { transform: translateY(8px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } + @keyframes slideFromRight { from { transform: translateX(20px); opacity: 0; } to { transform: translateX(0); opacity: 1; } } + @keyframes slideFromLeft { from { transform: translateX(-100%); } to { transform: translateX(0); } } + @keyframes slideOutRight { from { transform: translateX(0); opacity: 1; } to { transform: translateX(20px); opacity: 0; } } +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..5717fe8 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,187 @@ +import type { Message, TokenUsage } from '../types'; + +const API_BASE = 'http://localhost:8081/v1'; + +export interface ChatCompletionChunk { + choices: Array<{ + delta: { content?: string; reasoning_content?: string }; + index: number; + finish_reason?: string | null; + }>; + model?: string; + usage?: { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + }; +} + +export interface ChatCompletionResponse { + choices: Array<{ + message: { role: string; content: string }; + finish_reason: string; + }>; + usage?: { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + }; +} + +export async function listModels(): Promise{ + try { + const res = await fetch(`${API_BASE}/models`, { + headers: { 'Authorization': 'Bearer dummy' }, + }); + if (!res.ok) return []; + const data = await res.json(); + return (data.data || []).map((m: any) => m.id); + } catch { + return []; + } +} + +export async function checkBackendHealth(): Promise { + try { + const res = await fetch(`${API_BASE}/models`, { + headers: { 'Authorization': 'Bearer dummy' }, + signal: AbortSignal.timeout(3000), + }); + return res.ok; + } catch { + return false; + } +} + +export async function streamChatCompletion( + messages: Message[], + model: string, + settings: { temperature: number; maxTokens: number; topP: number; stream: boolean }, + onChunk: (text: string, reasoningText: string) => void, + signal: AbortSignal +): Promise<{ content: string; reasoning: string; usage: TokenUsage | null }> { + let fullContent = ''; + let fullReasoning = ''; + let usage: TokenUsage | null = null; + + const response = await fetch(`${API_BASE}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer dummy', + }, + body: JSON.stringify({ + model, + messages: messages.map(m => ({ role: m.role, content: m.content })), + stream: settings.stream, + temperature: settings.temperature, + max_tokens: settings.maxTokens, + top_p: settings.topP, + }), + signal, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Unknown error'); + throw new Error(`API Error (${response.status}): ${errorText || response.statusText}`); + } + + if (!settings.stream) { + const data: ChatCompletionResponse = await response.json(); + const content = data.choices[0]?.message?.content || ''; + fullContent = content; + onChunk(content, ''); + if (data.usage) { + usage = { + promptTokens: data.usage.prompt_tokens || 0, + completionTokens: data.usage.completion_tokens || 0, + totalTokens: data.usage.total_tokens || 0, + }; + } + return { content: fullContent, reasoning: fullReasoning, usage }; + } + + if (!response.body) { + throw new Error('No response body from server'); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder('utf-8'); + let buffer = ''; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + if (trimmed === 'data: [DONE]') continue; + if (!trimmed.startsWith('data:')) continue; + + try { + const jsonStr = trimmed.slice(5).trim(); + const chunk: ChatCompletionChunk = JSON.parse(jsonStr); + const delta = chunk.choices[0]?.delta; + if (delta) { + if (delta.content) { + fullContent += delta.content; + onChunk(delta.content, ''); + } + if (delta.reasoning_content) { + fullReasoning += delta.reasoning_content; + onChunk('', delta.reasoning_content); + } + } + if (chunk.usage) { + usage = { + promptTokens: chunk.usage.prompt_tokens || 0, + completionTokens: chunk.usage.completion_tokens || 0, + totalTokens: chunk.usage.total_tokens || 0, + }; + } + } catch (e) { + console.warn('Failed to parse SSE chunk:', trimmed.slice(0, 100)); + } + } + } + + return { content: fullContent, reasoning: fullReasoning, usage }; +} + +export async function generateTitle(initialMessage: string, model: string): Promise { + try { + const response = await fetch(`${API_BASE}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer dummy', + }, + body: JSON.stringify({ + model: model || 'gemini-flash-lite', + messages: [ + { + role: 'user', + content: `Generate a concise title (max 8 words, no quotes) for a conversation starting with: "${initialMessage.slice(0, 500)}"`, + }, + ], + stream: false, + max_tokens: 50, + temperature: 0.5, + }), + signal: AbortSignal.timeout(10000), + }); + + if (!response.ok) throw new Error('Title generation failed'); + const data = await response.json(); + let title = data.choices?.[0]?.message?.content?.trim() || ''; + title = title.replace(/^["']|["']$/g, '').replace(/\.$/, ''); + return title.slice(0, 60) || 'New Chat'; + } catch { + return initialMessage.slice(0, 40) || 'New Chat'; + } +} diff --git a/frontend/src/lib/messageContent.ts b/frontend/src/lib/messageContent.ts new file mode 100644 index 0000000..47ef45f --- /dev/null +++ b/frontend/src/lib/messageContent.ts @@ -0,0 +1,294 @@ +export const STRUCTURED_COMPONENTS = new Set([ + 'Bento', + 'Carousel', + 'Image', + 'Video', + 'Card', + 'Gallery', + 'Table', + 'Timeline', + 'Hero', + 'Stat', + 'Quote', +]); + +export const INTERNAL_PAYLOAD_KEYS = new Set([ + 'component', + 'props', + 'children', + 'actions', + 'render_json', + 'tool_output', + 'metadata', + 'sources_json', + 'internal_objects', +]); + +const REAL_CODE_LANGUAGES = new Set([ + 'python', + 'py', + 'javascript', + 'js', + 'typescript', + 'ts', + 'tsx', + 'jsx', + 'react', + 'html', + 'css', + 'sql', + 'bash', + 'sh', + 'zsh', + 'json', + 'yaml', + 'yml', +]); + +export type StructuredPayload = unknown; + +export type ContentSegment = + | { type: 'text'; text: string } + | { type: 'structured'; payload: StructuredPayload; raw: string }; + +const JSON_REQUEST_PATTERNS = [ + /\bjson\b/i, + /\b(yaml|xml|csv|tsv|markdown)\b/i, + /\b(return|output|generate|respond with|format as|provide|give me)\b[^\n]{0,40}\bjson\b/i, + /\bjson\s+only\b/i, + /\bvalid\s+json\b/i, + /\bstructured\s+json\b/i, + /\bapi\s+response\b/i, + /\bschema\b/i, +]; + +export function shouldAllowStructuredJson(text: string): boolean { + const normalized = text.replace(/\s+/g, ' ').trim(); + if (!normalized) return false; + return JSON_REQUEST_PATTERNS.some(pattern => pattern.test(normalized)); +} + +export function sanitizeAssistantContent(raw: string, allowJson = false): string { + if (allowJson || !raw) return raw; + const segments = parseAssistantContent(raw, false); + return segments + .map(segment => (segment.type === 'text' ? segment.text : '')) + .filter(Boolean) + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +export function parseAssistantContent(raw: string, allowJson = false): ContentSegment[] { + const normalized = (raw || '').replace(/\r\n/g, '\n'); + if (!normalized.trim()) return [{ type: 'text', text: '' }]; + if (allowJson) return [{ type: 'text', text: normalized }]; + + const segments: ContentSegment[] = []; + const lines = normalized.split('\n'); + let textBuffer: string[] = []; + let discardedStructured = false; + + const flushText = () => { + if (!textBuffer.length) return; + const text = textBuffer.join('\n').replace(/\n{3,}/g, '\n\n').trim(); + if (text) segments.push({ type: 'text', text }); + textBuffer = []; + }; + + let index = 0; + while (index < lines.length) { + const line = lines[index]; + const fenceMatch = line.match(/^\s*```([^`]*)\s*$/); + if (fenceMatch) { + const fenceLang = fenceMatch[1].trim().toLowerCase(); + const fenceLines: string[] = []; + index += 1; + while (index < lines.length) { + const fenceLine = lines[index]; + if (/^\s*```\s*$/.test(fenceLine)) { + index += 1; + break; + } + fenceLines.push(fenceLine); + index += 1; + } + + const fenceText = fenceLines.join('\n').trim(); + const parsed = tryParseJson(fenceText); + if (parsed && isStructuredPayload(parsed)) { + flushText(); + discardedStructured = true; + continue; + } + + if (parsed) { + const extracted = extractTextFromValue(parsed); + if (extracted) { + flushText(); + segments.push({ type: 'text', text: extracted }); + } else { + discardedStructured = true; + } + continue; + } + + if (REAL_CODE_LANGUAGES.has(fenceLang)) { + textBuffer.push(line, ...fenceLines, '```'); + } else { + const extracted = fenceText.replace(/^#+\s*/gm, '').trim(); + if (extracted) { + flushText(); + textBuffer.push(extracted); + } + } + continue; + } + + if (shouldStartJsonBlock(line)) { + const block = collectJsonBlock(lines, index); + if (block) { + const parsed = tryParseJson(block.text); + if (parsed && isStructuredPayload(parsed)) { + flushText(); + discardedStructured = true; + index += block.linesConsumed; + continue; + } + if (parsed) { + flushText(); + const extracted = extractTextFromValue(parsed); + if (extracted) segments.push({ type: 'text', text: extracted }); + else discardedStructured = true; + index += block.linesConsumed; + continue; + } + } + } + + textBuffer.push(line); + index += 1; + } + + flushText(); + + if (segments.length) return segments; + if (discardedStructured) return [{ type: 'text', text: '' }]; + return [{ type: 'text', text: normalized }]; +} + +function shouldStartJsonBlock(line: string): boolean { + const trimmed = line.trimStart(); + if (!trimmed) return false; + return trimmed.startsWith('{') || trimmed.startsWith('['); +} + +function collectJsonBlock(lines: string[], startIndex: number): { text: string; linesConsumed: number } | null { + let candidate = ''; + let depth = 0; + let inString = false; + let escaped = false; + let started = false; + + for (let index = startIndex; index < lines.length; index++) { + const line = lines[index]; + candidate += (candidate ? '\n' : '') + line; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (!started) { + if (ch === '{' || ch === '[') { + started = true; + depth = 1; + } + continue; + } + if (inString) { + if (escaped) { + escaped = false; + continue; + } + if (ch === '\\') { + escaped = true; + continue; + } + if (ch === '"') inString = false; + continue; + } + if (ch === '"') { + inString = true; + } else if (ch === '{' || ch === '[') { + depth++; + } else if (ch === '}' || ch === ']') { + depth--; + if (started && depth === 0) { + return { text: candidate, linesConsumed: index - startIndex + 1 }; + } + } + } + } + + return null; +} + +function tryParseJson(text: string): unknown | null { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function extractTextFromValue(value: unknown): string { + if (typeof value === 'string') return value.trim(); + if (Array.isArray(value)) { + return value + .flatMap(item => extractTextSegments(item)) + .map(part => part.trim()) + .filter(Boolean) + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); + } + if (value && typeof value === 'object') { + return extractTextFromPayload(value); + } + return String(value ?? '').trim(); +} + +function isStructuredPayload(value: unknown): value is StructuredPayload { + if (Array.isArray(value)) { + return value.some(item => isStructuredPayload(item)); + } + if (!value || typeof value !== 'object') return false; + const record = value as Record ; + if (typeof record.component === 'string' && STRUCTURED_COMPONENTS.has(record.component)) return true; + return Object.keys(record).some(key => INTERNAL_PAYLOAD_KEYS.has(key)); +} + +function extractTextFromPayload(payload: unknown): string { + const pieces = extractTextSegments(payload).map(part => part.trim()).filter(Boolean); + return pieces.join('\n').replace(/\n{3,}/g, '\n\n').trim(); +} + +function extractTextSegments(value: unknown, depth = 0): string[] { + if (depth > 6 || value == null) return []; + if (typeof value === 'string') return [value]; + if (typeof value === 'number' || typeof value === 'boolean') return [String(value)]; + if (Array.isArray(value)) { + return value.flatMap(item => extractTextSegments(item, depth + 1)); + } + if (typeof value !== 'object') return []; + + const record = value as Record ; + const parts: string[] = []; + for (const [key, child] of Object.entries(record)) { + if (INTERNAL_PAYLOAD_KEYS.has(key)) continue; + if (matchesResourceKey(key)) continue; + parts.push(...extractTextSegments(child, depth + 1)); + } + return parts; +} + +function matchesResourceKey(key: string): boolean { + return /^(url|src|href|image|imageUrl|video|videoUrl|thumbnail|thumbnailUrl|poster|link)$/i.test(key); +} diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts new file mode 100644 index 0000000..4cadd09 --- /dev/null +++ b/frontend/src/lib/storage.ts @@ -0,0 +1,124 @@ +import type { Conversation, Folder, Profile, Settings, NotificationItem } from '../types'; + +const PREFIX = 'omniai:'; + +export const storage = { + get (key: string, defaultValue: T): T { + try { + const raw = localStorage.getItem(PREFIX + key); + if (raw === null) return defaultValue; + return JSON.parse(raw) as T; + } catch { + return defaultValue; + } + }, + + set (key: string, value: T): void { + try { + localStorage.setItem(PREFIX + key, JSON.stringify(value)); + } catch { + console.warn('Failed to write to localStorage'); + } + }, + + remove(key: string): void { + localStorage.removeItem(PREFIX + key); + }, +}; + +export const KEYS = { + CONVERSATIONS: 'conversations', + FOLDERS: 'folders', + CURRENT_CHAT_ID: 'currentChatId', + PINNED_IDS: 'pinnedIds', + PROFILE: 'profile', + SETTINGS: 'settings', + NOTIFICATIONS: 'notifications', + MODEL: 'selectedModel', + SIDEBAR_COLLAPSED: 'sidebarCollapsed', +}; + +export const DEFAULT_SETTINGS: Settings = { + theme: 'dark', + language: 'en', + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + streaming: true, + animations: true, + sidebarCollapsed: false, + notifications: { + enabled: true, + sound: false, + desktop: false, + }, + modelDefaults: { + model: 'gemini-3.5-flash-thinking', + temperature: 0.7, + maxTokens: 4096, + topP: 0.9, + }, + shortcuts: { + search: 'Ctrl+K', + newChat: 'Ctrl+N', + toggleSidebar: 'Ctrl+Shift+L', + closeDialog: 'Esc', + }, +}; + +export const DEFAULT_PROFILE: Profile = { + name: 'User', + email: 'user@example.com', +}; + +export const DEFAULT_FOLDERS: Folder[] = [ + { id: 'folder-work', name: 'Work', color: '#2563EB', expanded: true, createdAt: Date.now() }, + { id: 'folder-personal', name: 'Personal', color: '#22C55E', expanded: true, createdAt: Date.now() + 1 }, +]; + +export function loadConversations(): Conversation[] { + return storage.get (KEYS.CONVERSATIONS, []); +} + +export function saveConversations(conversations: Conversation[]): void { + storage.set(KEYS.CONVERSATIONS, conversations); +} + +export function loadFolders(): Folder[] { + return storage.get (KEYS.FOLDERS, DEFAULT_FOLDERS); +} + +export function saveFolders(folders: Folder[]): void { + storage.set(KEYS.FOLDERS, folders); +} + +export function loadPinnedIds(): string[] { + return storage.get (KEYS.PINNED_IDS, []); +} + +export function savePinnedIds(ids: string[]): void { + storage.set(KEYS.PINNED_IDS, ids); +} + +export function loadProfile(): Profile { + return storage.get (KEYS.PROFILE, DEFAULT_PROFILE); +} + +export function saveProfile(profile: Profile): void { + storage.set(KEYS.PROFILE, profile); +} + +export function loadSettings(): Settings { + const saved = storage.get >(KEYS.SETTINGS, {}); + return { ...DEFAULT_SETTINGS, ...saved, notifications: { ...DEFAULT_SETTINGS.notifications, ...saved.notifications }, modelDefaults: { ...DEFAULT_SETTINGS.modelDefaults, ...saved.modelDefaults } }; +} + +export function saveSettings(settings: Settings): void { + storage.set(KEYS.SETTINGS, settings); +} + +export function loadNotifications(): NotificationItem[] { + return storage.get (KEYS.NOTIFICATIONS, []); +} + +export function saveNotifications(notifications: NotificationItem[]): void { + storage.set(KEYS.NOTIFICATIONS, notifications.slice(0, 50)); +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000..16d2442 --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,110 @@ +import { type ClassValue, clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +export function generateId(): string { + return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; +} + +export function formatTimestamp(ts: number): string { + const now = Date.now(); + const diff = now - ts; + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + + if (minutes < 1) return 'Just now'; + if (minutes < 60) return `${minutes}m ago`; + if (hours < 24) return `${hours}h ago`; + if (days < 7) return `${days}d ago`; + + const date = new Date(ts); + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); +} + +export function formatTime(ts: number): string { + return new Date(ts).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); +} + +export function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; +} + +export function debounce any>(fn: T, ms: number): (...args: Parameters ) => void { + let timer: ReturnType ; + return (...args: Parameters ) => { + clearTimeout(timer); + timer = setTimeout(() => fn(...args), ms); + }; +} + +export function escapeRegExp(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export interface HighlightSegment { + text: string; + highlight: boolean; +} + +export function highlightSegments(text: string, query: string): HighlightSegment[] { + if (!query.trim()) return [{ text, highlight: false }]; + const lowerText = text.toLowerCase(); + const lowerQuery = query.toLowerCase(); + const result: HighlightSegment[] = []; + let idx = 0; + while (idx < text.length) { + const matchIdx = lowerText.indexOf(lowerQuery, idx); + if (matchIdx === -1) { + result.push({ text: text.slice(idx), highlight: false }); + break; + } + if (matchIdx > idx) { + result.push({ text: text.slice(idx, matchIdx), highlight: false }); + } + result.push({ text: text.slice(matchIdx, matchIdx + query.length), highlight: true }); + idx = matchIdx + query.length; + } + return result; +} + +export function highlightText(text: string, query: string): HighlightSegment[] { + return highlightSegments(text, query); +} + +export async function copyToClipboard(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + const el = document.createElement('textarea'); + el.value = text; + document.body.appendChild(el); + el.select(); + document.execCommand('copy'); + document.body.removeChild(el); + return true; + } +} + +export function getInitials(name: string): string { + return name + .split(' ') + .map(n => n[0]) + .filter(Boolean) + .slice(0, 2) + .join('') + .toUpperCase(); +} + +export function truncate(str: string, length: number): string { + if (str.length <= length) return str; + return str.slice(0, length) + '...'; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..120ba29 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,15 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' +import { AppProvider } from './store/AppContext' +import { Toaster } from './components/ui/Notifications' + +createRoot(document.getElementById('root')!).render( + + , +) diff --git a/frontend/src/store/AppContext.tsx b/frontend/src/store/AppContext.tsx new file mode 100644 index 0000000..03f83fc --- /dev/null +++ b/frontend/src/store/AppContext.tsx @@ -0,0 +1,416 @@ +import React, { createContext, useContext, useReducer, useEffect, useCallback, useMemo, useRef } from 'react'; +import type { Conversation, Folder, Profile, Settings, NotificationItem, Message, View } from '../types'; +import { generateId } from '../lib/utils'; +import { + loadConversations, saveConversations, + loadFolders, saveFolders, + loadPinnedIds, savePinnedIds, + loadProfile, saveProfile, + loadSettings, saveSettings, + loadNotifications, saveNotifications, + storage, KEYS, +} from '../lib/storage'; + +interface AppState { + view: View; + conversations: Conversation[]; + folders: Folder[]; + pinnedIds: string[]; + profile: Profile; + settings: Settings; + notifications: NotificationItem[]; + currentChatId: string | null; + selectedModel: string; + sidebarCollapsed: boolean; + sidebarMobileOpen: boolean; + backendAvailable: boolean; + dialog: DialogState; +} + +interface DialogState { + settings: boolean; + profile: boolean; + search: boolean; + share: boolean; + folderCreate: boolean; + folderEdit: string | null; + notifications: boolean; + confirmDelete: string | null; +} + +type Action = + | { type: 'SET_VIEW'; payload: View } + | { type: 'HYDRATE'; payload: Partial+ ++ + } + | { type: 'ADD_CONVERSATION'; payload: Conversation } + | { type: 'UPDATE_CONVERSATION'; payload: { id: string; updates: Partial } } + | { type: 'DELETE_CONVERSATION'; payload: string } + | { type: 'SET_CURRENT_CHAT'; payload: string | null } + | { type: 'ADD_MESSAGE'; payload: { chatId: string; message: Message } } + | { type: 'UPDATE_MESSAGE'; payload: { chatId: string; messageId: string; updates: Partial } } + | { type: 'DELETE_MESSAGE'; payload: { chatId: string; messageId: string } } + | { type: 'SET_MESSAGES'; payload: { chatId: string; messages: Message[] } } + | { type: 'TOGGLE_PIN'; payload: string } + | { type: 'SET_PINNED_ORDER'; payload: string[] } + | { type: 'ADD_FOLDER'; payload: Folder } + | { type: 'UPDATE_FOLDER'; payload: { id: string; updates: Partial } } + | { type: 'DELETE_FOLDER'; payload: string } + | { type: 'MOVE_TO_FOLDER'; payload: { chatId: string; folderId: string | null } } + | { type: 'UPDATE_PROFILE'; payload: Partial } + | { type: 'UPDATE_SETTINGS'; payload: Partial } + | { type: 'SELECT_MODEL'; payload: string } + | { type: 'TOGGLE_SIDEBAR'; payload?: boolean } + | { type: 'TOGGLE_MOBILE_SIDEBAR'; payload?: boolean } + | { type: 'ADD_NOTIFICATION'; payload: NotificationItem } + | { type: 'MARK_NOTIFICATION_READ'; payload: string } + | { type: 'CLEAR_NOTIFICATIONS' } + | { type: 'OPEN_DIALOG'; payload: Partial } + | { type: 'CLOSE_ALL_DIALOGS' } + | { type: 'SET_BACKEND_AVAILABLE'; payload: boolean }; + +const initialDialog: DialogState = { + settings: false, + profile: false, + search: false, + share: false, + folderCreate: false, + folderEdit: null, + notifications: false, + confirmDelete: null, +}; + +const initialState: AppState = { + view: 'chat', + conversations: [], + folders: [], + pinnedIds: [], + profile: { name: 'User', email: 'user@example.com' }, + settings: { + theme: 'dark', + language: 'en', + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + streaming: true, + animations: true, + sidebarCollapsed: false, + notifications: { enabled: true, sound: false, desktop: false }, + modelDefaults: { model: 'gemini-3.5-flash-thinking', temperature: 0.7, maxTokens: 4096, topP: 0.9 }, + shortcuts: { search: 'Ctrl+K', newChat: 'Ctrl+N', toggleSidebar: 'Ctrl+Shift+L', closeDialog: 'Esc' }, + }, + notifications: [], + currentChatId: null, + selectedModel: 'gemini-3.5-flash-thinking', + sidebarCollapsed: false, + sidebarMobileOpen: false, + backendAvailable: true, + dialog: initialDialog, +}; + +function reducer(state: AppState, action: Action): AppState { + switch (action.type) { + case 'SET_VIEW': + return { ...state, view: action.payload }; + + case 'HYDRATE': + return { ...state, ...action.payload }; + + case 'ADD_CONVERSATION': { + const newConvs = [action.payload, ...state.conversations]; + saveConversations(newConvs); + return { ...state, conversations: newConvs, currentChatId: action.payload.id, view: 'chat' }; + } + + case 'UPDATE_CONVERSATION': { + const updated = state.conversations.map(c => + c.id === action.payload.id ? { ...c, ...action.payload.updates, updatedAt: Date.now() } : c + ); + saveConversations(updated); + return { ...state, conversations: updated }; + } + + case 'DELETE_CONVERSATION': { + const filtered = state.conversations.filter(c => c.id !== action.payload); + saveConversations(filtered); + const newPinned = state.pinnedIds.filter(id => id !== action.payload); + savePinnedIds(newPinned); + const newCurrent = state.currentChatId === action.payload ? null : state.currentChatId; + return { + ...state, + conversations: filtered, + pinnedIds: newPinned, + currentChatId: newCurrent, + }; + } + + case 'SET_CURRENT_CHAT': + return { ...state, currentChatId: action.payload, view: 'chat' }; + + case 'ADD_MESSAGE': { + const updated = state.conversations.map(c => { + if (c.id !== action.payload.chatId) return c; + return { ...c, messages: [...c.messages, action.payload.message], updatedAt: Date.now() }; + }); + saveConversations(updated); + return { ...state, conversations: updated }; + } + + case 'UPDATE_MESSAGE': { + const updated = state.conversations.map(c => { + if (c.id !== action.payload.chatId) return c; + return { + ...c, + messages: c.messages.map(m => + m.id === action.payload.messageId ? { ...m, ...action.payload.updates } : m + ), + updatedAt: Date.now(), + }; + }); + saveConversations(updated); + return { ...state, conversations: updated }; + } + + case 'DELETE_MESSAGE': { + const updated = state.conversations.map(c => { + if (c.id !== action.payload.chatId) return c; + return { + ...c, + messages: c.messages.filter(m => m.id !== action.payload.messageId), + updatedAt: Date.now(), + }; + }); + saveConversations(updated); + return { ...state, conversations: updated }; + } + + case 'SET_MESSAGES': { + const updated = state.conversations.map(c => { + if (c.id !== action.payload.chatId) return c; + return { ...c, messages: action.payload.messages, updatedAt: Date.now() }; + }); + saveConversations(updated); + return { ...state, conversations: updated }; + } + + case 'TOGGLE_PIN': { + const isPinned = state.pinnedIds.includes(action.payload); + const newPinned = isPinned + ? state.pinnedIds.filter(id => id !== action.payload) + : [...state.pinnedIds, action.payload]; + savePinnedIds(newPinned); + return { ...state, pinnedIds: newPinned }; + } + + case 'SET_PINNED_ORDER': + savePinnedIds(action.payload); + return { ...state, pinnedIds: action.payload }; + + case 'ADD_FOLDER': { + const folders = [...state.folders, action.payload]; + saveFolders(folders); + return { ...state, folders }; + } + + case 'UPDATE_FOLDER': { + const folders = state.folders.map(f => + f.id === action.payload.id ? { ...f, ...action.payload.updates } : f + ); + saveFolders(folders); + return { ...state, folders }; + } + + case 'DELETE_FOLDER': { + const folders = state.folders.filter(f => f.id !== action.payload); + saveFolders(folders); + const convs = state.conversations.map(c => + c.folderId === action.payload ? { ...c, folderId: null } : c + ); + saveConversations(convs); + return { ...state, folders, conversations: convs }; + } + + case 'MOVE_TO_FOLDER': { + const convs = state.conversations.map(c => + c.id === action.payload.chatId ? { ...c, folderId: action.payload.folderId } : c + ); + saveConversations(convs); + return { ...state, conversations: convs }; + } + + case 'UPDATE_PROFILE': { + const profile = { ...state.profile, ...action.payload }; + saveProfile(profile); + return { ...state, profile }; + } + + case 'UPDATE_SETTINGS': { + const settings = { ...state.settings, ...action.payload }; + saveSettings(settings); + return { ...state, settings }; + } + + case 'SELECT_MODEL': + storage.set(KEYS.MODEL, action.payload); + return { ...state, selectedModel: action.payload }; + + case 'TOGGLE_SIDEBAR': { + const collapsed = action.payload ?? !state.sidebarCollapsed; + storage.set(KEYS.SIDEBAR_COLLAPSED, collapsed); + return { ...state, sidebarCollapsed: collapsed }; + } + + case 'TOGGLE_MOBILE_SIDEBAR': + return { ...state, sidebarMobileOpen: action.payload ?? !state.sidebarMobileOpen }; + + case 'ADD_NOTIFICATION': { + const notifications = [action.payload, ...state.notifications].slice(0, 50); + saveNotifications(notifications); + return { ...state, notifications }; + } + + case 'MARK_NOTIFICATION_READ': { + const notifications = state.notifications.map(n => + n.id === action.payload ? { ...n, read: true } : n + ); + saveNotifications(notifications); + return { ...state, notifications }; + } + + case 'CLEAR_NOTIFICATIONS': + saveNotifications([]); + return { ...state, notifications: [] }; + + case 'OPEN_DIALOG': + return { ...state, dialog: { ...state.dialog, ...action.payload } }; + + case 'CLOSE_ALL_DIALOGS': + return { ...state, dialog: initialDialog }; + + case 'SET_BACKEND_AVAILABLE': + return { ...state, backendAvailable: action.payload }; + + default: + return state; + } +} + +interface AppContextValue extends AppState { + dispatch: React.Dispatch ; + createNewChat: (modelOverride?: string) => string; + selectChat: (id: string) => void; + deleteChat: (id: string) => void; + pinChat: (id: string) => void; + notify: (type: NotificationItem['type'], title: string, message?: string) => void; + openDialog: (dialog: Partial ) => void; + closeDialogs: () => void; + getCurrentConversation: () => Conversation | null; +} + +const AppContext = createContext (null); + +export function AppProvider({ children }: { children: React.ReactNode }) { + const [state, dispatch] = useReducer(reducer, initialState); + const initialized = useRef(false); + + useEffect(() => { + if (initialized.current) return; + initialized.current = true; + const conversations = loadConversations(); + const folders = loadFolders(); + const pinnedIds = loadPinnedIds(); + const profile = loadProfile(); + const settings = loadSettings(); + const notifications = loadNotifications(); + const selectedModel = storage.get (KEYS.MODEL, settings.modelDefaults.model); + const sidebarCollapsed = storage.get (KEYS.SIDEBAR_COLLAPSED, false); + const currentChatId = storage.get (KEYS.CURRENT_CHAT_ID, null); + dispatch({ type: 'HYDRATE', payload: { conversations, folders, pinnedIds, profile, settings, notifications, selectedModel, sidebarCollapsed, currentChatId: conversations.length && conversations.some(c => c.id === currentChatId) ? currentChatId : null } }); + const applyTheme = (theme: Settings['theme']) => { + const root = document.documentElement; + const effective = theme === 'system' ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') : theme; + root.classList.toggle('dark', effective === 'dark'); + root.classList.toggle('light', effective === 'light'); + }; + applyTheme(settings.theme); + }, []); + + useEffect(() => { + if (state.currentChatId) storage.set(KEYS.CURRENT_CHAT_ID, state.currentChatId); + }, [state.currentChatId]); + + useEffect(() => { + const root = document.documentElement; + const effective = state.settings.theme === 'system' + ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') + : state.settings.theme; + root.classList.toggle('dark', effective === 'dark'); + root.classList.toggle('light', effective === 'light'); + }, [state.settings.theme]); + + const createNewChat = useCallback((modelOverride?: string): string => { + const id = generateId(); + const model = modelOverride || state.selectedModel || state.settings.modelDefaults.model; + const conv: Conversation = { + id, + title: 'New Chat', + messages: [], + model, + createdAt: Date.now(), + updatedAt: Date.now(), + folderId: null, + tags: [], + }; + dispatch({ type: 'ADD_CONVERSATION', payload: conv }); + return id; + }, [state.selectedModel, state.settings.modelDefaults.model]); + + const selectChat = useCallback((id: string) => { + dispatch({ type: 'SET_CURRENT_CHAT', payload: id }); + }, []); + + const deleteChat = useCallback((id: string) => { + dispatch({ type: 'DELETE_CONVERSATION', payload: id }); + }, []); + + const pinChat = useCallback((id: string) => { + dispatch({ type: 'TOGGLE_PIN', payload: id }); + }, []); + + const notify = useCallback((type: NotificationItem['type'], title: string, message?: string) => { + dispatch({ + type: 'ADD_NOTIFICATION', + payload: { id: generateId(), type, title, message, timestamp: Date.now(), read: false }, + }); + }, []); + + const openDialog = useCallback((dialog: Partial ) => { + dispatch({ type: 'OPEN_DIALOG', payload: dialog }); + }, []); + + const closeDialogs = useCallback(() => { + dispatch({ type: 'CLOSE_ALL_DIALOGS' }); + }, []); + + const getCurrentConversation = useCallback((): Conversation | null => { + if (!state.currentChatId) return null; + return state.conversations.find(c => c.id === state.currentChatId) || null; + }, [state.currentChatId, state.conversations]); + + const value = useMemo (() => ({ + ...state, + dispatch, + createNewChat, + selectChat, + deleteChat, + pinChat, + notify, + openDialog, + closeDialogs, + getCurrentConversation, + }), [state, createNewChat, selectChat, deleteChat, pinChat, notify, openDialog, closeDialogs, getCurrentConversation]); + + return {children} ; +} + +export function useApp() { + const ctx = useContext(AppContext); + if (!ctx) throw new Error('useApp must be used within AppProvider'); + return ctx; +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..9ce00b2 --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,86 @@ +export interface Message { + id: string; + role: 'user' | 'assistant'; + content: string; + timestamp: number; + thinking?: boolean; + error?: boolean; + tokenUsage?: TokenUsage; + edited?: boolean; + renderMode?: 'markdown' | 'json'; +} + +export interface TokenUsage { + promptTokens: number; + completionTokens: number; + totalTokens: number; +} + +export interface Conversation { + id: string; + title: string; + messages: Message[]; + model: string; + createdAt: number; + updatedAt: number; + pinned?: boolean; + folderId?: string | null; + tags?: string[]; + scrollPosition?: number; +} + +export interface Folder { + id: string; + name: string; + color?: string; + expanded?: boolean; + createdAt: number; +} + +export interface Profile { + name: string; + email: string; + avatar?: string; +} + +export interface Settings { + theme: 'dark' | 'light' | 'system'; + language: string; + timezone: string; + streaming: boolean; + animations: boolean; + sidebarCollapsed: boolean; + notifications: { + enabled: boolean; + sound: boolean; + desktop: boolean; + }; + modelDefaults: { + model: string; + temperature: number; + maxTokens: number; + topP: number; + }; + shortcuts: Record; +} + +export interface NotificationItem { + id: string; + type: 'success' | 'error' | 'info' | 'warning'; + title: string; + message?: string; + timestamp: number; + read?: boolean; +} + +export interface UploadedFile { + id: string; + name: string; + size: number; + type: string; + progress: number; + data?: string; + error?: string; +} + +export type View = 'chat' | 'settings' | 'profile' | 'models' | 'search'; diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..6830b6f --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..8455dcb --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..c4069b7 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react(), tailwindcss()], +}) diff --git a/test_openai.py b/test_openai.py new file mode 100644 index 0000000..7da9b2f --- /dev/null +++ b/test_openai.py @@ -0,0 +1,15 @@ +from openai import OpenAI + +client = OpenAI( + base_url="http://localhost:8081/v1", + api_key="dummy" +) + +response = client.chat.completions.create( + model="gemini-3.5-flash-thinking", + messages=[ + {"role": "user", "content": "What is 2+2?"} + ] +) + +print(response.choices[0].message.content)