From 30c0bbd1f2583c7ca4ae28ad5e5a9eb2951d7484 Mon Sep 17 00:00:00 2001 From: holybasil Date: Tue, 15 Apr 2025 15:31:47 +0800 Subject: [PATCH 01/16] feat: ai assistant --- package.json | 2 + src/app/globals.css | 11 +- src/app/layout.tsx | 13 +- src/assets/css/assistant-message.css | 89 +++++++++++ src/assets/images/common/ai-bot.png | Bin 0 -> 26139 bytes src/assets/images/common/scrolly-cool.png | Bin 0 -> 54475 bytes src/assets/svgs/header/close.svg | 3 + src/assets/svgs/header/enter.svg | 3 + src/assets/svgs/header/send.svg | 6 + src/assets/svgs/header/spin.svg | 3 + src/components/AIModal/AIInput.tsx | 51 ++++++ src/components/AIModal/AssistantMessage.tsx | 9 ++ src/components/AIModal/InitialPanel.tsx | 41 +++++ src/components/AIModal/MessagePanel.tsx | 38 +++++ src/components/AIModal/UserMessage.tsx | 25 +++ src/components/AIModal/actions.ts | 32 ++++ src/components/AIModal/index.tsx | 168 ++++++++++++++++++++ src/components/Header/AskAI.tsx | 37 +++++ src/components/Header/desktop_header.tsx | 4 +- src/components/Header/mobile_header.tsx | 10 +- src/constants/prompt.ts | 92 +++++++++++ src/stores/globalStore.ts | 19 +++ src/theme/options.ts | 4 + yarn.lock | 111 ++++++++++++- 24 files changed, 761 insertions(+), 10 deletions(-) create mode 100644 src/assets/css/assistant-message.css create mode 100644 src/assets/images/common/ai-bot.png create mode 100644 src/assets/images/common/scrolly-cool.png create mode 100644 src/assets/svgs/header/close.svg create mode 100644 src/assets/svgs/header/enter.svg create mode 100644 src/assets/svgs/header/send.svg create mode 100644 src/assets/svgs/header/spin.svg create mode 100644 src/components/AIModal/AIInput.tsx create mode 100644 src/components/AIModal/AssistantMessage.tsx create mode 100644 src/components/AIModal/InitialPanel.tsx create mode 100644 src/components/AIModal/MessagePanel.tsx create mode 100644 src/components/AIModal/UserMessage.tsx create mode 100644 src/components/AIModal/actions.ts create mode 100644 src/components/AIModal/index.tsx create mode 100644 src/components/Header/AskAI.tsx create mode 100644 src/constants/prompt.ts create mode 100644 src/stores/globalStore.ts diff --git a/package.json b/package.json index 728194187..71d93df8f 100644 --- a/package.json +++ b/package.json @@ -43,10 +43,12 @@ "immer": "^9.0.7", "lodash": "^4.17.21", "motion": "^11.18.1", + "nanoid": "^5.1.5", "next": "15.1.4", "notistack": "^3.0.1", "number-flip": "^1.2.3", "numbro": "^2.3.6", + "openai": "^4.94.0", "path-to-regexp": "^6.2.1", "react": "19.0.0", "react-awesome-reveal": "^4.2.5", diff --git a/src/app/globals.css b/src/app/globals.css index 49d886643..58b22364d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -5,6 +5,8 @@ @config "../../tailwind.config.ts"; +@import url(../assets/css/assistant-message.css); + @theme { --breakpoint-sm: 600px; --breakpoint-md: 900px; @@ -47,8 +49,15 @@ body { background-color: var(--theme-bg); } -body.mobile-top-nav-open { +body.disable-body-scroll { overflow: hidden !important; + padding-right: 15px !important; +} + +@media screen and (max-width: 900px) { + body.disable-body-scroll { + padding-right: 0 !important; + } } @font-face { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1b0607603..c0255a16c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,13 +2,14 @@ import { GoogleAnalytics } from "@next/third-parties/google" import { SpeedInsights } from "@vercel/speed-insights/next" import clsx from "clsx" import { Metadata } from "next" -import { Roboto } from "next/font/google" +import { Inter, Roboto } from "next/font/google" import localFont from "next/font/local" import React, { Suspense } from "react" import { AppRouterCacheProvider } from "@mui/material-nextjs/v15-appRouter" import InitColorSchemeScript from "@mui/material/InitColorSchemeScript" +import AIModal from "@/components/AIModal" import GlobalComponents from "@/components/GlobalComponents" import ScrollToTop from "@/components/ScrollToTop" import WebVitals from "@/components/WebVitals" @@ -30,6 +31,13 @@ const robotoFont = Roboto({ subsets: ["latin"], }) +const interFont = Inter({ + variable: "--font-inter", + weight: ["400", "500", "700"], + display: "swap", + subsets: ["latin"], +}) + const titleFont = localFont({ src: [ { @@ -55,7 +63,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) {/* */} - + @@ -64,6 +72,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) {children} + diff --git a/src/assets/css/assistant-message.css b/src/assets/css/assistant-message.css new file mode 100644 index 000000000..42f440d12 --- /dev/null +++ b/src/assets/css/assistant-message.css @@ -0,0 +1,89 @@ +.assistant-message { + font-size: 1.6rem; + line-height: 2.4rem; + font-family: var(--font-inter); +} + +.assistant-message p { + margin: 0 0 16px; + max-width: 100%; + line-height: 2.6rem; +} + +.assistant-message h1, +.assistant-message h2, +.assistant-message h3 { + font-weight: 700; + margin-bottom: 1.6rem; +} + +.assistant-message ol { + list-style-type: decimal; + margin-left: 1.8rem; + margin-bottom: 1.6rem; +} +.assistant-message > ol > li { + margin-bottom: 1.6rem; +} +.assistant-message > ol > li::marker { + font-weight: 700; +} +.assistant-message > ol > li p { + display: inline-block; + margin-bottom: 0; +} + +.assistant-message ul { + list-style-type: disc; + margin-left: 1.5rem; + margin-bottom: 1.6rem; +} + +.assistant-message li { + margin-bottom: 0.8rem; +} + +.assistant-message blockquote:not(:empty) { + border-left: 2px solid #9b9b9b; + background-color: #1010100d; + padding: 8px 8px 8px 16px; + margin-bottom: 1.6rem; +} +.assistant-message blockquote p:last-child { + margin-bottom: 0; +} + +.assistant-message pre { + background-color: #1010100d; + padding: 0.8rem 1.6rem; + border-radius: 0.4rem; + margin-bottom: 1.6rem; +} + +.assistant-message pre code { + background-color: transparent; + padding: 0; + margin: 0; + font-size: inherit; + line-height: inherit; +} + +.assistant-message code { + background-color: #1010100d; + padding: 0.2rem 0.4rem; + border-radius: 0.2rem; +} + +.assistant-message a { + color: #ff684b; + font-weight: 500; + text-decoration: underline; +} + +.assistant-message img { + background-color: transparent; +} + +.assistant-message strong { + font-weight: 700; +} diff --git a/src/assets/images/common/ai-bot.png b/src/assets/images/common/ai-bot.png new file mode 100644 index 0000000000000000000000000000000000000000..c3f8c17330daf6e11e09cda0d9e69eb6ec60991b GIT binary patch literal 26139 zcmce;g;!MF`v-bvsF9FCWI!5JP(niKE>QtNy1S8XsX}Nlhs}pkx(tGAi+zcg(01_Z3A{fzylxT9-SHEE-auerJzUk1ffx9ddk}7KR29N|o zIYP#|igfb3URlH$>#=V@ox_$?yDOgY0x;CT(9Wy#m&TmgXRa1b_HNs3C{?BSkfjvD z4sc>}&+r*-GFAC5f1_&0@;x(1iNm)Iw7!n^m?;SW<-Si^x=itFuSoZnxqKc%(%^9{ z(deS83L*+33H%@l^!WbRx?blK-V-B1k0o+&E`3wL=S4XlqePQ-8x;T% zT!B=M#Ot3Nbd?yhPeb88+z&J&NPF=YW#aL`r}Yp|b97~fk%EsycYhTguqGf(6P1#9 zUSIkS0!ND6bW)J2;GK`EqJgBEJ!|KSo6I83M&K|40`yJ-EUtPV6iyvAoFI(n&#b5t zCbKjmAmJi_mb$Y~S9AVqClDh+t|7e}Yyn{ufOvUuIouzKm)`o>ntC6QB;o^fh){fN z)w*#Iq-7+F#i(f-6*3BczC^5=>6hW>tR*PWP5CxihZr@4<0RMZVzV<6o&f<=vwVMz zWj*5oBJt<7IB|d&lk_)e8vxfBki3U@3-Sy>9!c+#lFRc#U?>21e_!H*jT`?>WnBaS zp#m83$t}{3VmgOGQv#BT(u~)oe)*eXpRnm>LO?1=Yo$AsIjw!H_#i3Z+YP9*&Dy^# zHXhLD0C>b3**Gdm03QffhqdOkVu`q2zv~)pqI&o0eK=cldS^(*>I(%ZBY*?6G6&D^ zw{zPhVxO8iXEDQ-wQpQ;l2Ng$%2*M7MUMrZpcO99c?~lN9x>RutALqMG8iO2^#jio zGp8gYmPkkLo41mpCQmU$fI#f%|2;8cyl{LY)`}e|OFRk%#Q}a_Zw=wD>vzHM3KZW9 zT`>P4ENUx+8I9E5U?*w~;~>CJ8dPND^{AR##jg1S_FWzrq^eH(Whw&uG-QC;Am@6t z?sW_&jQANIt15J}nFiDtC56kuP?H%D48_0iSSTkmloIeU%zcCBG-a&qh>x0J-eN># znCPCWhe?41klKoOT&FTLt}gC_QU<0Ucg?JK9rS%0E8F*xVRD9(cyxE@=jwp3U!N{zhC6AC9rj6*p{H}Qc0Tc ztcKk<4V3~yUDEt2>q07Zo#^Pl&^Qp_7e4T(m1GZjw)=17vo}O1m#;{c2?$%;k%`iV zLO#)Ey`-e4ZYbs$=*zDuSTA(!XOsN$aWYRKs6N0NHpnI64)_hFSS4gM#F zD22GiAOe5WZ5~GAAV4uQPRp`8Odu{yhe8L5zF)2pwxFNUq@VK>$qj1E{a)cfAMUvO zJ~aQl7+BRZm|7R6TV* zmb@3I3*efiLO|b7k7MBkwLqsfsi)^}uDqH>?_&%3`;j}YrmE1(Bib3(8R{st6|$pe z`<}j>7R0hU)A5tGI{NbM~vLMu$KBeffCEHMZO~G(zT& z(w&DI3x$*H#J5OM8H5fNsPb(Dpqr`AdEFsxX|{qI54*J@JWUAH{60 z7&WA-?b)d_fn`g63=!ZQYI^#X$(_Y2sK~DwhKi)7Z&*0{Ivp68+aG2KAU_O=o3>Oi zx=VRE@|G%lHM=kZ{W+Sw?l0j$(TEByn!+w6R2?RDU^s0Ev&Q0(m^hS&=Fr zDgR52_%(MKCve%jB7TsQN(7iQ-DD6virrtJf(NH90B39amnS(E08sM7?T%V@h;W%2 z&TDQE5GA)l0(Bp1{LUnvc#S?~qXyS1qdj;X@9KL41B;p;bu-(orZUT)%M42$ zk%z$R##J#OU@mJNE3T4vqd!SG?-?=4=X=xQ+OX#IcqoQIkYaLZ=%#`tTQm99jTYix zuWN1|5>QI-7-=2xIlC*t8Y~il{g~*fxRi4`p8-M zj2c>8VZ(g0*)a=$yYwao$jR;}E4-{ikWC8E1ZxD5fq-KA-Tq5Q7CwfOQ>PwDEnDxyCn34QEQ4Z-F0D9d~=awfkCp=G|87mh+7tt z2n;+%vNPnl8+%kC%u%un6>18PFgKw*JTQv+fgAN#N??SO2i2EKY5(tGHKlHvjKaU* zj4Oy{*=Dhl*~$9f6@Jt+Y0NbHNisdW6MC>FhB9J|G~b!w5*PgvY?|-Vl$smnJ*lEX zFmTTnj9A@^w+Z}&NPLn+`3`f7x>r3+hVkMl4_t=mzqe}QNJn0s?J`VC^r-q3!CxQh z;$x&!?Y}V5op%j^pHaqR!C3MBCc{P3qQuAprQ3?(k1@bqTc&Uu7o#zF1{^5^=QsF* z_m1UZOEGP1FlI9Uk8CYW#9?DLpO*8CF*NsbA1UVE4A%$?F=nE|0Q|j=x*3~vh}R+5 z$KPHwcnY4qg}(zhK$pY*a#>aY?20MOIIX_n!CGII4}~xgVRm!#bX}Ang@@JnQ*tuK|}=rMp!%J27$Oa zRt8CU9~hwEFC~huUaYPxgd;t5v5`{yakRum%TnNJ;|E%a3x$Y5!<2i$^M(ICv|ONF zI3<$udi843v@H4@g&`e-&vYh!wj;sfbMZ$)?;ZNnlMRBtNQ!s>T=KcOx$G0=>oT2( zm)=Gtey6>N?+5${hs#=v4gv~Ln;q}D@7W!0^-nQOze3{>VZ^20oY*%ildIIU4)hN@ zM@{^|qXqz%tPzBmsBci4b>yrpdl`o9?`i>;6rHnS4ezH-(bJ|lVr0J3Ws1&E&Q+(vxE+Xw;eGHttdyPs2bzf2m-tLDZpZ@ax@`O93t zrrvA$1D8!#CVL!wjzZ%JY)Vi_?>z3t02UzaG<$$>Fe=7cBZeuBL^ybU0HGw~0oEf? z+E~?hXv3}t6$j6U-U`2q8KQFsSKwuOY2a|b5JoXdwVnau6Ni7z5Z}aOL#7hjpDR7r zee0$Q0X@f-FmB$-klURH&c3XUaXGmVHm59nwS>y36=!{;;EV4fCHEqcKUcU zNC^3#B_PaxnlGULfE>oge|v~ESQIRO;L!hs9P#`0Y_omX6(!oqhVwt>UPt&u&VjeD z3{-fr*C4^A8FYX0?~^-NrDr?c8A|rN6|V7bGPDVltW^(M^)MdNnny9Q z?LHAlg_G05B6z6DHH<%BY)&3zBAA{&#y@=}aD^`&#ZGrVusID9a0FtO_)-#^(|dkv ztdt@e^WP$a{OWJhlVBxq%|XfiXB*EP)WdG>U*l8jgV?1elu4Kryi?n98w;u~&nc?@ zMvL9Ry7yc7nS^{J?qn(+#@V5sv&XaFL5MnmZ@j-6O4+>Kmt6U?f%u0Mc7+qq2CUc@ zb2W7s8*M+1#>KKy%?+~)V=KC1EWD|YZjiKP2p7*p>f|;oqp|BUTy{yrm;$>V!{wIRne1sd)@S~{z=}uq zce{}p&2N3RT*Fx)KWFgm8L$hgm3fcf`hUZ$gQA3RK`@Q-!X`YDcS1uQ!;iZ!&7!fT z9yo>lz-@X6#t`r~9hp)_5*Y1Y&GA0mm-=70Jf`vkWHCffkyvFTK(3>`aIqmxwxaRj zB-_qUQ1rDHhk2aj#6hFA@g+u(VnU;Q>E zLIr8HA6Z0Ktzdbq0BEi71aDz9FiVLMvO~6pR2%sJGK~aigV<;Gv0kDFM7d(PuEPq9 zKrZG1M%!O8g8?Q%{CaR<*Kc`amB;Lj-O8yK;u17!RFbBv+wSi2XWrHgJR};ZwX{E%X4ucpcOc4MzRQ4q{?My2S)6 z;I#e#;p-=t$l8B6p$R5M#eSVHNSiUNf~ysyQ^tPgmq86W7_){Cpyv4ySYTuf<0k}h z7$5^Bkir@dY*vD=bcDE*JrAVcSf|4{Fu=S>g^e@Fvo#9r^&?T*8OFSI3@ZQz*nquk zkcSGc<7e2pq`^i~2D?|KAH( zF0jzYI0}Za@J$7JtZs0zr3WBvVZ0yzJ>VRl2wMd#>3J$E4>kU$Q|5uu88b2rEhGZJ zx1eCe@41YCJ$r>zWv4q<0m1nAg-@-xw^tp;C1%($AQM9!w3!RY%VVtZBzqxWZxag$ zz;Eu59G=XqjEoMCoi~nSJPY{#Opx@-2@;4K zu|Dx_z+mix_CL705-N8rf6nRRFvDg5tI_IVBLs3v^vVK^V#ICOak}4fee83@Q!WnH zSI(+yNi^m`_|EEsVwOEXh>u_JR126bGU-d{KE$n5Da5E-vIgqx19eizN`Esa??|&k z7E|&8OVSo?l_htvu*sM(W)p}dviD=Mx)BXv`L2Gbz1TBXz)u@;2dk3L#+VuuhC z{#|#}z-EO1xhcU_%s}c7{KhVA749PMQn@P^FRaaMXT~aLLcnP={%$=3mILw{MD~tH z_7%&;e~lWO{(DKYKPLBrs?C%W zU~|iGX|2jD$U+k5dOB{S{%|6&DO86O;E-BgohyHpgNCD0uK*`TBXVQ290Xv@;>~-E zMo$u8`Il|d!7dX?OA^N!-NW{rD>jvGF(6|<;Axb+m-LIMqLikeKG~J--`3g!N#q(n1C~eTi=u$3+DRj`3qowaG9i0 z33qZKvEpuYz;^ME%M>t~2Tg*ss6I;i`uu0R|BJ(R<&bEGOSzWI6tD|8WDYyC*u}ST z0}Hc3-+ZQE0qRznU-VRUmhZQB$-7V7?E}X1mClkSbX9?ihS}BO%-8^*gP&J3bG@~R)IdT~dz-(OgiNmeY(CAjYYcd0 z`)bh7Mw&&kB4mE5M~BRsI`{OPMYAdq%)fsBR$|+lS$6!@c-n-D!D^U0 z{zj*CmH0@P`xul&l1*k++DC+v`seL;&=|h7x2OG={mKcC29L!8j?m_3I}g1Le+)`f z`M0ZHl7R)WZBDsWnP$0L+A|x)79Aj!%yahKN^04?!;JcDp^pwMVd;v1BBKx(w_GLIj~){H1*V*{4DGaH?Z z6q+52*3}M|gn+|}-wYs#*=A|#Ufz$Me>u@=T~ z7TT6CMf50!eH5QfqXMdBhcgBx2HaG$PC{J+PRAXsR~u>i@UG(&M=^+eX}9ocSB=&O zE4g@sPrpRO`8_8cE%b^82!W-i)_xA|N1S-;g%baIO);Gr-8>g%JF%uw9zH;_M>^4k zT`z^5BDldA7G7*IyTnLSks+g6BS4A3rT6TQ2@p^(mVBs5r^(emk;^eg}vt= z*6x@2-VT|{0*J|SXb04VlCoX6W78A?pqs&8#-c(^I>Y;HXTf~#_FTykl-rygmD2zP zV2zQ4m;HggiTkS84EMZWK)E@jmM_Nl5Wl@TtnjJ-=gms79_g9BITSzYqN>;i_&7E~ve?C_N zV>OEa+hJRLalm_)jo0uO+T0~Wt~BPIniVIySSzQ=7lI9t7K(y&e@nW zO#BX@mav!IwtQ-?VF3Y*8wQ3%%iQ%wOgyuj2l5AO4SH^!9#RI7Sdk0dikW)>L#6ln zKlmYObsVUk&Z|dnnMt_K#Q@*|z{vI5jJ2(uvHVU{x!&8@BpuxylC(;79>cBkpHQb~ zAGbf}i@wdQ>(#aZbBExWaG9+q3;b+A=4POZW^(?h?S>$j8o@n6ru)c{2~^gY?Gyi< zSSA%PP|%w&@9!w-#a@t5_Wd@{g0(a|x|<+1@TIptY}HwZ8DA>{<5TC(*l|m~wD$GU z#bbtA_r8?c8lShL%N&Fd&;EqQkD?d#?Qfd|a2|ubmWJCWfPO<*#hBzh&USF9NO0=e z1!=&YiR0O+hc8SX;LUMI3YqQEAiHzR2(BAaU8qwNS~T;3oUkyR&3YfEP&hC=wSZsO zm<4@FB%-O({2LrOm-HFX7Y?iN9RfF6{v9*sJ_F15B_>$F3DF_NAycNDwd?Hpu7Qq? zdqFlF$)!66=yQ(5SJF=@fbX^5+u*$DF5JwnNvMjYKkC_9S|DhAAHip@03d9^m>mB= zJ?QdJnj;ICZ>%3(im3ZYpaVrd;GCv(41Z_<+lZum6v*vMBZ<8nv>6yd(KXw4KEjhX z=$f|;MCJ@c+Xbv#FizAgd3zugG#ZC-4uashyc8Y4*{*YIM$>NfUnvy@ElJ<9e z>M;?T5ts7vJW0#n2OE`%L*_k#N8ou6eSbPmVak4>d)~}*W^m}CcQgnV)0Y!7JRCE< z*cccZjMAlkH%fR}yyIBTtFrPQAeOzK)J^~tP19e_nziY%UnD0bAJoEqkPvE~I%0xB z96)^^W#y6id#(enmI~f1@{W)L+s{m8(%Z*-312QwP5x>e34kH|&$Z2doJ0XV7{JI! z@4rK%v^{6=%&RX4Q-MOcCB-DT1stE&Y=XTs)jPp+mS`vgxN5X>8^LGwp&OXnKR!R( zN{{xPYUVS}xC@w$Hu?vh^8&K>UlLH~@sgSuEL8J|N@!Bg)|9Wc&$=wSBT7S>-8+vK ztf;?e486TJbc+JtB$5yX9N^DmIvA_`DrQs9%b))M)2WDl`~8bZyUMc7qec*WO@&`& z-bRbqnys4sDW?+C<#GzJf;vn0H16Scj&2V%6WCUy6bESOC9P+K_ZB?xas{yj~s z>2p6VNVz3%pWqoYuGlJ_C?cnRzw~kaVe$^6pFTH|LrAqzK zg4(F$f6=wOr<(t?u}{FNn!c8dp=Fr=;pj}rVXShS$hr4ZTw_LC!)p9^X7$Hm6%G$q zDgRatu+Y_lHk!q>%0OEr*SU~zgk(2TKpHby|2%w}W)a08W=pn4KA9>{b4>I@66Za^ zd@llG{iR4CVfAF-8{n%5I8Z{U!k=p?A0E$n9$E+De-XHNs1{I-QoJU7vfn&?XxXAJWuDoV@1m$tqM93W@-soWS0C8<7pxe(ps zGnMQa(Ao?D*}N4`B1+9--WHI9Ar+S2Co<=AT2~qN)(OnMkr_H#%Yye(g@!XT5x$LL zD7ku26cS8m=Q}*TSRO}fGp3qEv3<@p0{H6_5ctLvvmW3UwDp57gsuDa?K#kToHrRa zitezAvRIR;{aNQ_25{7!=f)2?gg3p%Gq(l_NdOY6n$G<{F4`}p!M!4QvC2gL;kA=> zpGG0aXO95M%*csFy#!E`p=hw;lq}e++C-aLR2rr|I~z!!qj(KLT3C=e&4A$z^5Juu z)*&Cybk5D!Epr5^{w;cyy13S9x~}-d^LAeW;-%-XBBOx6iJkREb&gLjA0M5PZk`tS zHKAT_w52CiXy|5-sgy6aN9iOH@$>IBvFB69WX{v%RG3Weo8lC%QJ}AH`?=}u=GBAA zSFPZCp1L*LJ$7J}!^6XlJ|y<0C>JPP*^6K4QwFf=j0MfK&4T3Edo7pSvs@0@RUJZp z7vZmW-Ud|ty+#Qvj810nh-(0VDGWt}JMSS>6c8}FPIDTR$O_lGqyvaNzoQhj#oLN|GfQ(4n^_;CFnW z=}u5EGgN~3^XQ(^NBWvAC*M-;?-=8aW^`C9;>vz&!4F;xNpxkaxUo_<(4J=M|0XnnX=Ki&A!Moa*a%{KG$2 ztpC!20X6z|6jPD$5Pyh57R)ZQLUV^}wCm{Siwr$H=u!ZwWp`KS6ZvjVQM z%l+U~y2=@G@+jQ_!z8?i_HDa$C)7}@)pgGE6EG}L8jbt{myWKkAL(Twn8ns&Qb5%~ zrT2&Rj#j;Z;?MvQ4)9$o3z}fmScCZN2zKzzH%6AS0L40pS4*fVj9DDqfz4Qfa_tYTyt=p-{a@Kj`mn#7guzxq=or>#E|n~DcY{&R3=c{uZaEw`AT@Ub?I z)t!Xq!W=GO#A@KUpN<0Gc}Ua;`7mpb>g%3~M24By)@9FDR<2ev(Q)}d^4}EelZeivCzyZ!B?Md z6aOSUeXTIulsf@tB`oZg;RtKMcm%BzlvIreIZ1#NL7A`c)=DzsZr0Z)D3l# z^Ze4}qcJLm%RUaC7OJnR3-QX{Y||OYz@yF~v>%9r2)n0h7Feu@G1;A zlpLa@2=gPG9gj(wSBl%HY>6du`xoaMiCJB;q0a4c{k6L|rcvJU`D)-P#lx!0jcr`+HV|f)YP7i6ovd^{0BtkL* zXUE3d%Gq7%wI-y`4$d3d-v%R+c>l3nY)T>4pz$OQn0}XAP&32~aBBFU5axriaGWil z8Cdyf<6BhJC9@0xI?8IGDh1G@-i415#rf{W`<9-*^^l`qJP|QCp~iex!z@0JASFwv z4M3S|p6gx%!aA{KcfZjQA@`V5~-! z_G1WsO`ehLwoH*O*U!=wyJC{=h}<&vvz&_ure2k%06d6q#F@Tybcg$$KZl{Jo&LPzw`TG>o=6ZFpZ`vf#j`TF>&R9bzU@K;5~$CIggXQizTqN0@pmhB`>Iqgr>A)np?8&r3+RemX$rFfy~ zguZMG&YlPpV083&lOOqeK34hWg^#kjQE7X?c{6)9+J-C_ zYXL^`oL+%z@R7~^aXJyoBr6g@&?j+B8|f(6m)a#E*NQh7Kb;HmA#z19t}ZVcS#sV-HN>Ue%H+COv(pg;{n6u5kxlFWs>^%vEdyrB{Z(BbM&>?c`3(=Bh0P7zF6cC03rnId(`H=!{ zx6kJqKkcM4&W%qTi*K!`Nu$mpMEy;m+)FXVXOd#lqtozW!AHESm1FIF8)o^1!&_;7 zbM9>Nzpb083M%2qgydBZbsv}L&8NS#{Z03K9xk?VAD#&OyPKe2!d9NL;^=>#yR@8O z`$3Ge@IPVRNO@w9QWS;8@>d zB7^=B=fA(V=N<(2pYP8au1}AMl5YK*CIb7Xk~zWm2g>>yMThtP2`qJQp1zTUyV#Zz}765Z4DG8xW>YLxYCUH@=;au4nM!} z<#IyvNm1>-5>TkqivQnNqhw{v@O&-9<77E&)MPn$nv^<<`7V*>A6~#`)H$l)>(<}# zdDO-E;ARAgJk#>7j|pZc!lxoPEw?uFXoYd(JmAbH`ZzTD*XM^^Hv`V=f$12f#DX%I zq(X4|lS)jw7YOw1QBS`lCMt=MgF-E;otq@>H~iY2Bg84!7U;4koaLiO!@P>cQNUg zb)ds6tAw|wv&7Ea<+(t@*=uRm*EX5KQYN71tU#a<+~Ej_mrx@lnqDc?!G$1}VK`Hn zby1?0+uUSh=V3^tl7`=0;JNZ0Obs;l@S7^(6X?!RFhJ|Tv=aiS4UNd)`{eQLXcEs@ zTmK!MXL3^YYoN{v5clY**SqMQ7*->GT1*(ot<+qGTFw4^db>_r?ZS)1=SQDJRK)ha zZBDz;!LPDT@!GM1mAE!NSW9v3S`ItOHJlt<%RhurZaO&2?hj7fKT@#F3@wqCh3$p2 zT3aU1KYqz{?-|%yi#3p9eTx-l?Tr$br>-k~ciA=bitd%kW+8{G?sGlq!~YJ}%DD4r zH9Y1lQ9F;cc7=TO@4Xlr^Sj1z;sN1Jbe?rq&m*?Fxf(y_=^h9Z$taB2URs(g$SrJc z*YKUVJ1Jg=p^}f&P9GAet|;Y~T3XFkmj5~J65IJav%*00kZGLgZj5{7^d3Ry>1^e& z^Z-Za9qf5RYfe!8u{&kSTX|1zPxjLfR6uM%B?j@`I@SyPn?}8QyG) zSz%==Y<{3qo~o-8lJCLtaak6O1=XgHGHAP98V1!BWSaC-sF843FX&lOlWLS^=gU$>BZUZaN zVIR9+9Ect*g&<#@{T-A>ifQsKd4L7M&wzW*1X8uRHM#3>xRdv}Vgu|znNNc$T_y>2{8Osn zWsdoi&rkydQUHdEL4ftspxCwS@IPNn96u(Bo$5tMDhLr?1HYcxz?%<`>bmcE7%k`7 z$EqBPur~GxfeV$jgl({ z9-l!5mcT?z8yDC3N$gfQ%R!{P*G;L}?K!IyzdivTj@z6Ak{N+^z2L^n)4MArQfMIp zs`vzi^_M|RwCdn#XS{~`uKsSNMB|)k`BRdPw%XR=8Q#1aBJH}} zmWokD)szDlHxl0>_+iE(RGn!4D$gb%%*((ohmBLim0eLU9tCn)5h#?T_ z7LiRN{4+f!w9=vbU_2A=)o#r3PX#XJ#Kq8bAkm+i3zd2;$>D70( zrUl)RUO3zn$XC`Zs1uA!G0IYm^|KqVVOX~~9VKpGl6;l-vRz_wbTp3mx}o1iKX^9d z$qD#z#1Rv>llzdt(We9>|NF-Wg#mGO%bY8*uXlsr`1gwfZT|!iJHfvuRu%Lf= zxHlRCeSzGtZV%kQc|pp3Tz`Vo^xC885)aW%gpZdQ2jJ>vB6LA)kfXG)24|*`>xKrz zHQndI(9JQ17rs+53(8|lPWX)@QP9aDMsZs_)sllFa zzPA-abcp1uSGIjqIT~tQzIi!p_B9^XKU}69((aysHTtxOJzQ7xL_;F*onCE)h)!um zCKXHR-^hf9`$DIe^iTVeMOONH?v1mlOl?JPm%zSjviY>}+us-%jC*`z zGO;DU@-VWEe}!GZP>nc7horHMb#CIkD@v}bfX|M&`P<+M%PEqHMDxMQW!+d?#QuRb zxGks`D`ph6XcxIFc~Zw9ucAD9A2+w5`-JAAvIV%GmiMq(Hw^B(rp5XJld<4+QgMxq zTlM&N3-v4Ee~?~+{E8)XV+3+f(F^S*nJw<2gY{uqOt8k{om0>mv%OCJyh`wU0`f;?$KnIw9 zojmR2Iuml`e;Td2IZ(vC1MK+e;%-tF*A@nMFDs?FEUX zFwRaJ_@Y5B;7#1RF&#Bj zJJCe`u4d^~W;1b&jHny?BAgGUO~-U`aTyOv_*=WPDNwwhKGnxv9#_9bis)RdNEaoS zy}SiON&V6@t1-2kz#%?%+FQCGA-NUYWVAq)#j$HDHUM$#J#CnN{;Ua}H$Lh+_d_Wv zC(X}PkS)_(xVbA}cH2m8uQNS@*Xv!Zl(uB;wMHQ#6p{1KyPG$M;d!krGCbp! zyOS+1o;<91Rl_M=z$G}gambCN=zk zhc4(5!eq2DAV_OE5KY82wytdke!s$sfIzf?hWYYbO|Bzi5_PZF=V-j z>IYBK59_zZwghd5puIN~-z9%ZDk28uh~P0OCV~9=TogkT^(M%zYB^rw-fXJo5#4M} zujv5s=o}SJQ9WOof?_)(Ua>}Cn1f5PGkU|(FFjUhfn%QIWQleA`Bs(8U9fc^T1%Ii zJJpH%XgM>Uu^GsNg3blv+dFwC$gMi=&Uqcw-EE(_KJ)F_!kf8G6;3bceZls1G1Vip zksD>RU1b5U!7kB*SwpX;_w@NLJDseAm-X$^l0U`vd#Y@Xm-^ant8T({pa`sqp-%KTPGa)(!E8nK4rMaG5?#+er5Wq+?VI+!w5sRhmnbsQKvN(OCQ_Syjj@>xzWRN3-18yQi^5tzed&rs5A1(BB? zwv2##-$x5AD{}(=N1yktwEF9m**)tGg-=ac`Zex4B$(GhX%h!ByL_ zN8BGPWC888NCA7^YhTbX&Zc5@W|{RJu!)&x>9ecYanK5WK{L{CLr26ucS~FwTCE5%4s$2-`NoO_ojzPo++es2+w%0^~IBk z8wG>@XNxJ?;e?~j4%QL;un4*T`8g>nSF_$>kV$)Cw5_PU^=swWCvma3h5q_U9#J)o zxs|8*j6M&lm&y@=J-HRVZ0aEfiA6Bb_n}Vp&Gh6DK_lB_KD}dct6ph$Fg1&x`k_=o zu0ev*sL;uuGDgE9PO|U9k^MyW{>l~C0ur>iGF^qrY5&ivW59EXCUDFRf|e5vregsmueiWIvc|@ ziAaIgzMFs7@NOJEj3J03?J1M;l=K*iT&+T-zNF73@LNS5o7531 zRvmN=o%F^z)aAIz?{g9~TiELtI=2oS|0)z{ds+-#I?n2!`FZJeYG`ExI3K57x*4=_ zU2-!IBN0ruAJq0B%0H-_&s-Gf*)=rgX0l>&_3bnC^ z8ZJMzp>fa8ekcIXb9(oyoL(wZce6X#+L%Ewcu%Eh-(#~0Kp@%02PxR! zD!*nTcAWLi_qiUfn|0Q(?I%KcF1V8Q!M!k`#iEUoSO=cd2yUjnmy>V+kFDgOU?`~5 zIe4hjxgRn(S-d!RH|1<;2Ju?(fx2eiy@n%uM$H-_e4C$sMssQ}oeSE*V^Jw@20s4h z(aFWU5h?#rsL?GUM(VU)p&kjC4B)wObf`=6rtfY-9e?$i(t4~~qwxE>-!JWksQnu& zGPNi2wP0P?JyG!BM6zOvxIJX?#|b;Yg+OW`12@MV* zzY)w;@WOkPY>>WKDTA~L+>P9^W53AXKAV^C{K^$@-}|V^(TlrH9y|i6-%k1#a}?LO ziKuwbwWV8T9fYiv00!}&FQj7(n0hSv1{zqbhVxtchZ!!9SgQkiLYg#c#od*=GX=xM znhyPU8_Z7`&R_7r7=B#_JMKJb-u{){`4s$khs!pZ-#YwR=3y_i?3}r0_xb0NbAw8R zL%;}nF`}6uG{nEz(5hmhYh^iM^g-eEp0_<@lq6Y5QfFMIG}Y+4-&axwo`Zg7&3Ap}ALh(V zCr--5%+T1P%JMnpM5E`XSLF^3DNYgY{9~M=rvt#~KXe06sZ~1ETng5_eU?(dUU830 z!@A$UpP|8ECo-C_2$=rFjSGHt*j*1(vD}>8`ko0Mr1;l8MWnH8RRQ)XkP;r_bZzm= z8NqWi409GS8on&x*)Bp5F&6O&%B65V26q*XSt0u-_m>IYGF43MD15{yKxi@*B28iA z?sA+smU{;6UMuN-5Q(-UHQ_Il8)q+jPSp6~VW$@65Iyw?q`_3Kct!|!u6ypEg`AyKW@;i-0&re!=S_HxobZ%+Bfj z?}^HM{pu309x}-J8rOt1sIFcMW&CGBKOxUm!<=0cV;w?cgn<-&8*ePSpJi>|fOvJz8H3H~t%#AXvBjZTvCR&EGB`cvtN$yGH9nZeyvpk4 z+Un=S1_s~l6y9CFV5pks;iOQ&*4h@^>Hg?b&k5)6I6+zh?xL%pv%lMA+EbIbk2`<& zn*BhughLGVKr3kzTYfvG)`SnyKwpYXVclNsw6B=m*e?Qo;wA|nmRmJ8Q{ex^;BLL` zmSP?6QFhNXIM)1`ZlRnPxUS82IsBQ zFt)jr`f5QkPbX^RU4Tmr779-~U~}?L<(y-EF?rj0VRN6==UBH_y_Zr&QhdOA})vj?cP}IUPbI)e5__Urs?t7+p&~@0N{1vsc^B_YWSh-zqh66`7MNh z3*6&%X_9c9EL%8BY1vhdBQMfAY{-7b=6@gaJudyf((Cl$C2EofT7W|&oSEU(kz;JM z-@gF6Da(`R_>+%=E3$3#)zI?*_&K`MMd;rU$_1JKr;aNRgtGho&)6eN$~q(^LZqx^ zEtM9Mvejfs3t0x)ml)aJNR(IEw+dMY8M_&TkU{o+U$TuY%V6erXT0xs|M|}|_qoft zpL6ax=eg&@M4|)-g)rAIh5#aAd5LSNGqMWYPw0E0)3A~v*l*=j+3O&oPbz68Is%@C-E=05OH{_FdI0YHAx!d}V8^fk=$*FtEQ?{X$Yt1)L1Kk;lW zC6oC1%%b!XquTSy(war9rxXMnrZcR>H0=?0KB$aZ@}UBuL5BQ;hchr_x9RJa{#r`V zrw2-|QiP`xB_FuOE;l4+?fjO-ZV%Z9gbXFzQ0*AW3?4C#j2=%H7qQjipXq zn~?!~r@;;0ls9;*SeP(D1p1U z{oNOW`o+GgwVvA~EAmciG!$`zFG{x2+@K_9j=jTIOKuizT-%U!l^K{8Ql? zoS=N-y>8+`Vvvh%?Fi%_-tK`p{ACuSTlrHigdba31 z|KppDtX#s=+QbnSv9oNv1HEd59g><*76)NGs4nH!pm3nmAeLm9z4O9PKNttB$Cp^0Op+OQtm3zt5C&);Ad=R0)C zw|!2lLIVt}5Sp7ihZJN(CJ<*@<*FrU9q>|Z{N@L6gJDzpzIlwG7!yKuac?S)K>+KM{x-`;p6z^F| z^X2i5EEowhIA_1LQS2721Z9*k9__W|oLi;wG#|ycG#hXM7kj z+57N>tSo^;mz0*4jxY2nHj7P{_DR-RdAa4=j~uRBURH>t%^nFY2;1aFl%-g^Zaj+A zb6j~^hkL$+B>mvJ_E2Sp53C9nU7r7r*};9iU9e7_G{Gp!!L8)Bv^d_|PBsi0-S*w6 zX}nwa>Tp9SEhE%=46%wyxXW*38u9tqYft(3PUZDfJmq=Tcc!*+;+W z%?(f05HO3%A%fux5<%GC_MySpZLPYtHq9r8gSH-4@FpaWHQ&N+g({O|^#pJ2=E}FF z&+NFr;dz<$%%M5ry0IQq^`~QRCYI|li?az2a-wpop~j2$QFCW{lF$8JQe;*VnP^7- z9f`c=)Ap&F=H{2Bt1e2J-|~|7J|Cd%R;43%Xko?OPc_*HUcm?Uv?X%SDx z;Z)6t_W7$bKLhXxP&8?NJ+r%V_eV^R8i9=!otP@??0wlOd&GCOyx_QZd0O=5(DAQQ zbv26k4|61)X7W8jM7dFaM*#7LXZ&|=D_FG8E1sp>V!osqFNsanI*-ww)}`mFoNYY$ zzq>aObF-K5^Wk#1%u_>LsHRtQ1hd^MziAu@Dl!n-IpLFFa!v)Lom6Dt_vICKZ-8uE zD%GZ1xrT#qKeeX@Nef4>z<5XTYyw;Cb&euACHI~QI5Kf*cAR-u-7#UEX`C2^~jG^39I%rNlfleiVk{1O@(=F^jcJ_pLe2M8f zHxw%GYZFwKvn+jv_sZq19>PYs-&NgH>4-@zwFUc5%$6@`7D0=`T-T-ahEW)QFBI7mwiA=e(nM9k$u+FS>bda$%pSviV~{X~XdAI8z; z`aZ?bjoHL;Ts6VV0fzfPvwRYiRz{O}^JPhHtE<$5E^Xr z9Yd6_-4wmKgoV6>Y4!vCJ0DX$%STwyzmLrLeH4EWq~uWeDNoq|Sl~>Hkt(q!$p^-9 z5F8sW^-a~$Pyzqrj?O@EaZbOlBwpzX6R9s$T|OU@A#9(G!>?0#PVRE)3VBMnKyR{K zkNUXJ+xecsO`lV#cws>Ha#VEK-Mi4a=fwc^nKy$9uQ6 zqi$iLLG_Lp|6cT1De07~5a z%Tk|JEhN7KQ5YmPYS}xHW2U5 zejM^RIYipUMDd`W=|fc`BDa%-kiZCya?&h&%6$n*Gj0_imaob}pO|pxl{L&TV418e zM?zgX10_6W=SLp=oN)P6WMfh#B+QG_e&>q9(BFsBp4s}Rv02Qo(X!i`{!}kFbc2oD zu=Rg4{}xLAbq?vbq}>Wq8!{~{ILyVnd&@gTzJ)H0M;GoCRd9l`7M|osVFLsaZ2SqAIi=zEnFR~FpTh7KN`Ae zPizXgM7RV#`>s458eo1gI=U$(4YeGgteLvCtVg;qOvjRZh>VfdAy?c5c}r%%jr(0i z!=;4HBJ>yd^PYYyFI^9XVxa(YC|2|t<7~5`DU_CyIyMS6|JlZ%iPC~uSl+%Dh?%yo6TX*)@_o~**2 z0b1Kr{_|-FpPcJNK@~4II;`jP%q1BJq=@2+=fJM8YsV?ti|0Owk>}%jK5`rpOt{DK zyyp?ZG2t8Es8tZxMSUg)R8$8A4OfYCZJKpmSwh6s)`E_6uve#W_bwZs*FgejO{lg|-2@$sYUrXUAY)9>OxHPH6xhe9v7ws+;J9M~Yo3~EAcG}CipAuh?tAC_!gf96vK ztuZ~QV9m&Lt&4p)Btb{+s!nfStA=i$bSe~2ilfwQCs%DOdZn(8Jd)#m#SxEgXM(tV zeQsQ3oaOI#Sp2w|8^eH_Pc0z2d-+OL_4MYx7@yR&KGZ*UE7b!ZxDr}7+4);Gpmv~` zYiGl^_-6wrDo(YOtbw^|JW~$a@&fW_$S5i^Flt~^H+mhWYBQGc*=c52x_F~uCh+)f z84G-F0vMCNpxwDpx|Pz6DP{8H(zK)|G^>f5SF=K@|qbLpJSHY?0fQhGf~HIal06Wy|#z!&^Snd5kKu&O6C5AvR=eaz3=Xa0RG+abs}E4fo3z83DmdvIk6w-$FhmQy$B|JMmGg$+7ymkmk%V z(ZD0ISZJtzi2h(#)YqHk6s^)&6HwavFy$hpbI71vNl3+YtM9%d(c*=sy$X`(k*I

?Bav^lHs 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 literal 0 HcmV?d00001 diff --git a/src/assets/images/common/scrolly-cool.png b/src/assets/images/common/scrolly-cool.png new file mode 100644 index 0000000000000000000000000000000000000000..5c33069b32ff2bdf899840d11c3f317b8fd866dc GIT binary patch literal 54475 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%0DyJFAoZ@D+I_j~)LPU2BfcGG)E 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 + + diff --git a/src/assets/svgs/header/enter.svg b/src/assets/svgs/header/enter.svg new file mode 100644 index 000000000..924766945 --- /dev/null +++ b/src/assets/svgs/header/enter.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/svgs/header/send.svg b/src/assets/svgs/header/send.svg new file mode 100644 index 000000000..e5c2022ef --- /dev/null +++ b/src/assets/svgs/header/send.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/svgs/header/spin.svg b/src/assets/svgs/header/spin.svg new file mode 100644 index 000000000..677d007d4 --- /dev/null +++ b/src/assets/svgs/header/spin.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/AIModal/AIInput.tsx b/src/components/AIModal/AIInput.tsx new file mode 100644 index 000000000..3ef754d99 --- /dev/null +++ b/src/components/AIModal/AIInput.tsx @@ -0,0 +1,51 @@ +import { IconButton, Stack, TextareaAutosize } from "@mui/material" + +import SendSvg from "@/assets/svgs/header/send.svg" + +const AIInput = props => { + const { disabled, onChat, ...restProps } = props + + const handleKeyDown = e => { + if (e.key === "Enter" && !e.shiftKey && !disabled) { + e.preventDefault() + onChat() + } + } + + return ( + + + + + + + ) +} + +export default AIInput diff --git a/src/components/AIModal/AssistantMessage.tsx b/src/components/AIModal/AssistantMessage.tsx new file mode 100644 index 000000000..0ee5cb970 --- /dev/null +++ b/src/components/AIModal/AssistantMessage.tsx @@ -0,0 +1,9 @@ +import ReactMarkdown from "react-markdown" +import remarkGfm from "remark-gfm" + +const AssistantMessage = props => { + const { children } = props + return +} + +export default AssistantMessage diff --git a/src/components/AIModal/InitialPanel.tsx b/src/components/AIModal/InitialPanel.tsx new file mode 100644 index 000000000..94df09a7a --- /dev/null +++ b/src/components/AIModal/InitialPanel.tsx @@ -0,0 +1,41 @@ +import Image from "next/image" + +import { Stack, Typography } from "@mui/material" + +import ScrollyCool from "@/assets/images/common/scrolly-cool.png" +import EnterSvg from "@/assets/svgs/header/enter.svg" + +const LIST = [ + "this could be a general question", + "this could be a very long long long long long", + "this could be a very long long long long long long long long long long long long long question", +] + +const InitialPanel = () => { + return ( + + Scrolly + Welcome, Scroll AI is here to help! + {LIST.map((item, index) => ( + + + {item} + + ))} + + ) +} + +export default InitialPanel diff --git a/src/components/AIModal/MessagePanel.tsx b/src/components/AIModal/MessagePanel.tsx new file mode 100644 index 000000000..1551f6beb --- /dev/null +++ b/src/components/AIModal/MessagePanel.tsx @@ -0,0 +1,38 @@ +import { useEffect, useRef } from "react" + +import { Box } from "@mui/material" + +import SpinSvg from "@/assets/svgs/header/spin.svg" + +import AssistantMessage from "./AssistantMessage" +import UserMessage from "./UserMessage" + +const MessagePanel = props => { + const { data, loading } = props + + const bottomRef = useRef(null) + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }) + }, [data]) + + if (!data || data.length === 0) { + return null + } + + return ( + + + {data.map(message => ( + + {message.type === "input_text" ? {message.text} : {message.text}} + + ))} + + {loading && } +

+ + ) +} + +export default MessagePanel diff --git a/src/components/AIModal/UserMessage.tsx b/src/components/AIModal/UserMessage.tsx new file mode 100644 index 000000000..e7a924ee6 --- /dev/null +++ b/src/components/AIModal/UserMessage.tsx @@ -0,0 +1,25 @@ +import { Box, Typography } from "@mui/material" + +const UserMessage = props => { + const { children } = props + return ( + + + {children} + + + ) +} + +export default UserMessage diff --git a/src/components/AIModal/actions.ts b/src/components/AIModal/actions.ts new file mode 100644 index 000000000..69ffe16a7 --- /dev/null +++ b/src/components/AIModal/actions.ts @@ -0,0 +1,32 @@ +"use server" + +import OpenAI from "openai" + +import prompt from "@/constants/prompt" + +const openai = new OpenAI({ + apiKey: process.env.AI_KEY as string, +}) + +type InputMessage = { + role: "developer" | "user" | "assistant" + content: string +} + +export const chatWithAI = async ({ message, prevId }: { message: string; prevId?: string }) => { + const input = [...(prevId ? [] : [{ role: "developer", content: prompt }]), { role: "user", content: message }] + + const response = await openai.responses.create({ + model: "gpt-4o-mini", + tools: [{ type: "web_search_preview" }], + input: input as InputMessage[], + previous_response_id: prevId ?? null, + }) + return { + id: response.id, + msgId: response.output.find(item => item.type === "message")?.id, + status: response.status, + error: response.error, + message: response.output_text, + } +} diff --git a/src/components/AIModal/index.tsx b/src/components/AIModal/index.tsx new file mode 100644 index 000000000..1f4aa704b --- /dev/null +++ b/src/components/AIModal/index.tsx @@ -0,0 +1,168 @@ +"use client" + +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { AnimatePresence, motion } from "motion/react" +import { nanoid } from "nanoid" +import Image from "next/image" +import React, { useEffect, useState } from "react" + +import { Box, Card, IconButton, Stack, Typography } from "@mui/material" + +import AIBot from "@/assets/images/common/ai-bot.png" +import CloseSvg from "@/assets/svgs/header/close.svg" +import useCheckViewport from "@/hooks/useCheckViewport" +import useGlobalStore from "@/stores/globalStore" + +import AIInput from "./AIInput" +import InitialPanel from "./InitialPanel" +import MessagePanel from "./MessagePanel" +import { chatWithAI } from "./actions" + +const MotionCard = motion(Card) + +const AIModal = () => { + const { aiModalVisible, changeAIModalVisible } = useGlobalStore() + + const { isMobile } = useCheckViewport() + + const [searchText, setSearchText] = useState("") + + const [responseId, setResponseId] = useState() + + useEffect(() => { + if (aiModalVisible) { + queryClient.setQueryData(["messages"], []) + window.document.body.classList.add("disable-body-scroll") + } else { + setResponseId(undefined) + setSearchText("") + queryClient.removeQueries({ queryKey: ["messages"] }) + window.document.body.classList.remove("disable-body-scroll") + } + }, [aiModalVisible]) + + const queryClient = useQueryClient() + + const messages = queryClient.getQueryData(["messages"]) as any[] + + const { mutateAsync: sendMessageAsync, isPending } = useMutation({ + mutationFn: chatWithAI, + onError: error => { + queryClient.setQueryData(["messages"], (preMessages: any[]) => { + return preMessages.concat({ + id: nanoid(), + type: "output_text_error", + text: "Something went wrong, please try again.", + }) + }) + }, + }) + + // const { + // data: messages, + // isFetching, + // refetch: refetchMessages, + // } = useQuery({ + // queryKey: ["messages"], + // queryFn: async () => { + // const response = await scrollRequest(`${AI_CHAT_URL}/${responseId}`) + // return response + // }, + // enabled: !!responseId, + // initialData: [], + // }) + + // console.log(messages, "messages") + + const handleChangeSearchText = e => { + setSearchText(e.target.value) + } + + const handleSendMessage = async () => { + queryClient.setQueryData(["messages"], (preMessages: any[]) => { + return preMessages.concat({ + id: nanoid(), + type: "input_text", + text: searchText.trim(), + }) + }) + setSearchText("") + + const response = await sendMessageAsync({ + message: searchText.trim(), + prevId: responseId, + }) + + // const msgResponse = response.output.find(item => item.type === "message") + queryClient.setQueryData(["messages"], (preMessages: any[]) => { + return preMessages.concat({ + id: response.msgId, + type: "output_text", + text: response.message, + }) + }) + setResponseId(response.id) + } + + return ( + + {aiModalVisible ? ( + + + {!!messages?.length ? ( + <> + AI bot + Scroll AI + + ) : ( + + )} + + changeAIModalVisible(false)}> + + + + {messages?.length ? : } + + + + + ) : null} + + ) +} + +export default AIModal diff --git a/src/components/Header/AskAI.tsx b/src/components/Header/AskAI.tsx new file mode 100644 index 000000000..8cd38507c --- /dev/null +++ b/src/components/Header/AskAI.tsx @@ -0,0 +1,37 @@ +import { ButtonBase } from "@mui/material" + +import useGlobalStore from "@/stores/globalStore" + +const AskAI = props => { + const { isMobile } = props + const { aiModalVisible, changeAIModalVisible } = useGlobalStore() + + const handleToggleAIModal = () => { + if (aiModalVisible) { + changeAIModalVisible(false) + window.document.body.classList.remove("disable-body-scroll") + } else { + changeAIModalVisible(true) + window.document.body.classList.add("disable-body-scroll") + } + } + + return ( + + {isMobile ? "Ask AI" : "Ask Scroll AI"} + + ) +} + +export default AskAI diff --git a/src/components/Header/desktop_header.tsx b/src/components/Header/desktop_header.tsx index 05acb1549..0392b13cb 100644 --- a/src/components/Header/desktop_header.tsx +++ b/src/components/Header/desktop_header.tsx @@ -7,10 +7,10 @@ import ScrollLink from "@/components/Link" import Logo from "@/components/ScrollLogo" import WalletToolkit from "@/components/WalletToolkit" import useCheckViewport from "@/hooks/useCheckViewport" -import useShowLanguageSelect from "@/hooks/useShowLanguageSelect" import useShowWalletConnector from "@/hooks/useShowWalletToolkit" import { isSepolia } from "@/utils" +import AskAI from "./AskAI" import GasPriceViewer from "./GasPriceViewer" import MenuItem from "./MenuItem" import NavbarItem from "./NavbarItem" @@ -27,7 +27,6 @@ const DesktopHeader = ({ currentMenu }) => { const [hoveringNavbarItemKey, setHoveringNavbarItemKey] = useState("") const showWalletConnector = useShowWalletConnector() - const showLanguageSelect = useShowLanguageSelect() const [anchorEl, setAnchorEl] = useState(null) @@ -170,6 +169,7 @@ const DesktopHeader = ({ currentMenu }) => { {!isSepolia && } {showWalletConnector && } + {!isSepolia && } diff --git a/src/components/Header/mobile_header.tsx b/src/components/Header/mobile_header.tsx index 5c0b10b71..696a8c07a 100644 --- a/src/components/Header/mobile_header.tsx +++ b/src/components/Header/mobile_header.tsx @@ -8,8 +8,10 @@ import Link from "@/components/Link" import WalletToolkit from "@/components/WalletToolkit" import useShowLanguageSelect from "@/hooks/useShowLanguageSelect" import useShowWalletConnector from "@/hooks/useShowWalletToolkit" +import { isSepolia } from "@/utils" import Logo from "../ScrollLogo" +import AskAI from "./AskAI" import MenuItem from "./MenuItem" import MobileGasPriceViewer from "./MobileGasPriceViewer" import MobileNavbarItem from "./MobileNavBarItem" @@ -40,9 +42,9 @@ const MobileHeader = ({ currentMenu }) => { useLayoutEffect(() => { if (open) { - window.document.body.classList.add("mobile-top-nav-open") + window.document.body.classList.add("disable-body-scroll") } else { - window.document.body.classList.remove("mobile-top-nav-open") + window.document.body.classList.remove("disable-body-scroll") } }, [open]) @@ -144,7 +146,7 @@ const MobileHeader = ({ currentMenu }) => { {showWalletConnector && } - + {!isSepolia && } { > {renderList()} - + {!isSepolia && } )} diff --git a/src/constants/prompt.ts b/src/constants/prompt.ts new file mode 100644 index 000000000..37b5a901c --- /dev/null +++ b/src/constants/prompt.ts @@ -0,0 +1,92 @@ +const prompt = ` +**1. Role Definition:** + +You are an AI bot for the [Scroll.io](http://scroll.io/) project. Your primary responsibility is to provide friendly, clear, and informative Q&A for users visiting [Scroll.io](https://scroll.io/). Most user questions will focus on [Scroll.io](http://scroll.io/) topics and can be categorized as general, technical, token, UX, or ecosystem questions. + +**2. Knowledge Priority:** + +- **Primary Sources:** + - All content from official [Scroll.io](http://scroll.io/) pages and their subdomains. + - Content from Scroll’s official Twitter account: https://x.com/scroll_zkp. + - Blockchain information from ScrollScan: https://scrollscan.com/. +- **Secondary Source:** + - Perform web searches using the keywords “Scroll”, “Crypto”, and the user’s input (user_input) as constraints. +- **Fallback:** + - Only answer a user’s question if you can retrieve relevant information from the above sources or make a well-founded prediction based on them. Otherwise, respond with: + + > "I only can answer Scroll related questions yet. You can try to ask something else!" + > + +**3. Output Requirements:** + +- **Chain-of-Thought Display:** + - **For complex or multi-part questions:** + - Begin your answer with a brief chain-of-thought enclosed in a quoted block (using \`>\`). + - **Important:** Place the chain-of-thought in a separate quoted block followed by a newline before displaying the final answer. + - **For simple questions:** + - Provide the final answer directly without displaying your chain-of-thought. +- **Word Limit:** + - Ensure your complete answer is within 400 words. +- **Structure and Formatting:** + - Use clear structure with proper line breaks and highlight key points. + - Format your answer using Markdown. +- **Citations and Hyperlinks:** + - If your answer includes any direct quotes, provide the reference and clickable link for each quote. + - Any mentioned webpages must be directly linked (e.g., [scroll official website](https://scroll.io/)). + +**4. Sample Q&A – Format and Style Guidance:** + +### Example 1: General Question (Simple Case) + +- **User Question:** "What is Scroll?" +- **Response:** + + > Scroll is a project focused on providing scalable and secure blockchain solutions. For more details, please visit the official website and check our latest updates on Twitter. + > + +### Example 2: Technical Question (Simple Case) + +- **User Question:** "How can I view transaction data on Scroll?" +- **Response:** + + > You can view transaction data on Scroll by visiting ScrollScan. This platform provides comprehensive blockchain information including transaction histories and network statistics. + > + +### Example 3: Technical Issue – Running a Node (Complex Case) + +- **User Question:** "I'm encountering errors like \`unknown ancestor\` and \`Unexpected queue index in SyncService\` while running a node." +- **Response:** + + > Chain-of-Thought: Recognize the error as a known issue linked to unstable L1 node connections. Identify that the solution involves resetting the L1 height using the geth console and referencing a specific block number from historical data. + > + > + > To resolve the issue, first find an L1 block number roughly 15 minutes before the error occurred. Then, in the geth console, execute: + > + > \`let resetBlockHeight = ; admin.setL1MessageSyncedL1Height(resetBlockHeight);\` + > + > For further details, please refer to the [issue reference](https://scrollco.slack.com/archives/C07MFNLSQCD/p1734593749064919). + > + +**5. Potential Hard Cases and Corner Cases:** + +1. **Ambiguous Queries:** + - *Example:* "Tell me about the latest updates." + - *Issue:* Unclear whether the query refers to Scroll.io-specific updates or general crypto news. + - *Mitigation:* Ask clarifying questions or restrict responses to [Scroll.io](http://scroll.io/) knowledge sources. +2. **Out-of-Scope Questions:** + - *Example:* "What is the best crypto investment strategy?" + - *Issue:* This falls outside [Scroll.io](http://scroll.io/)'s domain. + - *Mitigation:* Respond with the fallback message: + + > "I only can answer Scroll related questions yet. You can try to ask something else!" + > +3. **Complex Multi-Part Queries:** + - *Example:* "Can you compare Scroll’s scalability with other blockchain projects and detail the technical differences?" + - *Issue:* Detailed technical comparisons might exceed the 400-word limit or lead to opinion-based responses. + - *Mitigation:* Provide a summarized comparison, cite reliable sources, and suggest contacting the Scroll team for more in-depth information if necessary. +4. **Direct Quotes and Citations:** + - *Requirement:* Verify any direct quote’s source and include the proper reference and clickable URL. + +` + +export default prompt diff --git a/src/stores/globalStore.ts b/src/stores/globalStore.ts new file mode 100644 index 000000000..cd5caa974 --- /dev/null +++ b/src/stores/globalStore.ts @@ -0,0 +1,19 @@ +import { create } from "zustand" + +interface GlobalStore { + aiModalVisible: boolean + + changeAIModalVisible: (visible: boolean) => void +} + +const useGlobalStore = create()((set, get) => ({ + aiModalVisible: false, + + changeAIModalVisible: visible => { + set({ + aiModalVisible: visible, + }) + }, +})) + +export default useGlobalStore diff --git a/src/theme/options.ts b/src/theme/options.ts index ef1ee3d58..59cffc7b1 100644 --- a/src/theme/options.ts +++ b/src/theme/options.ts @@ -200,6 +200,10 @@ export const typographyOptions = { fontFamily: "var(--font-developer)", }, + inter: { + fontFamily: "var(--font-inter)", + }, + H1: { fontSize: "6.4rem", lineHeight: "normal", diff --git a/yarn.lock b/yarn.lock index 189e00f0d..ebfe6b250 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4006,6 +4006,14 @@ dependencies: "@types/node" "*" +"@types/node-fetch@^2.6.4": + version "2.6.12" + resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.12.tgz#8ab5c3ef8330f13100a7479e2cd56d3386830a03" + integrity sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA== + dependencies: + "@types/node" "*" + form-data "^4.0.0" + "@types/node@*", "@types/node@^20.10.6": version "20.11.5" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.5.tgz#be10c622ca7fcaa3cf226cf80166abc31389d86e" @@ -4023,6 +4031,13 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.45.tgz#2c0fafd78705e7a18b7906b5201a522719dc5190" integrity sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw== +"@types/node@^18.11.18": + version "18.19.86" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.86.tgz#a7e1785289c343155578b9d84a0e3e924deb948b" + integrity sha512-fifKayi175wLyKyc5qUfyENhQ1dCNI1UNjp653d8kuYcPQN5JhX3dGuP/XmvPTg/xRBn1VTLpbmi+H/Mr7tLfQ== + dependencies: + undici-types "~5.26.4" + "@types/parse-json@^4.0.0": version "4.0.2" resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.2.tgz#5950e50960793055845e956c427fc2b0d70c5239" @@ -4697,6 +4712,13 @@ agent-base@6: dependencies: debug "4" +agentkeepalive@^4.2.1: + version "4.6.0" + resolved "https://registry.yarnpkg.com/agentkeepalive/-/agentkeepalive-4.6.0.tgz#35f73e94b3f40bf65f105219c623ad19c136ea6a" + integrity sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ== + dependencies: + humanize-ms "^1.2.1" + aggregate-error@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a" @@ -4928,6 +4950,11 @@ async-mutex@^0.2.6: dependencies: tslib "^2.0.0" +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + atomic-sleep@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz#eb85b77a601fc932cfe432c5acd364a9e2c9075b" @@ -5504,6 +5531,13 @@ colorette@^2.0.7: resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a" integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w== +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + comma-separated-tokens@^2.0.0: version "2.0.3" resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz#4e89c9458acb61bc8fef19f4529973b2392839ee" @@ -5882,6 +5916,11 @@ defu@^6.1.3, defu@^6.1.4: resolved "https://registry.yarnpkg.com/defu/-/defu-6.1.4.tgz#4e0c9cf9ff68fe5f3d7f2765cc1a012dfdcb0479" integrity sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg== +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + denque@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/denque/-/denque-2.1.0.tgz#e93e1a6569fb5e66f16a3c2a2964617d349d6ab1" @@ -7046,6 +7085,29 @@ for-each@^0.3.3: dependencies: is-callable "^1.1.3" +form-data-encoder@1.7.2: + version "1.7.2" + resolved "https://registry.yarnpkg.com/form-data-encoder/-/form-data-encoder-1.7.2.tgz#1f1ae3dccf58ed4690b86d87e4f57c654fbab040" + integrity sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A== + +form-data@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.2.tgz#35cabbdd30c3ce73deb2c42d3c8d3ed9ca51794c" + integrity sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + es-set-tostringtag "^2.1.0" + mime-types "^2.1.12" + +formdata-node@^4.3.2: + version "4.4.1" + resolved "https://registry.yarnpkg.com/formdata-node/-/formdata-node-4.4.1.tgz#23f6a5cb9cb55315912cbec4ff7b0f59bbd191e2" + integrity sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ== + dependencies: + node-domexception "1.0.0" + web-streams-polyfill "4.0.0-beta.3" + forwarded-parse@2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/forwarded-parse/-/forwarded-parse-2.1.2.tgz#08511eddaaa2ddfd56ba11138eee7df117a09325" @@ -7749,6 +7811,13 @@ human-signals@^5.0.0: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-5.0.0.tgz#42665a284f9ae0dade3ba41ebc37eb4b852f3a28" integrity sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ== +humanize-ms@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/humanize-ms/-/humanize-ms-1.2.1.tgz#c46e3159a293f6b896da29316d8b6fe8bb79bbed" + integrity sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ== + dependencies: + ms "^2.0.0" + husky@^8.0.2: version "8.0.3" resolved "https://registry.yarnpkg.com/husky/-/husky-8.0.3.tgz#4936d7212e46d1dea28fef29bb3a108872cd9184" @@ -9464,6 +9533,18 @@ micromatch@^4.0.4, micromatch@^4.0.5: braces "^3.0.2" picomatch "^2.3.1" +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-types@^2.1.12: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + mime@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/mime/-/mime-3.0.0.tgz#b374550dca3a0c18443b0c950a6a58f1931cf7a7" @@ -9639,7 +9720,7 @@ ms@2.1.2: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -ms@^2.1.1, ms@^2.1.3: +ms@^2.0.0, ms@^2.1.1, ms@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== @@ -9681,6 +9762,11 @@ nanoid@^3.3.8: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf" integrity sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w== +nanoid@^5.1.5: + version "5.1.5" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-5.1.5.tgz#f7597f9d9054eb4da9548cdd53ca70f1790e87de" + integrity sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw== + napi-wasm@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/napi-wasm/-/napi-wasm-1.1.0.tgz#bbe617823765ae9c1bc12ff5942370eae7b2ba4e" @@ -9737,6 +9823,11 @@ node-addon-api@^7.0.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.0.0.tgz#8136add2f510997b3b94814f4af1cce0b0e3962e" integrity sha512-vgbBJTS4m5/KkE16t5Ly0WW9hz46swAstv0hYYwMtbG7AznRhNyfLRe8HZAiWIpcHzoO7HxhLuBQj9rJ/Ho0ZA== +node-domexception@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" + integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== + node-fetch-native@^1.4.0, node-fetch-native@^1.4.1, node-fetch-native@^1.6.1: version "1.6.1" resolved "https://registry.yarnpkg.com/node-fetch-native/-/node-fetch-native-1.6.1.tgz#f95c74917d3cebc794cdae0cd2a9c7594aad0cb4" @@ -9970,6 +10061,19 @@ open@^8.4.0: is-docker "^2.1.1" is-wsl "^2.2.0" +openai@^4.94.0: + version "4.94.0" + resolved "https://registry.yarnpkg.com/openai/-/openai-4.94.0.tgz#d5fb3f39ec0e2090687dc9f97db4ca05200e27ca" + integrity sha512-WVmr9HWcwfouLJ7R3UHd2A93ClezTPuJljQxkCYQAL15Sjyt+FBNoqEz5MHSdH/ebQrVyvRhFyn/bvdqtSPyIA== + dependencies: + "@types/node" "^18.11.18" + "@types/node-fetch" "^2.6.4" + abort-controller "^3.0.0" + agentkeepalive "^4.2.1" + form-data-encoder "1.7.2" + formdata-node "^4.3.2" + node-fetch "^2.6.7" + optionator@^0.9.3: version "0.9.3" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.3.tgz#007397d44ed1872fdc6ed31360190f81814e2c64" @@ -12614,6 +12718,11 @@ web-namespaces@^2.0.0: resolved "https://registry.yarnpkg.com/web-namespaces/-/web-namespaces-2.0.1.tgz#1010ff7c650eccb2592cebeeaf9a1b253fd40692" integrity sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ== +web-streams-polyfill@4.0.0-beta.3: + version "4.0.0-beta.3" + resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz#2898486b74f5156095e473efe989dcf185047a38" + integrity sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug== + webauthn-p256@0.0.5: version "0.0.5" resolved "https://registry.yarnpkg.com/webauthn-p256/-/webauthn-p256-0.0.5.tgz#0baebd2ba8a414b21cc09c0d40f9dd0be96a06bd" From 31fdfc8cc474dc495da13b830b62f4c0522b3db9 Mon Sep 17 00:00:00 2001 From: holybasil Date: Wed, 16 Apr 2025 14:30:15 +0800 Subject: [PATCH 02/16] fix: mobile style --- src/assets/css/assistant-message.css | 5 +-- src/components/AIModal/AIInput.tsx | 7 ++-- src/components/AIModal/AssistantMessage.tsx | 11 ++++++- src/components/AIModal/InitialPanel.tsx | 32 +++++++++++++------ src/components/AIModal/actions.ts | 4 +-- src/components/AIModal/index.tsx | 14 +++++--- src/components/Header/AskAI.tsx | 4 +-- .../Header/MobileGasPriceViewer.tsx | 8 ++--- src/components/Header/mobile_header.tsx | 4 ++- src/constants/{prompt.ts => ai-assistant.ts} | 17 ++++++++-- src/constants/index.ts | 1 + 11 files changed, 74 insertions(+), 33 deletions(-) rename src/constants/{prompt.ts => ai-assistant.ts} (87%) diff --git a/src/assets/css/assistant-message.css b/src/assets/css/assistant-message.css index 42f440d12..5127872e3 100644 --- a/src/assets/css/assistant-message.css +++ b/src/assets/css/assistant-message.css @@ -29,7 +29,6 @@ font-weight: 700; } .assistant-message > ol > li p { - display: inline-block; margin-bottom: 0; } @@ -45,7 +44,8 @@ .assistant-message blockquote:not(:empty) { border-left: 2px solid #9b9b9b; - background-color: #1010100d; + font-style: italic; + font-weight: 700; padding: 8px 8px 8px 16px; margin-bottom: 1.6rem; } @@ -78,6 +78,7 @@ color: #ff684b; font-weight: 500; text-decoration: underline; + text-underline-position: from-font; } .assistant-message img { diff --git a/src/components/AIModal/AIInput.tsx b/src/components/AIModal/AIInput.tsx index 3ef754d99..2a6ad58e0 100644 --- a/src/components/AIModal/AIInput.tsx +++ b/src/components/AIModal/AIInput.tsx @@ -3,12 +3,12 @@ import { IconButton, Stack, TextareaAutosize } from "@mui/material" import SendSvg from "@/assets/svgs/header/send.svg" const AIInput = props => { - const { disabled, onChat, ...restProps } = props + const { value, disabled, onChat, ...restProps } = props const handleKeyDown = e => { if (e.key === "Enter" && !e.shiftKey && !disabled) { e.preventDefault() - onChat() + onChat(value.trim()) } } @@ -38,10 +38,11 @@ const AIInput = props => { scrollbarColor: "#ececec transparent", scrollbarWidth: "thin", }} + value={value} {...restProps} onKeyDown={handleKeyDown} > - + onChat(value)}> diff --git a/src/components/AIModal/AssistantMessage.tsx b/src/components/AIModal/AssistantMessage.tsx index 0ee5cb970..beda5e1a0 100644 --- a/src/components/AIModal/AssistantMessage.tsx +++ b/src/components/AIModal/AssistantMessage.tsx @@ -3,7 +3,16 @@ import remarkGfm from "remark-gfm" const AssistantMessage = props => { const { children } = props - return + return ( + , + }} + className="assistant-message" + /> + ) } export default AssistantMessage diff --git a/src/components/AIModal/InitialPanel.tsx b/src/components/AIModal/InitialPanel.tsx index 94df09a7a..32c61da2b 100644 --- a/src/components/AIModal/InitialPanel.tsx +++ b/src/components/AIModal/InitialPanel.tsx @@ -1,23 +1,35 @@ +import { sampleSize } from "lodash" import Image from "next/image" +import { useEffect, useState } from "react" import { Stack, Typography } from "@mui/material" import ScrollyCool from "@/assets/images/common/scrolly-cool.png" import EnterSvg from "@/assets/svgs/header/enter.svg" +import { AI_QUESTION_LIST } from "@/constants" +import useGlobalStore from "@/stores/globalStore" -const LIST = [ - "this could be a general question", - "this could be a very long long long long long", - "this could be a very long long long long long long long long long long long long long question", -] +const InitialPanel = props => { + const { onChat } = props + const [initialQuestionList, setInitialQuestionList] = useState([]) + const { aiModalVisible } = useGlobalStore() + + useEffect(() => { + if (aiModalVisible) { + setInitialQuestionList(sampleSize(AI_QUESTION_LIST, 5)) + } + }, [aiModalVisible]) -const InitialPanel = () => { return ( - + Scrolly Welcome, Scroll AI is here to help! - {LIST.map((item, index) => ( + {initialQuestionList.map((item, index) => ( { bgcolor: "#1010100D", borderRadius: "4.4rem", mb: "1.6rem", + cursor: "pointer", }} + onClick={() => onChat(item)} > - {item} + {item} ))} diff --git a/src/components/AIModal/actions.ts b/src/components/AIModal/actions.ts index 69ffe16a7..ba2d79c9e 100644 --- a/src/components/AIModal/actions.ts +++ b/src/components/AIModal/actions.ts @@ -2,7 +2,7 @@ import OpenAI from "openai" -import prompt from "@/constants/prompt" +import { AI_PROMPT } from "@/constants" const openai = new OpenAI({ apiKey: process.env.AI_KEY as string, @@ -14,7 +14,7 @@ type InputMessage = { } export const chatWithAI = async ({ message, prevId }: { message: string; prevId?: string }) => { - const input = [...(prevId ? [] : [{ role: "developer", content: prompt }]), { role: "user", content: message }] + const input = [...(prevId ? [] : [{ role: "developer", content: AI_PROMPT }]), { role: "user", content: message }] const response = await openai.responses.create({ model: "gpt-4o-mini", diff --git a/src/components/AIModal/index.tsx b/src/components/AIModal/index.tsx index 1f4aa704b..7e6fd32dd 100644 --- a/src/components/AIModal/index.tsx +++ b/src/components/AIModal/index.tsx @@ -78,18 +78,18 @@ const AIModal = () => { setSearchText(e.target.value) } - const handleSendMessage = async () => { + const handleSendMessage = async userMessage => { queryClient.setQueryData(["messages"], (preMessages: any[]) => { return preMessages.concat({ id: nanoid(), type: "input_text", - text: searchText.trim(), + text: userMessage, }) }) setSearchText("") const response = await sendMessageAsync({ - message: searchText.trim(), + message: userMessage, prevId: responseId, }) @@ -115,7 +115,7 @@ const AIModal = () => { top: [0, "7.5rem"], width: ["100%", "46.8rem"], - height: ["100%", "66.8rem"], + height: ["100%", "calc(100svh - 7.5rem - 10rem)"], borderRadius: [0, "1.6rem"], display: "flex", @@ -155,7 +155,11 @@ const AIModal = () => { - {messages?.length ? : } + {messages?.length ? ( + + ) : ( + + )} diff --git a/src/components/Header/AskAI.tsx b/src/components/Header/AskAI.tsx index 8cd38507c..b22b721eb 100644 --- a/src/components/Header/AskAI.tsx +++ b/src/components/Header/AskAI.tsx @@ -25,11 +25,11 @@ const AskAI = props => { p: "0.8rem 1.6rem", bgcolor: "background.default", whiteSpace: "nowrap", - borderRadius: "0.8rem", + borderRadius: isMobile ? "0.5rem" : "0.8rem", }} onClick={handleToggleAIModal} > - {isMobile ? "Ask AI" : "Ask Scroll AI"} + Ask Scroll AI ) } diff --git a/src/components/Header/MobileGasPriceViewer.tsx b/src/components/Header/MobileGasPriceViewer.tsx index d403b4e45..8d5649bef 100644 --- a/src/components/Header/MobileGasPriceViewer.tsx +++ b/src/components/Header/MobileGasPriceViewer.tsx @@ -88,9 +88,7 @@ const MobileGasPriceViewer = props => { position: "relative", borderRadius: "0.5rem", width: "100%", - height: "21.6rem", - mt: "2.4rem", - mb: "4.8rem", + my: "2.4rem", overflow: "hidden", }} ref={gasPriceRef} @@ -112,7 +110,7 @@ const MobileGasPriceViewer = props => { }, }, closed: { - clipPath: `polygon(0 0, 100px 0, 100px 50px, 0 50px)`, + clipPath: `polygon(0 0, 100px 0, 100px 30px, 0 30px)`, transition: { ease: [0.165, 0.84, 0.44, 1], delay: 0.2, @@ -129,7 +127,7 @@ const MobileGasPriceViewer = props => { width: "min-content", alignItems: "center", borderRadius: "0.5rem", - padding: "1.6rem", + padding: "0.8rem 1.6rem", color: dark ? "primary.contrastText" : "text.primary", backgroundColor: dark ? "#333" : "background.default", }} diff --git a/src/components/Header/mobile_header.tsx b/src/components/Header/mobile_header.tsx index 696a8c07a..de173ee1f 100644 --- a/src/components/Header/mobile_header.tsx +++ b/src/components/Header/mobile_header.tsx @@ -146,7 +146,7 @@ const MobileHeader = ({ currentMenu }) => { {showWalletConnector && } - {!isSepolia && } + { sx={{ flex: 1, backgroundColor: dark ? "themeBackground.dark" : "themeBackground.light", + paddingBottom: "4.8rem", overflowY: "auto", }} > {renderList()} {!isSepolia && } + {!isSepolia && } )} diff --git a/src/constants/prompt.ts b/src/constants/ai-assistant.ts similarity index 87% rename from src/constants/prompt.ts rename to src/constants/ai-assistant.ts index 37b5a901c..eb483d3fe 100644 --- a/src/constants/prompt.ts +++ b/src/constants/ai-assistant.ts @@ -1,4 +1,17 @@ -const prompt = ` +export const AI_QUESTION_LIST = [ + "What is Scroll?", + "How can I get SCR?", + "How can I earn Marks in Scroll Session 2?", + "What are the key improvements in the latest Scroll mainnet upgrade?", + "How do I bridge my assets from Ethereum to the Scroll mainnet?", + "Which tokens and assets are supported on the Scroll bridge?", + "How long does the bridging process usually take on Scroll mainnet?", + "How can I stake SCR, and what incentives does staking offer?", + "I’m a Builder interested in building on Scroll. Where can I find Scroll’s technical docs and developer tools?", + "How can I join the Scroll community for updates, support, and discussions?", +] + +export const AI_PROMPT = ` **1. Role Definition:** You are an AI bot for the [Scroll.io](http://scroll.io/) project. Your primary responsibility is to provide friendly, clear, and informative Q&A for users visiting [Scroll.io](https://scroll.io/). Most user questions will focus on [Scroll.io](http://scroll.io/) topics and can be categorized as general, technical, token, UX, or ecosystem questions. @@ -88,5 +101,3 @@ You are an AI bot for the [Scroll.io](http://scroll.io/) project. Your primary r - *Requirement:* Verify any direct quote’s source and include the proper reference and clickable URL. ` - -export default prompt diff --git a/src/constants/index.ts b/src/constants/index.ts index 6024ef807..617d2b104 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -13,3 +13,4 @@ export * from "./canvas" export * from "./badge" export * from "./canvas-badge" export * from "./community" +export * from "./ai-assistant" From 580bb75afd40a9be960676c5ec965e45b30bd418 Mon Sep 17 00:00:00 2001 From: holybasil Date: Wed, 16 Apr 2025 15:09:55 +0800 Subject: [PATCH 03/16] fix: import css file --- src/app/globals.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/globals.css b/src/app/globals.css index 58b22364d..adbea8a35 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -5,7 +5,7 @@ @config "../../tailwind.config.ts"; -@import url(../assets/css/assistant-message.css); +@import "../assets/css/assistant-message.css"; @theme { --breakpoint-sm: 600px; From 0691fcf3647f21893dd659e872b5e823e34c90a9 Mon Sep 17 00:00:00 2001 From: holybasil Date: Wed, 16 Apr 2025 21:30:55 +0800 Subject: [PATCH 04/16] fix: streaming --- src/components/AIModal/actions.ts | 11 +-- src/components/AIModal/index.tsx | 134 +++++++++++++++++++----------- 2 files changed, 89 insertions(+), 56 deletions(-) diff --git a/src/components/AIModal/actions.ts b/src/components/AIModal/actions.ts index ba2d79c9e..58000c936 100644 --- a/src/components/AIModal/actions.ts +++ b/src/components/AIModal/actions.ts @@ -14,6 +14,7 @@ type InputMessage = { } export const chatWithAI = async ({ message, prevId }: { message: string; prevId?: string }) => { + console.log(prevId, "prevId") const input = [...(prevId ? [] : [{ role: "developer", content: AI_PROMPT }]), { role: "user", content: message }] const response = await openai.responses.create({ @@ -21,12 +22,8 @@ export const chatWithAI = async ({ message, prevId }: { message: string; prevId? tools: [{ type: "web_search_preview" }], input: input as InputMessage[], previous_response_id: prevId ?? null, + stream: true, }) - return { - id: response.id, - msgId: response.output.find(item => item.type === "message")?.id, - status: response.status, - error: response.error, - message: response.output_text, - } + + return response.toReadableStream() } diff --git a/src/components/AIModal/index.tsx b/src/components/AIModal/index.tsx index 7e6fd32dd..5bafc5940 100644 --- a/src/components/AIModal/index.tsx +++ b/src/components/AIModal/index.tsx @@ -1,6 +1,5 @@ "use client" -import { useMutation, useQueryClient } from "@tanstack/react-query" import { AnimatePresence, motion } from "motion/react" import { nanoid } from "nanoid" import Image from "next/image" @@ -20,6 +19,14 @@ import { chatWithAI } from "./actions" const MotionCard = motion(Card) +interface Message { + id: string + type: "input_text" | "output_text" | "output_text_error" + text: string +} + +type LoadingStatus = "none" | "fetching" | "streaming" + const AIModal = () => { const { aiModalVisible, changeAIModalVisible } = useGlobalStore() @@ -27,60 +34,30 @@ const AIModal = () => { const [searchText, setSearchText] = useState("") + const [messages, setMessages] = useState([]) + const [responseId, setResponseId] = useState() + const [loadingStatus, setLoadingStatus] = useState("none") + useEffect(() => { if (aiModalVisible) { - queryClient.setQueryData(["messages"], []) window.document.body.classList.add("disable-body-scroll") } else { setResponseId(undefined) setSearchText("") - queryClient.removeQueries({ queryKey: ["messages"] }) + setMessages([]) window.document.body.classList.remove("disable-body-scroll") } }, [aiModalVisible]) - const queryClient = useQueryClient() - - const messages = queryClient.getQueryData(["messages"]) as any[] - - const { mutateAsync: sendMessageAsync, isPending } = useMutation({ - mutationFn: chatWithAI, - onError: error => { - queryClient.setQueryData(["messages"], (preMessages: any[]) => { - return preMessages.concat({ - id: nanoid(), - type: "output_text_error", - text: "Something went wrong, please try again.", - }) - }) - }, - }) - - // const { - // data: messages, - // isFetching, - // refetch: refetchMessages, - // } = useQuery({ - // queryKey: ["messages"], - // queryFn: async () => { - // const response = await scrollRequest(`${AI_CHAT_URL}/${responseId}`) - // return response - // }, - // enabled: !!responseId, - // initialData: [], - // }) - - // console.log(messages, "messages") - const handleChangeSearchText = e => { setSearchText(e.target.value) } const handleSendMessage = async userMessage => { - queryClient.setQueryData(["messages"], (preMessages: any[]) => { - return preMessages.concat({ + setMessages(preValue => { + return preValue.concat({ id: nanoid(), type: "input_text", text: userMessage, @@ -88,20 +65,78 @@ const AIModal = () => { }) setSearchText("") - const response = await sendMessageAsync({ + setLoadingStatus("fetching") + const stream = await chatWithAI({ message: userMessage, prevId: responseId, }) - // const msgResponse = response.output.find(item => item.type === "message") - queryClient.setQueryData(["messages"], (preMessages: any[]) => { - return preMessages.concat({ - id: response.msgId, - type: "output_text", - text: response.message, + const reader = stream.getReader() + const decoder = new TextDecoder("utf-8") + + let currentResponseId = nanoid() + try { + while (true) { + const { done, value } = await reader.read() + + if (done) { + break + } + if (value) { + setLoadingStatus("streaming") + decodeValue(value, decoder, currentResponseId) + } + } + } catch (error) { + setMessages(preValue => { + return preValue.concat({ + id: nanoid(), + type: "output_text_error", + text: error.message, + }) }) - }) - setResponseId(response.id) + await reader.cancel() + } finally { + setLoadingStatus("none") + reader.releaseLock() + } + } + + const decodeValue = async (value, decoder, currentResponseId) => { + const chunk = decoder.decode(value, { stream: true }) + + const lines = chunk.split("\n").filter(line => line.trim()) + + for (const line of lines) { + const event = JSON.parse(line) + + if (event.type === "response.created") { + currentResponseId = event.response.id + setResponseId(currentResponseId) + + setMessages(preValue => { + return preValue.concat({ + id: currentResponseId, + type: "output_text", + text: "", + }) + }) + } else if (event.type === "response.failed") { + throw new Error("Failed to generate AI response, please try again.") + } else if (event.type === "response.output_text.delta") { + setMessages(preValue => { + const lastMessage = preValue[preValue.length - 1] + const newMessage = { + id: lastMessage.id, + type: "output_text", + text: lastMessage.text + event.delta, + } as Message + return [...preValue.slice(0, -1), newMessage] + }) + } else if (event.type === "error") { + throw new Error("Connection error, please try again.") + } + } } return ( @@ -156,12 +191,13 @@ const AIModal = () => { {messages?.length ? ( - + ) : ( )} + {/* {!!lastMessage && {lastMessage}} */} - + ) : null} From 2c2f9b8762dd2c50f5b26888baec390f0a917d0e Mon Sep 17 00:00:00 2001 From: holybasil Date: Wed, 16 Apr 2025 21:41:19 +0800 Subject: [PATCH 05/16] fix: loadingStatus --- src/components/AIModal/MessagePanel.tsx | 3 ++- src/components/AIModal/actions.ts | 1 - src/components/AIModal/index.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/AIModal/MessagePanel.tsx b/src/components/AIModal/MessagePanel.tsx index 1551f6beb..fe6f78176 100644 --- a/src/components/AIModal/MessagePanel.tsx +++ b/src/components/AIModal/MessagePanel.tsx @@ -1,3 +1,4 @@ +import clsx from "clsx" import { useEffect, useRef } from "react" import { Box } from "@mui/material" @@ -29,7 +30,7 @@ const MessagePanel = props => { ))} - {loading && } +
) diff --git a/src/components/AIModal/actions.ts b/src/components/AIModal/actions.ts index 58000c936..268b859c1 100644 --- a/src/components/AIModal/actions.ts +++ b/src/components/AIModal/actions.ts @@ -14,7 +14,6 @@ type InputMessage = { } export const chatWithAI = async ({ message, prevId }: { message: string; prevId?: string }) => { - console.log(prevId, "prevId") const input = [...(prevId ? [] : [{ role: "developer", content: AI_PROMPT }]), { role: "user", content: message }] const response = await openai.responses.create({ diff --git a/src/components/AIModal/index.tsx b/src/components/AIModal/index.tsx index 5bafc5940..3fc3ebf6e 100644 --- a/src/components/AIModal/index.tsx +++ b/src/components/AIModal/index.tsx @@ -47,6 +47,7 @@ const AIModal = () => { setResponseId(undefined) setSearchText("") setMessages([]) + setLoadingStatus("none") window.document.body.classList.remove("disable-body-scroll") } }, [aiModalVisible]) @@ -83,7 +84,6 @@ const AIModal = () => { break } if (value) { - setLoadingStatus("streaming") decodeValue(value, decoder, currentResponseId) } } @@ -124,6 +124,7 @@ const AIModal = () => { } else if (event.type === "response.failed") { throw new Error("Failed to generate AI response, please try again.") } else if (event.type === "response.output_text.delta") { + setLoadingStatus("streaming") setMessages(preValue => { const lastMessage = preValue[preValue.length - 1] const newMessage = { @@ -195,7 +196,6 @@ const AIModal = () => { ) : ( )} - {/* {!!lastMessage && {lastMessage}} */} From 688701c167a4f5ea40bde87ef44322b5bb6149de Mon Sep 17 00:00:00 2001 From: holybasil Date: Thu, 17 Apr 2025 11:10:15 +0800 Subject: [PATCH 06/16] fix: mobile style --- src/components/AIModal/InitialPanel.tsx | 9 ++++++++- src/components/AIModal/MessagePanel.tsx | 12 ++++++++++-- src/components/AIModal/index.tsx | 6 +++--- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/components/AIModal/InitialPanel.tsx b/src/components/AIModal/InitialPanel.tsx index 32c61da2b..5a9f9cbc2 100644 --- a/src/components/AIModal/InitialPanel.tsx +++ b/src/components/AIModal/InitialPanel.tsx @@ -23,7 +23,14 @@ const InitialPanel = props => { return ( Scrolly Welcome, Scroll AI is here to help! diff --git a/src/components/AIModal/MessagePanel.tsx b/src/components/AIModal/MessagePanel.tsx index fe6f78176..d2fdb72f5 100644 --- a/src/components/AIModal/MessagePanel.tsx +++ b/src/components/AIModal/MessagePanel.tsx @@ -22,7 +22,15 @@ const MessagePanel = props => { } return ( - + {data.map(message => ( @@ -30,7 +38,7 @@ const MessagePanel = props => { ))} - +
) diff --git a/src/components/AIModal/index.tsx b/src/components/AIModal/index.tsx index 3fc3ebf6e..60c835809 100644 --- a/src/components/AIModal/index.tsx +++ b/src/components/AIModal/index.tsx @@ -172,9 +172,9 @@ const AIModal = () => { height: "5.6rem", alignItems: "center", justifyContent: "space-between", - pl: "1.6rem", + pl: ["2rem", "1.6rem"], pr: "0.8rem", - gap: "1.4rem", + gap: "1.6rem", borderBottom: messages?.length ? "1px solid #1010101A" : "none", }} > @@ -196,7 +196,7 @@ const AIModal = () => { ) : ( )} - + From 9fd68cdb5f03f9ef2a70c5936077172d4871f891 Mon Sep 17 00:00:00 2001 From: holybasil Date: Fri, 18 Apr 2025 17:28:37 +0800 Subject: [PATCH 07/16] fix: style --- src/app/globals.css | 7 ------- src/assets/svgs/header/spin.svg | 14 +++++++++++--- src/components/AIModal/index.tsx | 5 +++-- src/components/Header/AskAI.tsx | 2 -- src/utils/dom.ts | 11 +++++++++++ 5 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index adbea8a35..cf2ac4954 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -51,13 +51,6 @@ body { body.disable-body-scroll { overflow: hidden !important; - padding-right: 15px !important; -} - -@media screen and (max-width: 900px) { - body.disable-body-scroll { - padding-right: 0 !important; - } } @font-face { diff --git a/src/assets/svgs/header/spin.svg b/src/assets/svgs/header/spin.svg index 677d007d4..3e7f55507 100644 --- a/src/assets/svgs/header/spin.svg +++ b/src/assets/svgs/header/spin.svg @@ -1,3 +1,11 @@ - - - + + + + + + + + + + + \ No newline at end of file diff --git a/src/components/AIModal/index.tsx b/src/components/AIModal/index.tsx index 60c835809..db9505f02 100644 --- a/src/components/AIModal/index.tsx +++ b/src/components/AIModal/index.tsx @@ -11,6 +11,7 @@ import AIBot from "@/assets/images/common/ai-bot.png" import CloseSvg from "@/assets/svgs/header/close.svg" import useCheckViewport from "@/hooks/useCheckViewport" import useGlobalStore from "@/stores/globalStore" +import { lockBodyScroll } from "@/utils" import AIInput from "./AIInput" import InitialPanel from "./InitialPanel" @@ -42,13 +43,13 @@ const AIModal = () => { useEffect(() => { if (aiModalVisible) { - window.document.body.classList.add("disable-body-scroll") + lockBodyScroll(true) } else { setResponseId(undefined) setSearchText("") setMessages([]) setLoadingStatus("none") - window.document.body.classList.remove("disable-body-scroll") + lockBodyScroll(false) } }, [aiModalVisible]) diff --git a/src/components/Header/AskAI.tsx b/src/components/Header/AskAI.tsx index b22b721eb..7bfd95a40 100644 --- a/src/components/Header/AskAI.tsx +++ b/src/components/Header/AskAI.tsx @@ -9,10 +9,8 @@ const AskAI = props => { const handleToggleAIModal = () => { if (aiModalVisible) { changeAIModalVisible(false) - window.document.body.classList.remove("disable-body-scroll") } else { changeAIModalVisible(true) - window.document.body.classList.add("disable-body-scroll") } } diff --git a/src/utils/dom.ts b/src/utils/dom.ts index 89d72d761..22f4742ed 100644 --- a/src/utils/dom.ts +++ b/src/utils/dom.ts @@ -7,3 +7,14 @@ export const isBelowScreen = element => { const rect = element.getBoundingClientRect() return rect.top > window.innerHeight } + +export const lockBodyScroll = (lock: boolean) => { + if (lock) { + const scrollbarWidth = window.innerWidth - document.body.offsetWidth + document.body.style.overflow = "hidden" + document.body.style.paddingRight = `${scrollbarWidth}px` + } else { + document.body.style.overflow = "auto" + document.body.style.paddingRight = "0px" + } +} From 2d465b52118091545ea405d63f157245731d2307 Mon Sep 17 00:00:00 2001 From: holybasil Date: Wed, 30 Apr 2025 09:57:37 +0800 Subject: [PATCH 08/16] fix: update questions and prompt --- src/constants/ai-assistant.ts | 180 ++++++++++++++++++++++++++++------ 1 file changed, 152 insertions(+), 28 deletions(-) diff --git a/src/constants/ai-assistant.ts b/src/constants/ai-assistant.ts index eb483d3fe..542cb67e5 100644 --- a/src/constants/ai-assistant.ts +++ b/src/constants/ai-assistant.ts @@ -1,22 +1,20 @@ export const AI_QUESTION_LIST = [ "What is Scroll?", "How can I get SCR?", - "How can I earn Marks in Scroll Session 2?", - "What are the key improvements in the latest Scroll mainnet upgrade?", "How do I bridge my assets from Ethereum to the Scroll mainnet?", "Which tokens and assets are supported on the Scroll bridge?", "How long does the bridging process usually take on Scroll mainnet?", - "How can I stake SCR, and what incentives does staking offer?", + "What are the best yeild DeFi protocols on Scroll?", "I’m a Builder interested in building on Scroll. Where can I find Scroll’s technical docs and developer tools?", "How can I join the Scroll community for updates, support, and discussions?", ] export const AI_PROMPT = ` -**1. Role Definition:** +### **1. Role Definition:** You are an AI bot for the [Scroll.io](http://scroll.io/) project. Your primary responsibility is to provide friendly, clear, and informative Q&A for users visiting [Scroll.io](https://scroll.io/). Most user questions will focus on [Scroll.io](http://scroll.io/) topics and can be categorized as general, technical, token, UX, or ecosystem questions. -**2. Knowledge Priority:** +### **2. Knowledge Priority:** - **Primary Sources:** - All content from official [Scroll.io](http://scroll.io/) pages and their subdomains. @@ -24,13 +22,14 @@ You are an AI bot for the [Scroll.io](http://scroll.io/) project. Your primary r - Blockchain information from ScrollScan: https://scrollscan.com/. - **Secondary Source:** - Perform web searches using the keywords “Scroll”, “Crypto”, and the user’s input (user_input) as constraints. + - For other blockchain or DeFi data, you can refer to [https://l2beat.com](https://l2beat.com/scaling/summary), https://defillama.com/, dashboards on [https://dune.com/](https://dune.com/discover/content/trending), and - **Fallback:** - Only answer a user’s question if you can retrieve relevant information from the above sources or make a well-founded prediction based on them. Otherwise, respond with: - + > "I only can answer Scroll related questions yet. You can try to ask something else!" - > + > -**3. Output Requirements:** +### **3. Output Requirements:** - **Chain-of-Thought Display:** - **For complex or multi-part questions:** @@ -47,40 +46,148 @@ You are an AI bot for the [Scroll.io](http://scroll.io/) project. Your primary r - If your answer includes any direct quotes, provide the reference and clickable link for each quote. - Any mentioned webpages must be directly linked (e.g., [scroll official website](https://scroll.io/)). -**4. Sample Q&A – Format and Style Guidance:** +### **4. Sample Q&A – Format and Style Guidance:** -### Example 1: General Question (Simple Case) +Example 1: General Question (Simple Case) - **User Question:** "What is Scroll?" - **Response:** + + > Scroll is a Layer 2 scaling solution for Ethereum that leverages ZK proofs to bundle off-chain transactions into succinct on-chain proofs, delivering up to 2,000+ TPS with 90% lower fees while maintaining full EVM compatibility and inheriting Ethereum’s security model. + > - > Scroll is a project focused on providing scalable and secure blockchain solutions. For more details, please visit the official website and check our latest updates on Twitter. - > - -### Example 2: Technical Question (Simple Case) +Example 2: Technical Question (Simple Case) - **User Question:** "How can I view transaction data on Scroll?" - **Response:** - + > You can view transaction data on Scroll by visiting ScrollScan. This platform provides comprehensive blockchain information including transaction histories and network statistics. - > + > -### Example 3: Technical Issue – Running a Node (Complex Case) +Example 3: Technical Issue – Running a Node (Complex Case) - **User Question:** "I'm encountering errors like \`unknown ancestor\` and \`Unexpected queue index in SyncService\` while running a node." - **Response:** - + > Chain-of-Thought: Recognize the error as a known issue linked to unstable L1 node connections. Identify that the solution involves resetting the L1 height using the geth console and referencing a specific block number from historical data. - > - > + > + > > To resolve the issue, first find an L1 block number roughly 15 minutes before the error occurred. Then, in the geth console, execute: - > - > \`let resetBlockHeight = ; admin.setL1MessageSyncedL1Height(resetBlockHeight);\` - > + > + > \`\`\`go + > let resetBlockHeight = ; // replace with the correct block number + > admin.setL1MessageSyncedL1Height(resetBlockHeight); + > + > \`\`\` + > > For further details, please refer to the [issue reference](https://scrollco.slack.com/archives/C07MFNLSQCD/p1734593749064919). - > - -**5. Potential Hard Cases and Corner Cases:** + > + +### 5. Sample Questions - Any similar questions, no need to think, just reply to user with these prepared answers + +**Example 1** + +- **User Question: “What is Scroll?”** +- Response: +Scroll is a Layer 2 scaling solution for Ethereum that leverages ZK proofs to bundle off-chain transactions into succinct on-chain proofs, delivering up to 2,000+ TPS with 90% lower fees while maintaining full EVM compatibility and inheriting Ethereum’s security model. + +**Example 2** + +- **User Question: “How can I get SCR?”** +- Response: + + You can get SCR quickly through the following: + + **Swap on Scroll DEXs** + + - [Ambient](https://scroll-us.ambient.finance/swap) + - [Maverick](https://app.mav.xyz/) + - [Oku Trade](https://oku.trade/app/scroll) + - [SyncSwap](https://syncswap.xyz/scroll) + + **Buy on Centralized Exchanges** + + - [Binance (SCR/USDT)](https://www.binance.com/en/trade/SCR_USDT) + - [Bithumb (SCR/KRW)](https://www.bithumb.com/react/trade/order/SCR-KRW) + - [OKX](https://www.okx.com/how-to-buy/scroll-scr) + + Please verify all links and do your own research. Scroll is not responsible for any financial losses. + + +**Example 3** + +- **User Question: “How do I bridge my assets from Ethereum to the Scroll mainnet?”** +- Response: + 1. Go to [portal.scroll.io/bridge](https://portal.scroll.io/bridge) and connect your wallet + 2. Click “Deposit to Scroll,” select your token and amount, approve and confirm + 3. Wait for the deposit to complete, typically within 20 minutes + +**Example 4** + +- **User Question: “Which tokens and assets are supported on the Scroll bridge?”** +- Response: +The bridge supports: + - ETH (native) + - All ERC-20 tokens (e.g., USDC, USDT, DAI, WBTC) + - ERC-721 and ERC-1155 NFTs + +**Example 5** + +- **User Question: “How long does the bridging process usually take on Scroll mainnet?”** +- Response: + - Deposits to Scroll: usually 2–20 minutes + - Withdrawals to Ethereum: up to 1 hour to claim + +**Example 6** + +- **User Question: “What are the best yield DeFi protocols on Scroll?”** +- Response: + + Scroll's DeFi ecosystem includes a range of lending, yield, and AMM protocols: + + - [Aave V3](https://app.aave.com/?marketName=proto_scroll_v3): Lending and borrowing with low fees + - [Stargate Finance](https://stargate.finance/bridge): Cross-chain bridge powered by LayerZero + - [Ambient](https://scroll.ambient.finance/swap): Single-contract AMM with dynamic fees and vaults + - [Mitosis](https://app.mitosis.org/?asset=weETH&type=deposit&to=scroll): LP aggregation and points farming + - [Pencils Protocol](https://pencilsprotocol.io/): Vaults, liquid staking, and community rewards + - [Skydrome](https://app.skydrome.finance/): ve(3,3)-style DEX with bribes + - [PunkSwap](https://app.punkswap.exchange/): NFT-farming enabled DEX + + For more, visit [scroll.io/ecosystem](https://scroll.io/ecosystem) + + +**Example 7** + +- **User Question: “I’m a Builder. Where can I find Scroll’s developer docs and tools?”** +- Response: +Explore the developer portal at [docs.scroll.io/en/home](https://docs.scroll.io/en/home), where you’ll find: + - Quickstart guides for popular frameworks + - Architecture overviews and zkEVM design + - SDKs, APIs, tutorials, and GitHub links + +**Example 8** + +- **User Question: “How can I join the Scroll community?”** +- Response: + - [Discord](https://discord.com/invite/scroll): Join live chats and support channels + - [Twitter (X)](https://x.com/Scroll_ZKP): Follow for announcements and updates + - [Telegram](https://t.me/+0tvdw8QMJBMyOTli): Talk with other builders and contributors + +**Example 9** + +- **User Question: “When will Scroll have airdrop 2?”** +- Response: +Scroll has not announced a date for the second airdrop yet. Stay tuned to official channels for news. + +**Example 10** + +- **User Question: “What happens to my Session 2 Marks?”** +- Response: + - A snapshot was taken on May 1, 2025. Marks no longer accrue + - You can still view your data on the [Sessions dashboard](https://portal.scroll.io/sessions) + - The airdrop is still coming—watch for more updates + +### **6. Potential Hard Cases and Corner Cases:** 1. **Ambiguous Queries:** - *Example:* "Tell me about the latest updates." @@ -90,9 +197,9 @@ You are an AI bot for the [Scroll.io](http://scroll.io/) project. Your primary r - *Example:* "What is the best crypto investment strategy?" - *Issue:* This falls outside [Scroll.io](http://scroll.io/)'s domain. - *Mitigation:* Respond with the fallback message: - + > "I only can answer Scroll related questions yet. You can try to ask something else!" - > + > 3. **Complex Multi-Part Queries:** - *Example:* "Can you compare Scroll’s scalability with other blockchain projects and detail the technical differences?" - *Issue:* Detailed technical comparisons might exceed the 400-word limit or lead to opinion-based responses. @@ -100,4 +207,21 @@ You are an AI bot for the [Scroll.io](http://scroll.io/) project. Your primary r 4. **Direct Quotes and Citations:** - *Requirement:* Verify any direct quote’s source and include the proper reference and clickable URL. +### 6. DONT DOs + +1. **Do NOT answer any questions about Scroll’s fundraising, investors, or investment details.** + + If asked, respond with: + + *“I don’t have information related to your question. Please try asking something else.”* + +2. **Only provide the real-time SCR price if explicitly asked.** + + For any other SCR-related price questions (e.g., predictions, historical prices, price charts), politely decline using this response: + + *“Sorry, I can’t provide details beyond the current SCR price. Please refer to trusted market platforms for price history or projections.”* + +3. **Always include the following disclaimer in any response involving tokens, price, or trading:** + + *“Scroll does not provide financial advice or recommendations. Please verify the validity of this information and conduct your own research to understand the risks involved. Scroll is not responsible for any financial losses you may incur.”* ` From b8a5b85afed5c4c04d764260a215df7506cb1c1f Mon Sep 17 00:00:00 2001 From: holybasil Date: Wed, 30 Apr 2025 13:52:02 +0800 Subject: [PATCH 09/16] fix: maxDuration --- src/app/layout.tsx | 2 ++ src/components/AIModal/index.tsx | 21 +++++++++++++++++---- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c0255a16c..cebc44046 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -23,6 +23,8 @@ import "./globals.css" export const metadata: Metadata = ROOT_METADATA +export const maxDuration = 90 + // same as scroll documnet const robotoFont = Roboto({ variable: "--font-developer", diff --git a/src/components/AIModal/index.tsx b/src/components/AIModal/index.tsx index db9505f02..43f97f457 100644 --- a/src/components/AIModal/index.tsx +++ b/src/components/AIModal/index.tsx @@ -68,10 +68,23 @@ const AIModal = () => { setSearchText("") setLoadingStatus("fetching") - const stream = await chatWithAI({ - message: userMessage, - prevId: responseId, - }) + + let stream + try { + stream = await chatWithAI({ + message: userMessage, + prevId: responseId, + }) + } catch (error) { + setMessages(preValue => { + return preValue.concat({ + id: nanoid(), + type: "output_text_error", + text: "Network error, please try again.", + }) + }) + return + } const reader = stream.getReader() const decoder = new TextDecoder("utf-8") From 344be15b2d64cfa6c8716484654712135a738697 Mon Sep 17 00:00:00 2001 From: holybasil Date: Wed, 30 Apr 2025 14:12:16 +0800 Subject: [PATCH 10/16] fix: style --- src/assets/css/assistant-message.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/assets/css/assistant-message.css b/src/assets/css/assistant-message.css index 5127872e3..a5895a8ec 100644 --- a/src/assets/css/assistant-message.css +++ b/src/assets/css/assistant-message.css @@ -45,7 +45,8 @@ .assistant-message blockquote:not(:empty) { border-left: 2px solid #9b9b9b; font-style: italic; - font-weight: 700; + font-size: 15px; + color: #777; padding: 8px 8px 8px 16px; margin-bottom: 1.6rem; } From 9ff4a19c984c5b12ecc759247bf2821c52132987 Mon Sep 17 00:00:00 2001 From: holybasil Date: Wed, 30 Apr 2025 18:17:38 +0800 Subject: [PATCH 11/16] fix: update prompt --- src/components/AIModal/index.tsx | 1 + src/constants/ai-assistant.ts | 416 +++++++++++++++---------------- 2 files changed, 209 insertions(+), 208 deletions(-) diff --git a/src/components/AIModal/index.tsx b/src/components/AIModal/index.tsx index 43f97f457..973ba7090 100644 --- a/src/components/AIModal/index.tsx +++ b/src/components/AIModal/index.tsx @@ -83,6 +83,7 @@ const AIModal = () => { text: "Network error, please try again.", }) }) + setLoadingStatus("none") return } diff --git a/src/constants/ai-assistant.ts b/src/constants/ai-assistant.ts index 542cb67e5..af5e77bd8 100644 --- a/src/constants/ai-assistant.ts +++ b/src/constants/ai-assistant.ts @@ -10,218 +10,218 @@ export const AI_QUESTION_LIST = [ ] export const AI_PROMPT = ` -### **1. Role Definition:** + ### **1. Role Definition:** + + You are an AI bot for the [Scroll.io](http://scroll.io/) project. Your primary responsibility is to provide friendly, clear, and informative Q&A for users visiting [Scroll.io](https://scroll.io/). Most user questions will focus on [Scroll.io](http://scroll.io/) topics and can be categorized as general, technical, token, UX, or ecosystem questions. + + ### **2. Knowledge Priority:** + + - **Primary Sources:** + - All content from official [Scroll.io](http://scroll.io/) pages and their subdomains. + - Content from Scroll’s official Twitter account: https://x.com/scroll_zkp. + - Blockchain information from ScrollScan: https://scrollscan.com/. + - **Secondary Source:** + - Perform web searches using the keywords “Scroll”, “Crypto”, and the user’s input (user_input) as constraints. + - For other blockchain or DeFi data, you can refer to [https://l2beat.com](https://l2beat.com/scaling/summary), https://defillama.com/, dashboards on [https://dune.com/](https://dune.com/discover/content/trending), and + - **Fallback:** + - Only answer a user’s question if you can retrieve relevant information from the above sources or make a well-founded prediction based on them. Otherwise, respond with: + + "I only can answer Scroll related questions yet. You can try to ask something else!" + + + ### **3. Output Requirements:** + + - **Chain-of-Thought Display:** + - **For complex or multi-part questions:** + - Begin your answer with a brief chain-of-thought enclosed in a quoted block (using \` > \`). + - **Important:** Place the chain-of-thought in a separate quoted block followed by a newline before displaying the final answer. + - **For simple questions:** + - Provide the final answer directly without displaying your chain-of-thought. + - **Word Limit:** + - Ensure your complete answer is within 400 words. + - **Structure and Formatting:** + - Use clear structure with proper line breaks and highlight key points. + - Format your answer using Markdown. + - **Citations and Hyperlinks:** + - If your answer includes any direct quotes, provide the reference and clickable link for each quote. + - Any mentioned webpages must be directly linked (e.g., [scroll official website](https://scroll.io/)). + + ### **4. Sample Q&A – Format and Style Guidance:** + + Example 1: General Question (Simple Case) + + - **User Question:** "What is Scroll?" + - **Response:** + + Scroll is a Layer 2 scaling solution for Ethereum that leverages ZK proofs to bundle off-chain transactions into succinct on-chain proofs, delivering up to 2,000+ TPS with 90% lower fees while maintaining full EVM compatibility and inheriting Ethereum’s security model. + + + Example 2: Technical Question (Simple Case) -You are an AI bot for the [Scroll.io](http://scroll.io/) project. Your primary responsibility is to provide friendly, clear, and informative Q&A for users visiting [Scroll.io](https://scroll.io/). Most user questions will focus on [Scroll.io](http://scroll.io/) topics and can be categorized as general, technical, token, UX, or ecosystem questions. + - **User Question:** "How can I view transaction data on Scroll?" + - **Response:** + + You can view transaction data on Scroll by visiting ScrollScan. This platform provides comprehensive blockchain information including transaction histories and network statistics. + -### **2. Knowledge Priority:** + Example 3: Technical Issue – Running a Node (Complex Case) -- **Primary Sources:** - - All content from official [Scroll.io](http://scroll.io/) pages and their subdomains. - - Content from Scroll’s official Twitter account: https://x.com/scroll_zkp. - - Blockchain information from ScrollScan: https://scrollscan.com/. -- **Secondary Source:** - - Perform web searches using the keywords “Scroll”, “Crypto”, and the user’s input (user_input) as constraints. - - For other blockchain or DeFi data, you can refer to [https://l2beat.com](https://l2beat.com/scaling/summary), https://defillama.com/, dashboards on [https://dune.com/](https://dune.com/discover/content/trending), and -- **Fallback:** - - Only answer a user’s question if you can retrieve relevant information from the above sources or make a well-founded prediction based on them. Otherwise, respond with: + - **User Question:** "I'm encountering errors like \`unknown ancestor\` and \`Unexpected queue index in SyncService\` while running a node." + - **Response:** - > "I only can answer Scroll related questions yet. You can try to ask something else!" + > Recognize the error as a known issue linked to unstable L1 node connections. Identify that the solution involves resetting the L1 height using the geth console and referencing a specific block number from historical data. > + + To resolve the issue, first find an L1 block number roughly 15 minutes before the error occurred. Then, in the geth console, execute: + + \`\`\`go + let resetBlockHeight = ; // replace with the correct block number + admin.setL1MessageSyncedL1Height(resetBlockHeight); + + \`\`\` + + For further details, please refer to the [issue reference](https://scrollco.slack.com/archives/C07MFNLSQCD/p1734593749064919). + -### **3. Output Requirements:** - -- **Chain-of-Thought Display:** - - **For complex or multi-part questions:** - - Begin your answer with a brief chain-of-thought enclosed in a quoted block (using \`>\`). - - **Important:** Place the chain-of-thought in a separate quoted block followed by a newline before displaying the final answer. - - **For simple questions:** - - Provide the final answer directly without displaying your chain-of-thought. -- **Word Limit:** - - Ensure your complete answer is within 400 words. -- **Structure and Formatting:** - - Use clear structure with proper line breaks and highlight key points. - - Format your answer using Markdown. -- **Citations and Hyperlinks:** - - If your answer includes any direct quotes, provide the reference and clickable link for each quote. - - Any mentioned webpages must be directly linked (e.g., [scroll official website](https://scroll.io/)). - -### **4. Sample Q&A – Format and Style Guidance:** - -Example 1: General Question (Simple Case) - -- **User Question:** "What is Scroll?" -- **Response:** - - > Scroll is a Layer 2 scaling solution for Ethereum that leverages ZK proofs to bundle off-chain transactions into succinct on-chain proofs, delivering up to 2,000+ TPS with 90% lower fees while maintaining full EVM compatibility and inheriting Ethereum’s security model. - > - -Example 2: Technical Question (Simple Case) - -- **User Question:** "How can I view transaction data on Scroll?" -- **Response:** - - > You can view transaction data on Scroll by visiting ScrollScan. This platform provides comprehensive blockchain information including transaction histories and network statistics. - > - -Example 3: Technical Issue – Running a Node (Complex Case) - -- **User Question:** "I'm encountering errors like \`unknown ancestor\` and \`Unexpected queue index in SyncService\` while running a node." -- **Response:** - - > Chain-of-Thought: Recognize the error as a known issue linked to unstable L1 node connections. Identify that the solution involves resetting the L1 height using the geth console and referencing a specific block number from historical data. - > - > - > To resolve the issue, first find an L1 block number roughly 15 minutes before the error occurred. Then, in the geth console, execute: - > - > \`\`\`go - > let resetBlockHeight = ; // replace with the correct block number - > admin.setL1MessageSyncedL1Height(resetBlockHeight); - > - > \`\`\` - > - > For further details, please refer to the [issue reference](https://scrollco.slack.com/archives/C07MFNLSQCD/p1734593749064919). - > - -### 5. Sample Questions - Any similar questions, no need to think, just reply to user with these prepared answers - -**Example 1** - -- **User Question: “What is Scroll?”** -- Response: -Scroll is a Layer 2 scaling solution for Ethereum that leverages ZK proofs to bundle off-chain transactions into succinct on-chain proofs, delivering up to 2,000+ TPS with 90% lower fees while maintaining full EVM compatibility and inheriting Ethereum’s security model. - -**Example 2** - -- **User Question: “How can I get SCR?”** -- Response: - - You can get SCR quickly through the following: - - **Swap on Scroll DEXs** - - - [Ambient](https://scroll-us.ambient.finance/swap) - - [Maverick](https://app.mav.xyz/) - - [Oku Trade](https://oku.trade/app/scroll) - - [SyncSwap](https://syncswap.xyz/scroll) - - **Buy on Centralized Exchanges** - - - [Binance (SCR/USDT)](https://www.binance.com/en/trade/SCR_USDT) - - [Bithumb (SCR/KRW)](https://www.bithumb.com/react/trade/order/SCR-KRW) - - [OKX](https://www.okx.com/how-to-buy/scroll-scr) - - Please verify all links and do your own research. Scroll is not responsible for any financial losses. - - -**Example 3** - -- **User Question: “How do I bridge my assets from Ethereum to the Scroll mainnet?”** -- Response: - 1. Go to [portal.scroll.io/bridge](https://portal.scroll.io/bridge) and connect your wallet - 2. Click “Deposit to Scroll,” select your token and amount, approve and confirm - 3. Wait for the deposit to complete, typically within 20 minutes - -**Example 4** - -- **User Question: “Which tokens and assets are supported on the Scroll bridge?”** -- Response: -The bridge supports: - - ETH (native) - - All ERC-20 tokens (e.g., USDC, USDT, DAI, WBTC) - - ERC-721 and ERC-1155 NFTs - -**Example 5** - -- **User Question: “How long does the bridging process usually take on Scroll mainnet?”** -- Response: - - Deposits to Scroll: usually 2–20 minutes - - Withdrawals to Ethereum: up to 1 hour to claim - -**Example 6** - -- **User Question: “What are the best yield DeFi protocols on Scroll?”** -- Response: - - Scroll's DeFi ecosystem includes a range of lending, yield, and AMM protocols: - - - [Aave V3](https://app.aave.com/?marketName=proto_scroll_v3): Lending and borrowing with low fees - - [Stargate Finance](https://stargate.finance/bridge): Cross-chain bridge powered by LayerZero - - [Ambient](https://scroll.ambient.finance/swap): Single-contract AMM with dynamic fees and vaults - - [Mitosis](https://app.mitosis.org/?asset=weETH&type=deposit&to=scroll): LP aggregation and points farming - - [Pencils Protocol](https://pencilsprotocol.io/): Vaults, liquid staking, and community rewards - - [Skydrome](https://app.skydrome.finance/): ve(3,3)-style DEX with bribes - - [PunkSwap](https://app.punkswap.exchange/): NFT-farming enabled DEX - - For more, visit [scroll.io/ecosystem](https://scroll.io/ecosystem) - - -**Example 7** - -- **User Question: “I’m a Builder. Where can I find Scroll’s developer docs and tools?”** -- Response: -Explore the developer portal at [docs.scroll.io/en/home](https://docs.scroll.io/en/home), where you’ll find: - - Quickstart guides for popular frameworks - - Architecture overviews and zkEVM design - - SDKs, APIs, tutorials, and GitHub links - -**Example 8** - -- **User Question: “How can I join the Scroll community?”** -- Response: - - [Discord](https://discord.com/invite/scroll): Join live chats and support channels - - [Twitter (X)](https://x.com/Scroll_ZKP): Follow for announcements and updates - - [Telegram](https://t.me/+0tvdw8QMJBMyOTli): Talk with other builders and contributors - -**Example 9** - -- **User Question: “When will Scroll have airdrop 2?”** -- Response: -Scroll has not announced a date for the second airdrop yet. Stay tuned to official channels for news. - -**Example 10** - -- **User Question: “What happens to my Session 2 Marks?”** -- Response: - - A snapshot was taken on May 1, 2025. Marks no longer accrue - - You can still view your data on the [Sessions dashboard](https://portal.scroll.io/sessions) - - The airdrop is still coming—watch for more updates - -### **6. Potential Hard Cases and Corner Cases:** - -1. **Ambiguous Queries:** - - *Example:* "Tell me about the latest updates." - - *Issue:* Unclear whether the query refers to Scroll.io-specific updates or general crypto news. - - *Mitigation:* Ask clarifying questions or restrict responses to [Scroll.io](http://scroll.io/) knowledge sources. -2. **Out-of-Scope Questions:** - - *Example:* "What is the best crypto investment strategy?" - - *Issue:* This falls outside [Scroll.io](http://scroll.io/)'s domain. - - *Mitigation:* Respond with the fallback message: - - > "I only can answer Scroll related questions yet. You can try to ask something else!" - > -3. **Complex Multi-Part Queries:** - - *Example:* "Can you compare Scroll’s scalability with other blockchain projects and detail the technical differences?" - - *Issue:* Detailed technical comparisons might exceed the 400-word limit or lead to opinion-based responses. - - *Mitigation:* Provide a summarized comparison, cite reliable sources, and suggest contacting the Scroll team for more in-depth information if necessary. -4. **Direct Quotes and Citations:** - - *Requirement:* Verify any direct quote’s source and include the proper reference and clickable URL. - -### 6. DONT DOs - -1. **Do NOT answer any questions about Scroll’s fundraising, investors, or investment details.** - - If asked, respond with: - - *“I don’t have information related to your question. Please try asking something else.”* - -2. **Only provide the real-time SCR price if explicitly asked.** - - For any other SCR-related price questions (e.g., predictions, historical prices, price charts), politely decline using this response: - - *“Sorry, I can’t provide details beyond the current SCR price. Please refer to trusted market platforms for price history or projections.”* - -3. **Always include the following disclaimer in any response involving tokens, price, or trading:** - - *“Scroll does not provide financial advice or recommendations. Please verify the validity of this information and conduct your own research to understand the risks involved. Scroll is not responsible for any financial losses you may incur.”* + ### 5. Sample Questions - Any similar questions, no need to think, just reply to user with these prepared answers + + **Example 1** + + - **User Question: “What is Scroll?”** + - Response: + Scroll is a Layer 2 scaling solution for Ethereum that leverages ZK proofs to bundle off-chain transactions into succinct on-chain proofs, delivering up to 2,000+ TPS with 90% lower fees while maintaining full EVM compatibility and inheriting Ethereum’s security model. + + **Example 2** + + - **User Question: “How can I get SCR?”** + - Response: + + You can get SCR quickly through the following: + + **Swap on Scroll DEXs** + + - [Ambient](https://scroll-us.ambient.finance/swap) + - [Maverick](https://app.mav.xyz/) + - [Oku Trade](https://oku.trade/app/scroll) + - [SyncSwap](https://syncswap.xyz/scroll) + + **Buy on Centralized Exchanges** + + - [Binance (SCR/USDT)](https://www.binance.com/en/trade/SCR_USDT) + - [Bithumb (SCR/KRW)](https://www.bithumb.com/react/trade/order/SCR-KRW) + - [OKX](https://www.okx.com/how-to-buy/scroll-scr) + + Please verify all links and do your own research. Scroll is not responsible for any financial losses. + + + **Example 3** + + - **User Question: “How do I bridge my assets from Ethereum to the Scroll mainnet?”** + - Response: + 1. Go to [portal.scroll.io/bridge](https://portal.scroll.io/bridge) and connect your wallet + 2. Click “Deposit to Scroll,” select your token and amount, approve and confirm + 3. Wait for the deposit to complete, typically within 20 minutes + + **Example 4** + + - **User Question: “Which tokens and assets are supported on the Scroll bridge?”** + - Response: + The bridge supports: + - ETH (native) + - All ERC-20 tokens (e.g., USDC, USDT, DAI, WBTC) + - ERC-721 and ERC-1155 NFTs + + **Example 5** + + - **User Question: “How long does the bridging process usually take on Scroll mainnet?”** + - Response: + - Deposits to Scroll: usually 2–20 minutes + - Withdrawals to Ethereum: up to 1 hour to claim + + **Example 6** + + - **User Question: “What are the best yield DeFi protocols on Scroll?”** + - Response: + + Scroll's DeFi ecosystem includes a range of lending, yield, and AMM protocols: + + - [Aave V3](https://app.aave.com/?marketName=proto_scroll_v3): Lending and borrowing with low fees + - [Stargate Finance](https://stargate.finance/bridge): Cross-chain bridge powered by LayerZero + - [Ambient](https://scroll.ambient.finance/swap): Single-contract AMM with dynamic fees and vaults + - [Mitosis](https://app.mitosis.org/?asset=weETH&type=deposit&to=scroll): LP aggregation and points farming + - [Pencils Protocol](https://pencilsprotocol.io/): Vaults, liquid staking, and community rewards + - [Skydrome](https://app.skydrome.finance/): ve(3,3)-style DEX with bribes + - [PunkSwap](https://app.punkswap.exchange/): NFT-farming enabled DEX + + For more, visit [scroll.io/ecosystem](https://scroll.io/ecosystem) + + + **Example 7** + + - **User Question: “I’m a Builder. Where can I find Scroll’s developer docs and tools?”** + - Response: + Explore the developer portal at [docs.scroll.io/en/home](https://docs.scroll.io/en/home), where you’ll find: + - Quickstart guides for popular frameworks + - Architecture overviews and zkEVM design + - SDKs, APIs, tutorials, and GitHub links + + **Example 8** + + - **User Question: “How can I join the Scroll community?”** + - Response: + - [Discord](https://discord.com/invite/scroll): Join live chats and support channels + - [Twitter (X)](https://x.com/Scroll_ZKP): Follow for announcements and updates + - [Telegram](https://t.me/+0tvdw8QMJBMyOTli): Talk with other builders and contributors + + **Example 9** + + - **User Question: “When will Scroll have airdrop 2?”** + - Response: + Scroll has not announced a date for the second airdrop yet. Stay tuned to official channels for news. + + **Example 10** + + - **User Question: “What happens to my Session 2 Marks?”** + - Response: + - A snapshot was taken on May 1, 2025. Marks no longer accrue + - You can still view your data on the [Sessions dashboard](https://portal.scroll.io/sessions) + - The airdrop is still coming—watch for more updates + + ### **6. Potential Hard Cases and Corner Cases:** + + 1. **Ambiguous Queries:** + - *Example:* "Tell me about the latest updates." + - *Issue:* Unclear whether the query refers to Scroll.io-specific updates or general crypto news. + - *Mitigation:* Ask clarifying questions or restrict responses to [Scroll.io](http://scroll.io/) knowledge sources. + 2. **Out-of-Scope Questions:** + - *Example:* "What is the best crypto investment strategy?" + - *Issue:* This falls outside [Scroll.io](http://scroll.io/)'s domain. + - *Mitigation:* Respond with the fallback message: + + "I only can answer Scroll related questions yet. You can try to ask something else!" + + 3. **Complex Multi-Part Queries:** + - *Example:* "Can you compare Scroll’s scalability with other blockchain projects and detail the technical differences?" + - *Issue:* Detailed technical comparisons might exceed the 400-word limit or lead to opinion-based responses. + - *Mitigation:* Provide a summarized comparison, cite reliable sources, and suggest contacting the Scroll team for more in-depth information if necessary. + 4. **Direct Quotes and Citations:** + - *Requirement:* Verify any direct quote’s source and include the proper reference and clickable URL. + + ### 6. DONT DOs + + 1. **Do NOT answer any questions about Scroll’s fundraising, investors, or investment details.** + + If asked, respond with: + + *“I don’t have information related to your question. Please try asking something else.”* + + 2. **Only provide the real-time SCR price if explicitly asked.** + + For any other SCR-related price questions (e.g., predictions, historical prices, price charts), politely decline using this response: + + *“Sorry, I can’t provide details beyond the current SCR price. Please refer to trusted market platforms for price history or projections.”* + + 3. **Always include the following disclaimer in any response involving tokens, price, or trading:** + + *“Scroll does not provide financial advice or recommendations. Please verify the validity of this information and conduct your own research to understand the risks involved. Scroll is not responsible for any financial losses you may incur.”* ` From daeb1699889d9b56fc05db91ed0337ddf2688399 Mon Sep 17 00:00:00 2001 From: holybasil Date: Mon, 12 May 2025 13:56:24 +0800 Subject: [PATCH 12/16] fix: svg --- src/assets/svgs/header/send.svg | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/assets/svgs/header/send.svg b/src/assets/svgs/header/send.svg index e5c2022ef..6e75ee0a0 100644 --- a/src/assets/svgs/header/send.svg +++ b/src/assets/svgs/header/send.svg @@ -1,6 +1,4 @@ - - + - From 83938effb4e34bb2f5e1f86b0e439bb9d069e56b Mon Sep 17 00:00:00 2001 From: holybasil Date: Tue, 3 Jun 2025 15:16:29 +0800 Subject: [PATCH 13/16] feat: add operation --- src/assets/svgs/header/checked.svg | 3 + src/assets/svgs/header/copy.svg | 3 + src/assets/svgs/header/re-send.svg | 3 + src/assets/svgs/header/thumb-down.svg | 3 + src/assets/svgs/header/thumb-up.svg | 3 + src/components/AIModal/AssistantMessage.tsx | 43 ++++++-- src/components/AIModal/FeedbackAlert.tsx | 44 ++++++++ src/components/AIModal/MessagePanel.tsx | 42 +++++++- src/components/AIModal/Operation.tsx | 109 ++++++++++++++++++++ src/components/AIModal/index.tsx | 37 ++++++- 10 files changed, 274 insertions(+), 16 deletions(-) create mode 100644 src/assets/svgs/header/checked.svg create mode 100644 src/assets/svgs/header/copy.svg create mode 100644 src/assets/svgs/header/re-send.svg create mode 100644 src/assets/svgs/header/thumb-down.svg create mode 100644 src/assets/svgs/header/thumb-up.svg create mode 100644 src/components/AIModal/FeedbackAlert.tsx create mode 100644 src/components/AIModal/Operation.tsx diff --git a/src/assets/svgs/header/checked.svg b/src/assets/svgs/header/checked.svg new file mode 100644 index 000000000..67ff60fd7 --- /dev/null +++ b/src/assets/svgs/header/checked.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/svgs/header/copy.svg b/src/assets/svgs/header/copy.svg new file mode 100644 index 000000000..cb923e597 --- /dev/null +++ b/src/assets/svgs/header/copy.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/svgs/header/re-send.svg b/src/assets/svgs/header/re-send.svg new file mode 100644 index 000000000..aa3a251e1 --- /dev/null +++ b/src/assets/svgs/header/re-send.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/svgs/header/thumb-down.svg b/src/assets/svgs/header/thumb-down.svg new file mode 100644 index 000000000..afe0becb7 --- /dev/null +++ b/src/assets/svgs/header/thumb-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/svgs/header/thumb-up.svg b/src/assets/svgs/header/thumb-up.svg new file mode 100644 index 000000000..778f4d676 --- /dev/null +++ b/src/assets/svgs/header/thumb-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/AIModal/AssistantMessage.tsx b/src/components/AIModal/AssistantMessage.tsx index beda5e1a0..eb4c25736 100644 --- a/src/components/AIModal/AssistantMessage.tsx +++ b/src/components/AIModal/AssistantMessage.tsx @@ -1,17 +1,42 @@ +import React, { useState } from "react" import ReactMarkdown from "react-markdown" import remarkGfm from "remark-gfm" +import { Box } from "@mui/material" + +import Operation from "./Operation" + const AssistantMessage = props => { - const { children } = props + const { children, feedback, allowOperation, isLast, onRetry, onThumbUp, onThumbDown } = props + + const [operationVisible, setOperationVisible] = useState(false) + + const handlePopoverOpen = (event: React.MouseEvent) => { + setOperationVisible(true) + } + + const handlePopoverClose = () => { + setOperationVisible(false) + } + return ( -
, - }} - className="assistant-message" - /> + <> + + , + }} + className="assistant-message" + /> + + {allowOperation && (isLast || operationVisible) && ( + + )} + + + ) } diff --git a/src/components/AIModal/FeedbackAlert.tsx b/src/components/AIModal/FeedbackAlert.tsx new file mode 100644 index 000000000..aa5e607e8 --- /dev/null +++ b/src/components/AIModal/FeedbackAlert.tsx @@ -0,0 +1,44 @@ +import { AnimatePresence, motion } from "motion/react" +import { useEffect } from "react" + +import { Box } from "@mui/material" + +const MotionBox = motion(Box) +const FeedbackAlert = props => { + const { sx, open, duration, children, onClose } = props + + useEffect(() => { + if (open) { + const timer = setTimeout(() => { + onClose?.() + }, duration || 2e3) // Auto close after 3 seconds + return () => clearTimeout(timer) + } + }, [open]) + + return ( + + {open && ( + + {children} + + )} + + ) +} + +export default FeedbackAlert diff --git a/src/components/AIModal/MessagePanel.tsx b/src/components/AIModal/MessagePanel.tsx index d2fdb72f5..ce6bbfcdd 100644 --- a/src/components/AIModal/MessagePanel.tsx +++ b/src/components/AIModal/MessagePanel.tsx @@ -1,16 +1,19 @@ import clsx from "clsx" -import { useEffect, useRef } from "react" +import { useEffect, useRef, useState } from "react" import { Box } from "@mui/material" import SpinSvg from "@/assets/svgs/header/spin.svg" import AssistantMessage from "./AssistantMessage" +import FeedbackAlert from "./FeedbackAlert" import UserMessage from "./UserMessage" const MessagePanel = props => { - const { data, loading } = props + const { data, fetching, streaming, onRetry, onUpdateData } = props + const [feedbackAlertVisible, setFeedbackAlertVisible] = useState(false) + // Reference to the bottom of the message panel for auto-scrolling const bottomRef = useRef(null) useEffect(() => { @@ -21,9 +24,19 @@ const MessagePanel = props => { return null } + const handleThumbUp = id => { + setFeedbackAlertVisible(true) + onUpdateData({ id, feedback: "good" }) + } + + const handleThumbDown = id => { + setFeedbackAlertVisible(true) + onUpdateData({ id, feedback: "bad" }) + } return ( { }} > - {data.map(message => ( + {data.map((message, index) => ( - {message.type === "input_text" ? {message.text} : {message.text}} + {message.type === "input_text" ? ( + {message.text} + ) : message.text ? ( + handleThumbUp(message.id)} + onThumbDown={() => handleThumbDown(message.id)} + onRetry={() => onRetry(message.id)} + > + {message.text} + + ) : null} ))} - +
+ + + setFeedbackAlertVisible(false)}> + Thanks for your feedback! + +
) } diff --git a/src/components/AIModal/Operation.tsx b/src/components/AIModal/Operation.tsx new file mode 100644 index 000000000..d941d640b --- /dev/null +++ b/src/components/AIModal/Operation.tsx @@ -0,0 +1,109 @@ +import { useState } from "react" + +import { Box, IconButton, Stack, Tooltip } from "@mui/material" + +import CheckedSvg from "@/assets/svgs/header/checked.svg" +import CopySvg from "@/assets/svgs/header/copy.svg" +import ReSendSvg from "@/assets/svgs/header/re-send.svg" +import ThumbDownSvg from "@/assets/svgs/header/thumb-down.svg" +import ThumbUpSvg from "@/assets/svgs/header/thumb-up.svg" + +const Operation = props => { + const { sx, feedback, message, onRetry, onThumbUp, onThumbDown } = props + const [tip, setTip] = useState("") + + const [copied, setCopied] = useState(false) + + const operations = [ + { + icon: copied ? CheckedSvg : CopySvg, + + tooltip: "Copy", + onClick: () => { + navigator.clipboard.writeText(message) + setCopied(true) + setTip("Copied") + setTimeout(() => { + setCopied(false) + setTip("") + }, 2e3) + }, + }, + { + icon: ThumbUpSvg, + hidden: feedback === "bad", + tooltip: "Good Response", + disabled: feedback === "good", + onClick: () => { + onThumbUp?.() + }, + }, + { + icon: ThumbDownSvg, + hidden: feedback === "good", + tooltip: "Bad Response", + disabled: feedback === "bad", + onClick: () => { + onThumbDown?.() + }, + }, + { + icon: ReSendSvg, + tooltip: "Try Again", + onClick: () => { + onRetry?.() + }, + }, + ] + + return ( + + + {operations + .filter(({ hidden }) => !hidden) + .map(({ icon: Icon, tooltip, disabled, onClick }, index) => ( + + + + + + ))} + + + ) +} + +export default Operation diff --git a/src/components/AIModal/index.tsx b/src/components/AIModal/index.tsx index 973ba7090..2fe643fee 100644 --- a/src/components/AIModal/index.tsx +++ b/src/components/AIModal/index.tsx @@ -24,6 +24,7 @@ interface Message { id: string type: "input_text" | "output_text" | "output_text_error" text: string + feedback?: "good" | "bad" } type LoadingStatus = "none" | "fetching" | "streaming" @@ -66,13 +67,23 @@ const AIModal = () => { }) }) setSearchText("") + chatWithScrollAI(userMessage) + } + + const handleReSendMessage = async (id: string) => { + const messageIndex = messages.findIndex(message => message.id === id) + const reservedMessage = messages.slice(0, messageIndex) + setMessages(reservedMessage) + chatWithScrollAI(reservedMessage[messageIndex - 1].text) + } + const chatWithScrollAI = async (message: string) => { setLoadingStatus("fetching") let stream try { stream = await chatWithAI({ - message: userMessage, + message, prevId: responseId, }) } catch (error) { @@ -149,12 +160,28 @@ const AIModal = () => { } as Message return [...preValue.slice(0, -1), newMessage] }) + } else if (event.type === "response.completed") { + setLoadingStatus("none") } else if (event.type === "error") { throw new Error("Connection error, please try again.") } } } + const handleUpdateData = ({ id, feedback }) => { + setMessages(preValue => { + return preValue.map(message => { + if (message.id === id) { + return { + ...message, + feedback: feedback, + } + } + return message + }) + }) + } + return ( {aiModalVisible ? ( @@ -207,7 +234,13 @@ const AIModal = () => { {messages?.length ? ( - + ) : ( )} From f726f78795accc056940255a5c67bf951b1188ea Mon Sep 17 00:00:00 2001 From: holybasil Date: Tue, 3 Jun 2025 20:08:15 +0800 Subject: [PATCH 14/16] fix: add GA --- src/components/AIModal/InitialPanel.tsx | 11 ++++++++++- src/components/AIModal/MessagePanel.tsx | 21 +++++++++++++++++++-- src/components/AIModal/actions.ts | 3 +++ src/components/Header/AskAI.tsx | 17 +++++++++++++++++ 4 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/components/AIModal/InitialPanel.tsx b/src/components/AIModal/InitialPanel.tsx index 5a9f9cbc2..d16eebc11 100644 --- a/src/components/AIModal/InitialPanel.tsx +++ b/src/components/AIModal/InitialPanel.tsx @@ -1,3 +1,4 @@ +import { sendGAEvent } from "@next/third-parties/google" import { sampleSize } from "lodash" import Image from "next/image" import { useEffect, useState } from "react" @@ -20,6 +21,14 @@ const InitialPanel = props => { } }, [aiModalVisible]) + const handleClickDefaultQuestion = (question: string) => { + onChat(question) + + sendGAEvent("event", "click_ai_default_question", { + label: question, + }) + } + return ( { mb: "1.6rem", cursor: "pointer", }} - onClick={() => onChat(item)} + onClick={() => handleClickDefaultQuestion(item)} > {item} diff --git a/src/components/AIModal/MessagePanel.tsx b/src/components/AIModal/MessagePanel.tsx index ce6bbfcdd..b49b02332 100644 --- a/src/components/AIModal/MessagePanel.tsx +++ b/src/components/AIModal/MessagePanel.tsx @@ -1,3 +1,4 @@ +import { sendGAEvent } from "@next/third-parties/google" import clsx from "clsx" import { useEffect, useRef, useState } from "react" @@ -18,7 +19,7 @@ const MessagePanel = props => { useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth" }) - }, [data]) + }, [data[data.length - 1]?.text]) if (!data || data.length === 0) { return null @@ -27,11 +28,27 @@ const MessagePanel = props => { const handleThumbUp = id => { setFeedbackAlertVisible(true) onUpdateData({ id, feedback: "good" }) + + const messageIndex = data.findIndex(message => message.id === id) + + sendGAEvent("event", "click_ai_feedback", { + label: data[messageIndex - 1].text, + id, + feedback: "good", + }) } const handleThumbDown = id => { setFeedbackAlertVisible(true) onUpdateData({ id, feedback: "bad" }) + + const messageIndex = data.findIndex(message => message.id === id) + + sendGAEvent("event", "click_ai_feedback", { + label: data[messageIndex - 1].text, + id, + feedback: "bad", + }) } return ( {
- setFeedbackAlertVisible(false)}> + setFeedbackAlertVisible(false)}> Thanks for your feedback! diff --git a/src/components/AIModal/actions.ts b/src/components/AIModal/actions.ts index 268b859c1..05f0ef315 100644 --- a/src/components/AIModal/actions.ts +++ b/src/components/AIModal/actions.ts @@ -22,6 +22,9 @@ export const chatWithAI = async ({ message, prevId }: { message: string; prevId? input: input as InputMessage[], previous_response_id: prevId ?? null, stream: true, + metadata: { + env: process.env.NEXT_PUBLIC_SCROLL_ENVIRONMENT, + }, }) return response.toReadableStream() diff --git a/src/components/Header/AskAI.tsx b/src/components/Header/AskAI.tsx index 7bfd95a40..3031bc58b 100644 --- a/src/components/Header/AskAI.tsx +++ b/src/components/Header/AskAI.tsx @@ -1,3 +1,6 @@ +import { sendGAEvent } from "@next/third-parties/google" +import { useEffect, useRef } from "react" + import { ButtonBase } from "@mui/material" import useGlobalStore from "@/stores/globalStore" @@ -5,12 +8,26 @@ import useGlobalStore from "@/stores/globalStore" const AskAI = props => { const { isMobile } = props const { aiModalVisible, changeAIModalVisible } = useGlobalStore() + const aiDurationRef = useRef(null) + + useEffect(() => { + if (aiModalVisible) { + aiDurationRef.current = Date.now() + } else { + if (aiDurationRef.current) { + const duration = Date.now() - aiDurationRef.current + sendGAEvent("event", "ai_modal_duration", { duration }) + aiDurationRef.current = null // reset the start time + } + } + }, [aiModalVisible]) const handleToggleAIModal = () => { if (aiModalVisible) { changeAIModalVisible(false) } else { changeAIModalVisible(true) + sendGAEvent("event", "click_ask_ai") } } From 837230b2f99ed31390cd1b87bd92fb63bd934241 Mon Sep 17 00:00:00 2001 From: holybasil Date: Wed, 4 Jun 2025 10:10:36 +0800 Subject: [PATCH 15/16] fix: animation --- src/components/AIModal/AssistantMessage.tsx | 11 +- src/components/AIModal/MessagePanel.tsx | 9 -- src/components/AIModal/Operation.tsx | 117 ++++++++++++-------- src/components/AIModal/index.tsx | 16 ++- 4 files changed, 92 insertions(+), 61 deletions(-) diff --git a/src/components/AIModal/AssistantMessage.tsx b/src/components/AIModal/AssistantMessage.tsx index eb4c25736..126171a74 100644 --- a/src/components/AIModal/AssistantMessage.tsx +++ b/src/components/AIModal/AssistantMessage.tsx @@ -31,9 +31,14 @@ const AssistantMessage = props => { className="assistant-message" /> - {allowOperation && (isLast || operationVisible) && ( - - )} +
diff --git a/src/components/AIModal/MessagePanel.tsx b/src/components/AIModal/MessagePanel.tsx index b49b02332..ce1edce06 100644 --- a/src/components/AIModal/MessagePanel.tsx +++ b/src/components/AIModal/MessagePanel.tsx @@ -13,7 +13,6 @@ import UserMessage from "./UserMessage" const MessagePanel = props => { const { data, fetching, streaming, onRetry, onUpdateData } = props - const [feedbackAlertVisible, setFeedbackAlertVisible] = useState(false) // Reference to the bottom of the message panel for auto-scrolling const bottomRef = useRef(null) @@ -26,7 +25,6 @@ const MessagePanel = props => { } const handleThumbUp = id => { - setFeedbackAlertVisible(true) onUpdateData({ id, feedback: "good" }) const messageIndex = data.findIndex(message => message.id === id) @@ -39,7 +37,6 @@ const MessagePanel = props => { } const handleThumbDown = id => { - setFeedbackAlertVisible(true) onUpdateData({ id, feedback: "bad" }) const messageIndex = data.findIndex(message => message.id === id) @@ -83,12 +80,6 @@ const MessagePanel = props => {
- - - setFeedbackAlertVisible(false)}> - Thanks for your feedback! - - ) } diff --git a/src/components/AIModal/Operation.tsx b/src/components/AIModal/Operation.tsx index d941d640b..d54793345 100644 --- a/src/components/AIModal/Operation.tsx +++ b/src/components/AIModal/Operation.tsx @@ -1,3 +1,4 @@ +import { AnimatePresence, LayoutGroup, motion } from "motion/react" import { useState } from "react" import { Box, IconButton, Stack, Tooltip } from "@mui/material" @@ -8,16 +9,22 @@ import ReSendSvg from "@/assets/svgs/header/re-send.svg" import ThumbDownSvg from "@/assets/svgs/header/thumb-down.svg" import ThumbUpSvg from "@/assets/svgs/header/thumb-up.svg" +const MotionBox = motion(Box) + +const MotionStack = motion(Stack) + +const MotionIconButton = motion(IconButton) + const Operation = props => { - const { sx, feedback, message, onRetry, onThumbUp, onThumbDown } = props + const { sx, visible, feedback, message, onRetry, onThumbUp, onThumbDown } = props const [tip, setTip] = useState("") const [copied, setCopied] = useState(false) const operations = [ { + key: "copy", icon: copied ? CheckedSvg : CopySvg, - tooltip: "Copy", onClick: () => { navigator.clipboard.writeText(message) @@ -30,6 +37,7 @@ const Operation = props => { }, }, { + key: "thumbUp", icon: ThumbUpSvg, hidden: feedback === "bad", tooltip: "Good Response", @@ -39,6 +47,7 @@ const Operation = props => { }, }, { + key: "thumbDown", icon: ThumbDownSvg, hidden: feedback === "good", tooltip: "Bad Response", @@ -48,6 +57,7 @@ const Operation = props => { }, }, { + key: "retry", icon: ReSendSvg, tooltip: "Try Again", onClick: () => { @@ -57,52 +67,63 @@ const Operation = props => { ] return ( - - - {operations - .filter(({ hidden }) => !hidden) - .map(({ icon: Icon, tooltip, disabled, onClick }, index) => ( - - - - - - ))} - - + + {visible && ( + + + + {operations + .filter(({ hidden }) => !hidden) + .map(({ icon: Icon, key, tooltip, disabled, onClick }) => ( + + + + + + ))} + + + + )} + ) } diff --git a/src/components/AIModal/index.tsx b/src/components/AIModal/index.tsx index 2fe643fee..ec3b97468 100644 --- a/src/components/AIModal/index.tsx +++ b/src/components/AIModal/index.tsx @@ -14,6 +14,7 @@ import useGlobalStore from "@/stores/globalStore" import { lockBodyScroll } from "@/utils" import AIInput from "./AIInput" +import FeedbackAlert from "./FeedbackAlert" import InitialPanel from "./InitialPanel" import MessagePanel from "./MessagePanel" import { chatWithAI } from "./actions" @@ -35,6 +36,7 @@ const AIModal = () => { const { isMobile } = useCheckViewport() const [searchText, setSearchText] = useState("") + const [feedbackAlertVisible, setFeedbackAlertVisible] = useState(false) const [messages, setMessages] = useState([]) @@ -169,6 +171,8 @@ const AIModal = () => { } const handleUpdateData = ({ id, feedback }) => { + // only feedback update the messages + setFeedbackAlertVisible(true) setMessages(preValue => { return preValue.map(message => { if (message.id === id) { @@ -245,7 +249,17 @@ const AIModal = () => { )} - + + + + setFeedbackAlertVisible(false)}> + Thanks for your feedback! + ) : null} From 7c960e03c7eb28600c4a9e5588610c19f34e4604 Mon Sep 17 00:00:00 2001 From: holybasil Date: Wed, 4 Jun 2025 13:07:21 +0800 Subject: [PATCH 16/16] fix: disable AI on mobile --- src/app/layout.tsx | 2 -- src/components/AIModal/Operation.tsx | 2 +- src/components/AIModal/index.tsx | 28 ++++++++++++++++++++--- src/components/GlobalComponents/index.tsx | 5 ++++ src/components/Header/mobile_header.tsx | 2 +- 5 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index cebc44046..a92dc7e7f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -9,7 +9,6 @@ import React, { Suspense } from "react" import { AppRouterCacheProvider } from "@mui/material-nextjs/v15-appRouter" import InitColorSchemeScript from "@mui/material/InitColorSchemeScript" -import AIModal from "@/components/AIModal" import GlobalComponents from "@/components/GlobalComponents" import ScrollToTop from "@/components/ScrollToTop" import WebVitals from "@/components/WebVitals" @@ -74,7 +73,6 @@ export default function RootLayout({ children }: { children: React.ReactNode }) {children} - diff --git a/src/components/AIModal/Operation.tsx b/src/components/AIModal/Operation.tsx index d54793345..c237edd4d 100644 --- a/src/components/AIModal/Operation.tsx +++ b/src/components/AIModal/Operation.tsx @@ -88,7 +88,7 @@ const Operation = props => { lineHeight: "1.6rem", padding: "0.4rem 0.8rem", backgroundColor: "text.primary", - marginTop: "0.8rem", + marginTop: "0.8rem !important", "& .MuiTooltip-arrow": { color: "#101010", }, diff --git a/src/components/AIModal/index.tsx b/src/components/AIModal/index.tsx index ec3b97468..4b3a27e5b 100644 --- a/src/components/AIModal/index.tsx +++ b/src/components/AIModal/index.tsx @@ -56,6 +56,28 @@ const AIModal = () => { } }, [aiModalVisible]) + // useLayoutEffect(() => { + // function adjustLayout() { + // const viewportHeight = window.visualViewport!.height || "100vh" + // modalRef.current!.style.height = `${viewportHeight}px` + // } + // function preventTouchScroll(e) { + // e.preventDefault() + // } + + // if (isMobile) { + // document.body.addEventListener("touchmove", preventTouchScroll) + // window.visualViewport!.addEventListener("resize", adjustLayout) + // } + + // return () => { + // if (isMobile) { + // window.visualViewport!.removeEventListener("resize", adjustLayout) + // document.body.removeEventListener("touchmove", preventTouchScroll) + // } + // } + // }, [isMobile]) + const handleChangeSearchText = e => { setSearchText(e.target.value) } @@ -185,7 +207,6 @@ const AIModal = () => { }) }) } - return ( {aiModalVisible ? ( @@ -194,10 +215,11 @@ const AIModal = () => { position: "fixed", zIndex: "var(--mui-zIndex-modal)", right: [0, "2.5rem"], - top: [0, "7.5rem"], + top: ["unset", "7.5rem"], + bottom: [0, "unset"], width: ["100%", "46.8rem"], - height: ["100%", "calc(100svh - 7.5rem - 10rem)"], + height: ["100vh", "calc(100svh - 7.5rem - 10rem)"], borderRadius: [0, "1.6rem"], display: "flex", diff --git a/src/components/GlobalComponents/index.tsx b/src/components/GlobalComponents/index.tsx index 43ac84c46..2264802d5 100644 --- a/src/components/GlobalComponents/index.tsx +++ b/src/components/GlobalComponents/index.tsx @@ -1,9 +1,14 @@ +import { isDesktop } from "react-device-detect" + import TxHistoryDialog from "@/app/bridge/TxHistoryDialog" +import AIModal from "../AIModal" + const GlobalComponents = () => { return ( <> + {isDesktop && } ) } diff --git a/src/components/Header/mobile_header.tsx b/src/components/Header/mobile_header.tsx index de173ee1f..7b6712cff 100644 --- a/src/components/Header/mobile_header.tsx +++ b/src/components/Header/mobile_header.tsx @@ -181,7 +181,7 @@ const MobileHeader = ({ currentMenu }) => { {renderList()} {!isSepolia && } - {!isSepolia && } + {/* {!isSepolia && } */} )}