docs: improve AI Assistant (#11208)

* initial implementation

* integrate new ai assistant in other projects + ux improvements

* fix chat window on mobile devices

* fixes to mobile

* allow pre

* change shortcut to i

* improved responsiveness

* align version in navbar
This commit is contained in:
Shahed Nasser authored and GitHub committed 2025-01-29 19:13:51 +02:00
1 parent 51d2960a57
commit 5634a4762b
57 files changed
+1571 -743

No files matched your search

@@ -36,7 +36,7 @@ const TagOperationCodeSection = ({
> >
<div className={clsx("flex w-[calc(100%-36px)] gap-1")}> <div className={clsx("flex w-[calc(100%-36px)] gap-1")}>
<MethodLabel method={method} className="h-fit" /> <MethodLabel method={method} className="h-fit" />
<code className="text-medusa-fg-subtle =break-words break-all"> <code className="text-medusa-fg-base =break-words break-all">
{endpointPath} {endpointPath}
</code> </code>
</div> </div>
@@ -53,7 +53,7 @@ const TagsOperationDescriptionSection = ({
{operation["x-featureFlag"] && ( {operation["x-featureFlag"] && (
<FeatureFlagNotice <FeatureFlagNotice
featureFlag={operation["x-featureFlag"]} featureFlag={operation["x-featureFlag"]}
tooltipTextClassName="font-normal text-medusa-fg-subtle" tooltipTextClassName="font-normal text-medusa-fg-base"
badgeClassName="ml-0.5" badgeClassName="ml-0.5"
/> />
)} )}
@@ -16,7 +16,7 @@ const VersionSwitcher = () => {
location.href = process.env.NEXT_PUBLIC_API_V1_URL + pathname location.href = process.env.NEXT_PUBLIC_API_V1_URL + pathname
}} }}
/> />
<span className={clsx("text-medusa-fg-subtle")}>V2</span> <span className={clsx("text-medusa-fg-base")}>V2</span>
</div> </div>
) )
} }
+15 -1
View File
@@ -1,4 +1,5 @@
import { import {
AiAssistantProvider,
AnalyticsProvider, AnalyticsProvider,
PageLoadingProvider, PageLoadingProvider,
ScrollControllerProvider, ScrollControllerProvider,
@@ -21,7 +22,20 @@ const Providers = ({ children }: ProvidersProps) => {
<ScrollControllerProvider scrollableSelector="#main"> <ScrollControllerProvider scrollableSelector="#main">
<SidebarProvider> <SidebarProvider>
<MainNavProvider> <MainNavProvider>
<SearchProvider>{children}</SearchProvider> <SearchProvider>
<AiAssistantProvider
apiUrl={process.env.NEXT_PUBLIC_AI_ASSISTANT_URL || "temp"}
websiteId={process.env.NEXT_PUBLIC_AI_WEBSITE_ID || "temp"}
recaptchaSiteKey={
process.env
.NEXT_PUBLIC_AI_API_ASSISTANT_RECAPTCHA_SITE_KEY ||
"temp"
}
chatType="popover"
>
{children}
</AiAssistantProvider>
</SearchProvider>
</MainNavProvider> </MainNavProvider>
</SidebarProvider> </SidebarProvider>
</ScrollControllerProvider> </ScrollControllerProvider>
@@ -4,8 +4,6 @@ import {
usePageLoading, usePageLoading,
SearchProvider as UiSearchProvider, SearchProvider as UiSearchProvider,
searchFilters, searchFilters,
AiAssistantIcon,
AiAssistantProvider,
} from "docs-ui" } from "docs-ui"
import { config } from "../config" import { config } from "../config"
import basePathUrl from "../utils/base-path-url" import basePathUrl from "../utils/base-path-url"
@@ -53,28 +51,6 @@ const SearchProvider = ({ children }: SearchProviderProps) => {
), ),
filterOptions: searchFilters, filterOptions: searchFilters,
}} }}
commands={[
{
name: "ai-assistant",
icon: <AiAssistantIcon />,
component: (
<AiAssistantProvider
apiUrl={process.env.NEXT_PUBLIC_AI_ASSISTANT_URL || "temp"}
websiteId={process.env.NEXT_PUBLIC_AI_WEBSITE_ID || "temp"}
recaptchaSiteKey={
process.env.NEXT_PUBLIC_AI_API_ASSISTANT_RECAPTCHA_SITE_KEY ||
"temp"
}
/>
),
title: "AI Assistant",
badge: {
variant: "blue",
badgeType: "shaded",
children: "Beta",
},
},
]}
> >
{children} {children}
</UiSearchProvider> </UiSearchProvider>
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

+13 -4
View File
@@ -1,5 +1,5 @@
import clsx from "clsx" import clsx from "clsx"
import { MainNav, RootProviders } from "docs-ui" import { AiAssistantChatWindow, MainNav, RootProviders } from "docs-ui"
import HomepageTopSection from "../components/Homepage/TopSection" import HomepageTopSection from "../components/Homepage/TopSection"
import Providers from "../providers" import Providers from "../providers"
import HomepageLinksSection from "../components/Homepage/LinksSection" import HomepageLinksSection from "../components/Homepage/LinksSection"
@@ -12,12 +12,20 @@ const Homepage = () => {
<body <body
className={clsx( className={clsx(
"bg-medusa-bg-subtle font-base text-medium w-full", "bg-medusa-bg-subtle font-base text-medium w-full",
"text-medusa-fg-subtle px-0.25 pt-0.25", "text-medusa-fg-base px-0.25 pt-0.25",
"h-screen overflow-hidden" "h-screen overflow-hidden"
)} )}
> >
<RootProviders> <RootProviders
<Providers> layoutProviderProps={{
disableResizeObserver: true,
}}
>
<Providers
aiAssistantProps={{
chatType: "popover",
}}
>
<div <div
className={clsx( className={clsx(
"rounded-t bg-medusa-bg-base", "rounded-t bg-medusa-bg-base",
@@ -40,6 +48,7 @@ const Homepage = () => {
<HomepageModulesSection /> <HomepageModulesSection />
<HomepageFooter /> <HomepageFooter />
</div> </div>
<AiAssistantChatWindow />
</Providers> </Providers>
</RootProviders> </RootProviders>
</body> </body>
+26 -8
View File
@@ -1,6 +1,8 @@
"use client" "use client"
import { import {
AiAssistantProvider,
AiAssistantProviderProps,
AnalyticsProvider, AnalyticsProvider,
HooksLoader, HooksLoader,
LearningPathProvider, LearningPathProvider,
@@ -16,9 +18,10 @@ import { MainNavProvider } from "./main-nav"
type ProvidersProps = { type ProvidersProps = {
children?: React.ReactNode children?: React.ReactNode
aiAssistantProps?: Partial<Omit<AiAssistantProviderProps, "children">>
} }
const Providers = ({ children }: ProvidersProps) => { const Providers = ({ children, aiAssistantProps = {} }: ProvidersProps) => {
return ( return (
<AnalyticsProvider writeKey={process.env.NEXT_PUBLIC_SEGMENT_API_KEY}> <AnalyticsProvider writeKey={process.env.NEXT_PUBLIC_SEGMENT_API_KEY}>
<SiteConfigProvider config={config}> <SiteConfigProvider config={config}>
@@ -29,14 +32,29 @@ const Providers = ({ children }: ProvidersProps) => {
<PaginationProvider> <PaginationProvider>
<MainNavProvider> <MainNavProvider>
<SearchProvider> <SearchProvider>
<HooksLoader <AiAssistantProvider
options={{ {...aiAssistantProps}
pageScrollManager: true, apiUrl={
currentLearningPath: false, process.env.NEXT_PUBLIC_AI_ASSISTANT_URL || "temp"
}} }
websiteId={
process.env.NEXT_PUBLIC_AI_WEBSITE_ID || "temp"
}
recaptchaSiteKey={
process.env
.NEXT_PUBLIC_AI_API_ASSISTANT_RECAPTCHA_SITE_KEY ||
"temp"
}
> >
{children} <HooksLoader
</HooksLoader> options={{
pageScrollManager: true,
currentLearningPath: false,
}}
>
{children}
</HooksLoader>
</AiAssistantProvider>
</SearchProvider> </SearchProvider>
</MainNavProvider> </MainNavProvider>
</PaginationProvider> </PaginationProvider>
+1 -28
View File
@@ -1,11 +1,6 @@
"use client" "use client"
import { import { SearchProvider as UiSearchProvider, searchFilters } from "docs-ui"
AiAssistantIcon,
AiAssistantProvider,
SearchProvider as UiSearchProvider,
searchFilters,
} from "docs-ui"
import { config } from "../config" import { config } from "../config"
type SearchProviderProps = { type SearchProviderProps = {
@@ -52,28 +47,6 @@ const SearchProvider = ({ children }: SearchProviderProps) => {
), ),
filterOptions: searchFilters, filterOptions: searchFilters,
}} }}
commands={[
{
name: "ai-assistant",
icon: <AiAssistantIcon />,
component: (
<AiAssistantProvider
apiUrl={process.env.NEXT_PUBLIC_AI_ASSISTANT_URL || "temp"}
websiteId={process.env.NEXT_PUBLIC_AI_WEBSITE_ID || "temp"}
recaptchaSiteKey={
process.env.NEXT_PUBLIC_AI_API_ASSISTANT_RECAPTCHA_SITE_KEY ||
"temp"
}
/>
),
title: "AI Assistant",
badge: {
variant: "blue",
badgeType: "shaded",
children: "Beta",
},
},
]}
initialDefaultFilters={["guides"]} initialDefaultFilters={["guides"]}
> >
{children} {children}
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

+22 -7
View File
@@ -1,6 +1,7 @@
"use client" "use client"
import { import {
AiAssistantProvider,
AnalyticsProvider, AnalyticsProvider,
HooksLoader, HooksLoader,
LearningPathProvider, LearningPathProvider,
@@ -31,14 +32,28 @@ const Providers = ({ children }: ProvidersProps) => {
<PaginationProvider> <PaginationProvider>
<MainNavProvider> <MainNavProvider>
<SearchProvider> <SearchProvider>
<HooksLoader <AiAssistantProvider
options={{ apiUrl={
pageScrollManager: true, process.env.NEXT_PUBLIC_AI_ASSISTANT_URL || "temp"
currentLearningPath: false, }
}} websiteId={
process.env.NEXT_PUBLIC_AI_WEBSITE_ID || "temp"
}
recaptchaSiteKey={
process.env
.NEXT_PUBLIC_AI_API_ASSISTANT_RECAPTCHA_SITE_KEY ||
"temp"
}
> >
{children} <HooksLoader
</HooksLoader> options={{
pageScrollManager: true,
currentLearningPath: false,
}}
>
{children}
</HooksLoader>
</AiAssistantProvider>
</SearchProvider> </SearchProvider>
</MainNavProvider> </MainNavProvider>
</PaginationProvider> </PaginationProvider>
+1 -28
View File
@@ -1,11 +1,6 @@
"use client" "use client"
import { import { SearchProvider as UiSearchProvider, searchFilters } from "docs-ui"
AiAssistantIcon,
AiAssistantProvider,
SearchProvider as UiSearchProvider,
searchFilters,
} from "docs-ui"
import { config } from "../config" import { config } from "../config"
type SearchProviderProps = { type SearchProviderProps = {
@@ -41,28 +36,6 @@ const SearchProvider = ({ children }: SearchProviderProps) => {
checkInternalPattern: new RegExp(`^${config.baseUrl}/resources/.*`), checkInternalPattern: new RegExp(`^${config.baseUrl}/resources/.*`),
filterOptions: searchFilters, filterOptions: searchFilters,
}} }}
commands={[
{
name: "ai-assistant",
icon: <AiAssistantIcon />,
component: (
<AiAssistantProvider
apiUrl={process.env.NEXT_PUBLIC_AI_ASSISTANT_URL || "temp"}
websiteId={process.env.NEXT_PUBLIC_AI_WEBSITE_ID || "temp"}
recaptchaSiteKey={
process.env.NEXT_PUBLIC_AI_API_ASSISTANT_RECAPTCHA_SITE_KEY ||
"temp"
}
/>
),
title: "AI Assistant",
badge: {
variant: "blue",
badgeType: "shaded",
children: "Beta",
},
},
]}
initialDefaultFilters={["guides"]} initialDefaultFilters={["guides"]}
> >
{children} {children}
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

+1 -1
View File
@@ -23,7 +23,7 @@ export const Feedback = ({ title, ...props }: FeedbackProps) => {
section: title, section: title,
}} }}
{...props} {...props}
className={clsx("text-medusa-fg-subtle", props.className)} className={clsx("text-medusa-fg-base", props.className)}
/> />
) )
} }
+11 -1
View File
@@ -1,6 +1,7 @@
"use client" "use client"
import { import {
AiAssistantProvider,
AnalyticsProvider, AnalyticsProvider,
ScrollControllerProvider, ScrollControllerProvider,
SiteConfigProvider, SiteConfigProvider,
@@ -23,7 +24,16 @@ const Providers = ({ children }: ProvidersProps) => {
<SidebarProvider> <SidebarProvider>
<MainNavProvider> <MainNavProvider>
<SearchProvider> <SearchProvider>
<TooltipProvider>{children}</TooltipProvider> <AiAssistantProvider
apiUrl={process.env.NEXT_PUBLIC_AI_ASSISTANT_URL || "temp"}
websiteId={process.env.NEXT_PUBLIC_AI_WEBSITE_ID || "temp"}
recaptchaSiteKey={
process.env
.NEXT_PUBLIC_AI_API_ASSISTANT_RECAPTCHA_SITE_KEY || "temp"
}
>
<TooltipProvider>{children}</TooltipProvider>
</AiAssistantProvider>
</SearchProvider> </SearchProvider>
</MainNavProvider> </MainNavProvider>
</SidebarProvider> </SidebarProvider>
+1 -29
View File
@@ -1,11 +1,6 @@
"use client" "use client"
import { import { SearchProvider as UiSearchProvider, searchFilters } from "docs-ui"
AiAssistantIcon,
AiAssistantProvider,
SearchProvider as UiSearchProvider,
searchFilters,
} from "docs-ui"
import { absoluteUrl } from "../lib/absolute-url" import { absoluteUrl } from "../lib/absolute-url"
type SearchProviderProps = { type SearchProviderProps = {
@@ -37,29 +32,6 @@ const SearchProvider = ({ children }: SearchProviderProps) => {
filterOptions: searchFilters, filterOptions: searchFilters,
}} }}
initialDefaultFilters={["ui"]} initialDefaultFilters={["ui"]}
commands={[
{
name: "ai-assistant",
icon: <AiAssistantIcon />,
component: (
<AiAssistantProvider
apiUrl={process.env.NEXT_PUBLIC_AI_ASSISTANT_URL || "temp"}
websiteId={process.env.NEXT_PUBLIC_AI_WEBSITE_ID || "temp"}
recaptchaSiteKey={
process.env.NEXT_PUBLIC_AI_API_ASSISTANT_RECAPTCHA_SITE_KEY ||
"temp"
}
version="v1"
/>
),
title: "AI Assistant",
badge: {
variant: "blue",
badgeType: "shaded",
children: "Beta",
},
},
]}
> >
{children} {children}
</UiSearchProvider> </UiSearchProvider>
+1 -1
View File
@@ -28,7 +28,7 @@
} }
body { body {
@apply text-ui-fg-subtle; @apply text-ui-fg-base;
} }
/* Hack to hide navbar / toc when some components like prompt are opened. */ /* Hack to hide navbar / toc when some components like prompt are opened. */
+22 -6
View File
@@ -1,6 +1,7 @@
"use client" "use client"
import { import {
AiAssistantProvider,
AnalyticsProvider, AnalyticsProvider,
ColorModeProvider, ColorModeProvider,
HooksLoader, HooksLoader,
@@ -35,13 +36,28 @@ const Providers = ({ children }: ProvidersProps) => {
<PaginationProvider> <PaginationProvider>
<MainNavProvider> <MainNavProvider>
<SearchProvider> <SearchProvider>
<HooksLoader <AiAssistantProvider
options={{ apiUrl={
pageScrollManager: true, process.env.NEXT_PUBLIC_AI_ASSISTANT_URL ||
}} "temp"
}
websiteId={
process.env.NEXT_PUBLIC_AI_WEBSITE_ID || "temp"
}
recaptchaSiteKey={
// eslint-disable-next-line prettier/prettier, max-len
process.env.NEXT_PUBLIC_AI_API_ASSISTANT_RECAPTCHA_SITE_KEY ||
"temp"
}
> >
{children} <HooksLoader
</HooksLoader> options={{
pageScrollManager: true,
}}
>
{children}
</HooksLoader>
</AiAssistantProvider>
</SearchProvider> </SearchProvider>
</MainNavProvider> </MainNavProvider>
</PaginationProvider> </PaginationProvider>
+1 -28
View File
@@ -1,11 +1,6 @@
"use client" "use client"
import { import { SearchProvider as UiSearchProvider, searchFilters } from "docs-ui"
SearchProvider as UiSearchProvider,
AiAssistantIcon,
AiAssistantProvider,
searchFilters,
} from "docs-ui"
import { config } from "../config" import { config } from "../config"
type SearchProviderProps = { type SearchProviderProps = {
@@ -46,28 +41,6 @@ const SearchProvider = ({ children }: SearchProviderProps) => {
}} }}
// TODO change later when we have a user guide filter // TODO change later when we have a user guide filter
initialDefaultFilters={["guides"]} initialDefaultFilters={["guides"]}
commands={[
{
name: "ai-assistant",
icon: <AiAssistantIcon />,
component: (
<AiAssistantProvider
apiUrl={process.env.NEXT_PUBLIC_AI_ASSISTANT_URL || "temp"}
websiteId={process.env.NEXT_PUBLIC_AI_WEBSITE_ID || "temp"}
recaptchaSiteKey={
process.env.NEXT_PUBLIC_AI_API_ASSISTANT_RECAPTCHA_SITE_KEY ||
"temp"
}
/>
),
title: "AI Assistant",
badge: {
variant: "blue",
children: "Beta",
badgeType: "shaded",
},
},
]}
> >
{children} {children}
</UiSearchProvider> </UiSearchProvider>
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

@@ -0,0 +1,29 @@
import React from "react"
import clsx from "clsx"
import { Kbd } from "../../../Kbd"
export const AiAssistantChatWindowFooter = () => {
return (
<div
className={clsx(
"bg-medusa-bg-component border-t border-medusa-border-base",
"flex items-center justify-end gap-docs_0.75 text-compact-x-small",
"py-docs_0.75 px-docs_1"
)}
>
<span className="text-medusa-fg-muted">Chat is cleared on refresh</span>
<span className="h-full w-px bg-medusa-border-base"></span>
<div className="flex items-center gap-docs_0.5">
<span className="text-medusa-fg-subtle">Line break</span>
<div className="flex items-center gap-[5px]">
<Kbd className="bg-medusa-bg-field-component border-medusa-border-strong w-[18px] h-[18px] inline-block">
⇧
</Kbd>
<Kbd className="bg-medusa-bg-field-component border-medusa-border-strong w-[18px] h-[18px] inline-block">
↵
</Kbd>
</div>
</div>
</div>
)
}
@@ -0,0 +1,48 @@
"use client"
import clsx from "clsx"
import React from "react"
import { Tooltip } from "../../../Tooltip"
import { Link } from "../../../Link"
import { ShieldCheck, XMark } from "@medusajs/icons"
import { Button } from "../../../Button"
import { useAiAssistant } from "../../../../providers"
export const AiAssistantChatWindowHeader = () => {
const { setChatOpened } = useAiAssistant()
return (
<div
className={clsx(
"flex gap-docs_0.5 items-center justify-between",
"w-full px-docs_1 py-docs_0.75 rounded-t-docs_sm",
"border-medusa-border-base border-b"
)}
>
<div className="flex gap-[6px] items-center">
<span className="text-h3 text-medusa-fg-base">Ask Anything</span>
<Tooltip
tooltipChildren={
<>
This site is protected by reCAPTCHA and the{" "}
<Link href="https://policies.google.com/privacy">
Google Privacy Policy
</Link>{" "}
and <Link href="https://policies.google.com/terms">ToS</Link>{" "}
apply
</>
}
clickable={true}
>
<ShieldCheck className="text-medusa-fg-muted" />
</Tooltip>
</div>
<Button
variant="transparent-clear"
className="!p-[6.5px] rounded-docs_sm"
onClick={() => setChatOpened(false)}
>
<XMark className="text-medusa-fg-muted" height={15} width={15} />
</Button>
</div>
)
}
@@ -0,0 +1,106 @@
import React, { useEffect, useRef } from "react"
import clsx from "clsx"
import { useAiAssistantChat } from "../../../../providers/AiAssistant/Chat"
import { ArrowUpCircleSolid } from "@medusajs/icons"
export const AiAssistantChatWindowInput = () => {
const {
inputRef,
question,
setQuestion,
handleSubmit: submitQuestion,
loading,
getThreadItems,
} = useAiAssistantChat()
const formRef = useRef<HTMLFormElement | null>(null)
const onSubmit = (e?: React.FormEvent<HTMLFormElement>) => {
e?.preventDefault()
submitQuestion()
}
const handleKeyboardDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "ArrowUp" && !question) {
const lastQuestion = getThreadItems()
.reverse()
.find((item) => item.type === "question")
if (lastQuestion) {
setQuestion(lastQuestion.content)
}
return
}
if (e.key !== "Enter") {
return
}
if (e.shiftKey) {
setQuestion((prev) => `${prev}\n`)
} else {
onSubmit()
}
}
const adjustTextareaHeight = () => {
if (!inputRef.current) {
return
}
if (!question.length) {
inputRef.current.style.height = "auto"
return
}
inputRef.current.style.height = `${inputRef.current.scrollHeight}px`
}
useEffect(() => {
adjustTextareaHeight()
}, [question])
const handleTouch = (e: React.TouchEvent<HTMLTextAreaElement>) => {
e.preventDefault()
inputRef.current?.focus({
preventScroll: true,
})
}
return (
<div
className={clsx(
"px-docs_1 py-docs_0.75 border-t border-medusa-border-base"
)}
>
<form
className="flex flex-col gap-docs_0.5"
onSubmit={onSubmit}
ref={formRef}
>
<textarea
className={clsx(
"appearance-none text-base md:text-small placeholder:text-medusa-fg-muted",
"text-medusa-fg-base max-h-[210px] overflow-auto resize-none bg-transparent",
"focus:outline-none focus:ring-0 disabled:cursor-not-allowed max-h-[210px]",
"disabled:!bg-transparent disabled:text-medusa-fg-disabled"
)}
value={question}
onChange={(e) => setQuestion(e.target.value)}
onKeyDown={handleKeyboardDown}
onTouchStart={handleTouch}
onTouchMove={handleTouch}
onTouchEnd={handleTouch}
ref={inputRef as React.RefObject<HTMLTextAreaElement | null>}
placeholder="Ask me a question about Medusa..."
disabled={loading}
/>
<div className="flex items-center justify-end">
<button
className={clsx(
"appearance-none p-0 text-medusa-fg-base disabled:text-medusa-fg-disabled",
"transition-colors"
)}
disabled={!question || loading}
>
<ArrowUpCircleSolid />
</button>
</div>
</form>
</div>
)
}
@@ -0,0 +1,196 @@
"use client"
import clsx from "clsx"
import React, { useCallback, useEffect, useRef, useState } from "react"
import { useAiAssistant, useIsBrowser } from "../../../providers"
import { AiAssistantChatWindowHeader } from "./Header"
import { useAiAssistantChat } from "../../../providers/AiAssistant/Chat"
import { AiAssistantSuggestions } from "../Suggestions"
import { AiAssistantThreadItem } from "../ThreadItem"
import { AiAssistantChatWindowInput } from "./Input"
import { useAiAssistantChatNavigation, useKeyboardShortcut } from "../../.."
import { AiAssistantChatWindowFooter } from "./Footer"
const DEFAULT_HEIGHT = "calc(100% - 8px)"
export const AiAssistantChatWindow = () => {
const { chatOpened, setChatOpened, chatType: type } = useAiAssistant()
const [height, setHeight] = useState(DEFAULT_HEIGHT)
const [showFade, setShowFade] = useState(false)
const { isBrowser } = useIsBrowser()
const {
inputRef,
thread,
getThreadItems: getChatThreadItems,
answer,
loading,
contentRef,
} = useAiAssistantChat()
const chatWindowRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (chatOpened) {
inputRef.current?.focus({
preventScroll: true,
})
} else {
inputRef.current?.blur()
}
}, [chatOpened])
const getThreadItems = useCallback(() => {
const sortedThread = getChatThreadItems()
return sortedThread.map((item, index) => (
<AiAssistantThreadItem item={item} key={index} />
))
}, [getChatThreadItems])
useAiAssistantChatNavigation({
getChatWindowElm: () => chatWindowRef.current as HTMLElement | null,
getInputElm: () => inputRef.current as HTMLTextAreaElement | null,
focusInput: () =>
inputRef.current?.focus({
preventScroll: true,
}),
})
useKeyboardShortcut({
metakey: false,
shortcutKeys: ["escape"],
checkEditing: false,
action: () => {
if (!chatWindowRef.current?.contains(document.activeElement)) {
return
}
setChatOpened(false)
},
})
const checkShowFade = () => {
const parentElm = contentRef.current?.parentElement
if (!parentElm) {
return
}
setShowFade(
!loading &&
parentElm.offsetHeight + parentElm.scrollTop < parentElm.scrollHeight
)
}
useEffect(() => {
if (!contentRef.current?.parentElement) {
return
}
contentRef.current.parentElement.addEventListener("scroll", checkShowFade)
return () => {
contentRef.current?.parentElement?.removeEventListener(
"scroll",
checkShowFade
)
}
}, [contentRef.current])
useEffect(() => {
if (loading) {
setShowFade(false)
} else {
checkShowFade()
}
}, [loading])
const changeHeightForViewport = () => {
if (!window.visualViewport?.height) {
setHeight(DEFAULT_HEIGHT)
return
}
setHeight(`${window.visualViewport.height - 8}px`)
}
useEffect(() => {
if (!isBrowser) {
return
}
window.visualViewport?.addEventListener("resize", changeHeightForViewport)
return () => {
window.visualViewport?.removeEventListener(
"resize",
changeHeightForViewport
)
}
}, [isBrowser])
useEffect(() => {
checkShowFade()
}, [height])
return (
<>
<div
className={clsx(
"fixed top-0 left-0 h-screen w-screen z-50 bg-medusa-bg-overlay",
!chatOpened && "hidden",
chatOpened && "block",
type === "default" && "xxl:hidden"
)}
onClick={() => setChatOpened(false)}
/>
<div
className={clsx(
"flex z-50 w-[calc(100%-8px)] md:w-ai-assistant transition-[height,right]",
"absolute -right-[150%] sm:-right-full top-0",
type === "default" && [
"xxl:w-0 xxl:relative xxl:transition-[height,right,width]",
"xxl:shadow-elevation-card-rest xxl:dark:shadow-elevation-card-rest-dark",
chatOpened && "xxl:!w-ai-assistant",
],
"shadow-elevation-modal dark:shadow-elevation-modal-dark",
"bg-medusa-bg-base rounded-docs_DEFAULT overflow-x-hidden",
"flex-col justify-between m-docs_0.25 max-w-ai-assistant",
chatOpened && ["!right-0"]
)}
style={{
height,
}}
ref={chatWindowRef}
>
<AiAssistantChatWindowHeader />
<div className="flex flex-auto overflow-auto relative">
<div
className={clsx(
"overflow-y-auto flex-auto px-docs_0.5 pt-docs_0.25 pb-docs_2"
)}
>
<div ref={contentRef}>
{!thread.length && <AiAssistantSuggestions />}
{getThreadItems()}
{(answer.length || loading) && (
<AiAssistantThreadItem
item={{
type: "answer",
content: answer,
order: 0,
}}
/>
)}
</div>
</div>
<span
className={clsx(
"bg-ai-assistant-bottom content-[''] absolute pointer-events-none",
"bottom-0 left-0 w-full h-docs_6 z-10 opacity-0 transition-opacity",
showFade && "opacity-100"
)}
></span>
</div>
<AiAssistantChatWindowInput />
<AiAssistantChatWindowFooter />
</div>
</>
)
}
@@ -0,0 +1,186 @@
"use client"
import React, { useCallback } from "react"
import { Badge, Button, InputText, Kbd, Tooltip, Link } from "@/components"
import { useSearch } from "@/providers"
import { ArrowUturnLeft } from "@medusajs/icons"
import clsx from "clsx"
import { AiAssistantThreadItem } from "../ThreadItem"
import { AiAssistantSuggestions } from "../Suggestions"
import { useAiAssistantChat } from "../../../providers/AiAssistant/Chat"
import { useSearchNavigation } from "../../.."
export const AiAssistantSearchWindow = () => {
const {
handleSubmit,
getThreadItems: getChatThreadItems,
question,
setQuestion,
inputRef,
contentRef,
loading,
thread,
answer,
} = useAiAssistantChat()
const { setCommand } = useSearch()
const getThreadItems = useCallback(() => {
const sortedThread = getChatThreadItems()
return sortedThread.map((item, index) => (
<AiAssistantThreadItem item={item} key={index} />
))
}, [getChatThreadItems])
useSearchNavigation({
getInputElm: () => inputRef.current as HTMLInputElement | null,
focusInput: () => inputRef.current?.focus(),
handleSubmit,
})
return (
<div className="h-full">
<div className={clsx("px-docs_1 pt-docs_1")}>
<Tooltip
tooltipChildren={
<>
This site is protected by reCAPTCHA and the{" "}
<Link href="https://policies.google.com/privacy">
Google Privacy Policy
</Link>{" "}
and <Link href="https://policies.google.com/terms">ToS</Link>{" "}
apply
</>
}
clickable={true}
>
<Badge variant="neutral">AI Assistant</Badge>
</Tooltip>
</div>
<div
className={clsx(
"flex gap-docs_1 px-docs_1 py-docs_0.75",
"h-[57px] w-full md:rounded-t-docs_xl relative border-0 border-solid",
"border-b border-medusa-border-base relative"
)}
>
<Button
variant="transparent"
onClick={() => setCommand(null)}
className="text-medusa-fg-muted p-[6.5px]"
>
<ArrowUturnLeft />
</Button>
<InputText
value={question}
onChange={(e) => setQuestion(e.target.value)}
className={clsx(
"bg-transparent border-0 focus:outline-none hover:!bg-transparent",
"!shadow-none flex-1 text-medusa-fg-base",
"disabled:!bg-transparent disabled:cursor-not-allowed"
)}
placeholder="Ask me a question about Medusa..."
autoFocus={true}
passedRef={inputRef as React.RefObject<HTMLInputElement | null>}
disabled={loading}
/>
<span
onClick={() => {
setQuestion("")
inputRef.current?.focus()
}}
className={clsx(
"text-medusa-fg-muted hover:text-medusa-fg-subtle",
"absolute top-docs_0.75 right-docs_1",
"cursor-pointer",
question.length === 0 && "hidden"
)}
>
Clear
</span>
</div>
<div className="h-[calc(100%-95px)] lg:max-h-[calc(100%-140px)] lg:min-h-[calc(100%-140px)] overflow-auto">
<div ref={contentRef}>
{!thread.length && <AiAssistantSuggestions className="mx-docs_0.5" />}
{getThreadItems()}
{(answer.length || loading) && (
<AiAssistantThreadItem
item={{
type: "answer",
content: answer,
order: 0,
}}
/>
)}
</div>
</div>
<div
className={clsx(
"py-docs_0.75 hidden md:flex items-center justify-end px-docs_1",
"border-medusa-border-base border-t",
"bg-medusa-bg-field-component"
)}
>
<div className="flex items-center gap-docs_0.75">
<div className="flex items-center gap-docs_0.5">
{thread.length === 0 && (
<>
<span
className={clsx(
"text-medusa-fg-subtle",
"text-compact-x-small"
)}
>
Navigate FAQ
</span>
<span className="gap-[5px] flex">
<Kbd
className={clsx(
"!bg-medusa-bg-field-component !border-medusa-border-strong",
"!text-medusa-fg-subtle h-[18px] w-[18px] p-0"
)}
>
↑
</Kbd>
<Kbd
className={clsx(
"!bg-medusa-bg-field-component !border-medusa-border-strong",
"!text-medusa-fg-subtle h-[18px] w-[18px] p-0"
)}
>
↓
</Kbd>
</span>
</>
)}
{thread.length > 0 && (
<span
className={clsx("text-medusa-fg-muted", "text-compact-x-small")}
>
Chat is cleared on exit
</span>
)}
</div>
<div
className={clsx("h-docs_0.75 w-px bg-medusa-border-strong")}
></div>
<div className="flex items-center gap-docs_0.5">
<span
className={clsx("text-medusa-fg-subtle", "text-compact-x-small")}
>
Ask Question
</span>
<Kbd
className={clsx(
"!bg-medusa-bg-field-component !border-medusa-border-strong",
"!text-medusa-fg-subtle h-[18px] w-[18px] p-0"
)}
>
↵
</Kbd>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,76 @@
"use client"
import React, { useMemo } from "react"
import { SearchSuggestionType } from "../../Search/Suggestions"
import { useAiAssistant } from "../../../providers"
import { SearchHitGroupName } from "../../Search/Hits/GroupName"
import { SearchSuggestionItem } from "../../Search/Suggestions/Item"
import { useAiAssistantChat } from "../../../providers/AiAssistant/Chat"
type AiAssistantSuggestionsProps = React.AllHTMLAttributes<HTMLDivElement>
export const AiAssistantSuggestions = (props: AiAssistantSuggestionsProps) => {
const { version } = useAiAssistant()
const { setQuestion, handleSubmit } = useAiAssistantChat()
const suggestions: SearchSuggestionType[] = useMemo(() => {
return version === "v2"
? [
{
title: "FAQ",
items: [
"What is Medusa?",
"How can I create a module?",
"How can I create a data model?",
"How do I create a workflow?",
"How can I extend a data model in the Product Module?",
],
},
{
title: "Recipes",
items: [
"How do I build a marketplace with Medusa?",
"How do I build digital products with Medusa?",
"How do I build subscription-based purchases with Medusa?",
"What other recipes are available in the Medusa documentation?",
],
},
]
: [
{
title: "FAQ",
items: [
"What is Medusa?",
"How can I create an ecommerce store with Medusa?",
"How can I build a marketplace with Medusa?",
"How can I build subscription-based purchases with Medusa?",
"How can I build digital products with Medusa?",
"What can I build with Medusa?",
"What is Medusa Admin?",
"How do I configure the database in Medusa?",
],
},
]
}, [version])
return (
<div {...props}>
{suggestions.map((suggestion, index) => (
<React.Fragment key={index}>
<SearchHitGroupName name={suggestion.title} />
{suggestion.items.map((item, itemIndex) => (
<SearchSuggestionItem
onClick={() => {
setQuestion(item)
handleSubmit(item)
}}
key={itemIndex}
tabIndex={itemIndex}
>
{item}
</SearchSuggestionItem>
))}
</React.Fragment>
))}
</div>
)
}
@@ -1,19 +1,17 @@
import React, { useState } from "react" import React, { useState } from "react"
import { ThreadType } from "../.."
import clsx from "clsx" import clsx from "clsx"
import { Button, type ButtonProps } from "@/components" import { Badge, Button, Link, type ButtonProps } from "@/components"
import { Check, SquareTwoStackMini, ThumbDown, ThumbUp } from "@medusajs/icons" import { ThumbDown, ThumbUp } from "@medusajs/icons"
import { useCopy } from "@/hooks"
import { AiAssistantFeedbackType, useAiAssistant } from "@/providers" import { AiAssistantFeedbackType, useAiAssistant } from "@/providers"
import { AiAssistantThread } from "../../../../providers/AiAssistant/Chat"
export type AiAssistantThreadItemActionsProps = { export type AiAssistantThreadItemActionsProps = {
item: ThreadType item: AiAssistantThread
} }
export const AiAssistantThreadItemActions = ({ export const AiAssistantThreadItemActions = ({
item, item,
}: AiAssistantThreadItemActionsProps) => { }: AiAssistantThreadItemActionsProps) => {
const { isCopied, handleCopy } = useCopy(item.content)
const [feedback, setFeedback] = useState<AiAssistantFeedbackType | null>(null) const [feedback, setFeedback] = useState<AiAssistantFeedbackType | null>(null)
const { sendFeedback } = useAiAssistant() const { sendFeedback } = useAiAssistant()
@@ -37,28 +35,36 @@ export const AiAssistantThreadItemActions = ({
} }
return ( return (
<div <div className={clsx("flex gap-docs_0.75 justify-between items-center")}>
className={clsx("hidden md:flex gap-docs_0.25", "text-medusa-fg-muted")} {item.sources !== undefined && item.sources.length > 0 && (
> <div className="flex gap-[6px] items-center flex-wrap">
<ActionButton onClick={handleCopy}> {item.sources.map((source) => (
{isCopied ? <Check /> : <SquareTwoStackMini />} <Badge key={source.source_url} variant="neutral">
</ActionButton> <Link href={source.source_url} className="!text-inherit">
{(feedback === null || feedback === "upvote") && ( {source.title}
<ActionButton </Link>
onClick={async () => handleFeedback("upvote", item.question_id)} </Badge>
className={clsx(feedback === "upvote" && "!text-medusa-fg-subtle")} ))}
> </div>
<ThumbUp />
</ActionButton>
)}
{(feedback === null || feedback === "downvote") && (
<ActionButton
onClick={async () => handleFeedback("downvote", item.question_id)}
className={clsx(feedback === "downvote" && "!text-medusa-fg-subtle")}
>
<ThumbDown />
</ActionButton>
)} )}
<div className="flex gap-docs_0.25 items-center text-medusa-fg-muted">
{(feedback === null || feedback === "upvote") && (
<ActionButton
onClick={async () => handleFeedback("upvote", item.question_id)}
className={clsx(feedback === "upvote" && "!text-medusa-fg-muted")}
>
<ThumbUp />
</ActionButton>
)}
{(feedback === null || feedback === "downvote") && (
<ActionButton
onClick={async () => handleFeedback("downvote", item.question_id)}
className={clsx(feedback === "downvote" && "!text-medusa-fg-muted")}
>
<ThumbDown />
</ActionButton>
)}
</div>
</div> </div>
) )
} }
@@ -68,7 +74,7 @@ const ActionButton = ({ children, className, ...props }: ButtonProps) => {
<Button <Button
variant="transparent" variant="transparent"
className={clsx( className={clsx(
"text-medusa-fg-muted hover:text-medusa-fg-subtle", "text-medusa-fg-muted hover:text-medusa-fg-muted",
"hover:bg-medusa-bg-subtle-hover", "hover:bg-medusa-bg-subtle-hover",
"!p-[4.5px] rounded-docs_sm", "!p-[4.5px] rounded-docs_sm",
className className
@@ -1,11 +1,11 @@
import clsx from "clsx" import clsx from "clsx"
import React from "react" import React from "react"
import { ThreadType } from ".."
import { AiAssistantIcon, DotsLoading, MarkdownContent } from "@/components" import { AiAssistantIcon, DotsLoading, MarkdownContent } from "@/components"
import { AiAssistantThreadItemActions } from "./Actions" import { AiAssistantThreadItemActions } from "./Actions"
import { AiAssistantThread } from "../../../providers/AiAssistant/Chat"
export type AiAssistantThreadItemProps = { export type AiAssistantThreadItemProps = {
item: ThreadType item: AiAssistantThread
} }
export const AiAssistantThreadItem = ({ item }: AiAssistantThreadItemProps) => { export const AiAssistantThreadItem = ({ item }: AiAssistantThreadItemProps) => {
@@ -24,16 +24,24 @@ export const AiAssistantThreadItem = ({ item }: AiAssistantThreadItemProps) => {
)} )}
<div <div
className={clsx( className={clsx(
"txt-small text-medusa-fg-subtle", "txt-small text-medusa-fg-base",
item.type === "question" && [ item.type === "question" && [
"rounded-docs_xl bg-medusa-tag-neutral-bg", "rounded-docs_xl bg-medusa-tag-neutral-bg",
"px-docs_0.75 py-docs_0.5", "px-docs_0.75 py-docs_0.5",
], ],
item.type !== "question" && "flex-1", item.type !== "question" && "flex-1",
item.type === "answer" && "text-pretty flex-1" item.type === "answer" && "text-pretty flex-1 max-w-[calc(100%-20px)]"
)} )}
> >
{item.type === "question" && <>{item.content}</>} {item.type === "question" && (
<MarkdownContent
className="[&>*:last-child]:mb-0"
allowedElements={["br", "p", "code", "pre"]}
unwrapDisallowed={true}
>
{item.content}
</MarkdownContent>
)}
{item.type === "answer" && ( {item.type === "answer" && (
<div className="flex flex-col gap-docs_0.75"> <div className="flex flex-col gap-docs_0.75">
{!item.question_id && item.content.length === 0 && <DotsLoading />} {!item.question_id && item.content.length === 0 && <DotsLoading />}
@@ -0,0 +1,71 @@
"use client"
import React, { useMemo, useState } from "react"
import { Button } from "../../Button"
import { Tooltip } from "../../Tooltip"
import { Kbd } from "../../Kbd"
import { getOsShortcut } from "../../../utils"
import { useAiAssistant, useSearch, useSiteConfig } from "../../../providers"
import { useKeyboardShortcut } from "../../../hooks"
import Image from "next/image"
const AI_ASSISTANT_ICON = "/images/ai-assistent-luminosity.png"
const AI_ASSISTANT_ICON_ACTIVE = "/images/ai-assistent.png"
export const AiAssistantTriggerButton = () => {
const [hovered, setHovered] = useState(false)
const { config } = useSiteConfig()
const { chatOpened, setChatOpened } = useAiAssistant()
const { setIsOpen } = useSearch()
const isActive = useMemo(() => {
return hovered || chatOpened
}, [hovered, chatOpened])
const osShortcut = getOsShortcut()
useKeyboardShortcut({
metakey: true,
shortcutKeys: ["i"],
action: () => {
setChatOpened((prev) => !prev)
setIsOpen(false)
},
checkEditing: false,
})
return (
<Tooltip
render={() => (
<span className="flex gap-[6px] items-center">
<span className="text-compact-x-small-plus text-medusa-fg-base">
Ask AI
</span>
<span className="flex gap-[5px] items-center">
<Kbd className="bg-medusa-bg-field-component border-medusa-border-strong w-[18px] h-[18px] inline-block">
{osShortcut}
</Kbd>
<Kbd className="bg-medusa-bg-field-component border-medusa-border-strong w-[18px] h-[18px] inline-block">
i
</Kbd>
</span>
</span>
)}
>
<Button
variant="transparent-clear"
className="!p-[6.5px]"
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
onTouchStart={() => setHovered(true)}
onTouchEnd={() => setHovered(false)}
onClick={() => setChatOpened((prev) => !prev)}
>
<Image
src={`${config.basePath}${isActive ? AI_ASSISTANT_ICON_ACTIVE : AI_ASSISTANT_ICON}`}
width={15}
height={15}
alt="AI Assistant"
/>
</Button>
</Tooltip>
)
}
@@ -1,481 +0,0 @@
"use client"
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"
import {
Badge,
Button,
InputText,
Kbd,
SearchSuggestionItem,
SearchSuggestionType,
SearchHitGroupName,
Tooltip,
Link,
} from "@/components"
import { useAiAssistant, useSearch } from "@/providers"
import { ArrowUturnLeft } from "@medusajs/icons"
import clsx from "clsx"
import { useSearchNavigation } from "@/hooks"
import { AiAssistantThreadItem } from "./ThreadItem"
import useResizeObserver from "@react-hook/resize-observer"
export type ChunkType = {
stream_end: boolean
} & (
| {
type: "relevant_sources"
content: {
relevant_sources: RelevantSourcesType[]
}
}
| {
type: "partial_answer"
content: PartialAnswerType
}
| {
type: "identifiers"
content: IdentifierType
}
| {
type: "error"
content: ErrorType
}
)
export type RelevantSourcesType = {
source_url: string
}
export type PartialAnswerType = {
text: string
}
export type IdentifierType = {
thread_id: string
question_answer_id: string
}
export type ErrorType = {
reason: string
}
export type ThreadType = {
type: "question" | "answer" | "error"
content: string
question_id?: string
// for some reason, items in the array get reordered
// sometimes, so this is one way to avoid it
order: number
}
export const AiAssistant = () => {
const [question, setQuestion] = useState("")
// this helps set the `order` field of the threadtype
const [messagesCount, setMessagesCount] = useState(0)
const [thread, setThread] = useState<ThreadType[]>([])
const [answer, setAnswer] = useState("")
const [identifiers, setIdentifiers] = useState<IdentifierType | null>(null)
const [loading, setLoading] = useState(false)
const { getAnswer, version } = useAiAssistant()
const { setCommand } = useSearch()
const inputRef = useRef<HTMLInputElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
const suggestions: SearchSuggestionType[] = useMemo(() => {
return version === "v2"
? [
{
title: "FAQ",
items: [
"What is Medusa?",
"How can I create a module?",
"How can I create a data model?",
"How do I create a workflow?",
"How can I extend a data model in the Product Module?",
],
},
{
title: "Recipes",
items: [
"How do I build a marketplace with Medusa?",
"How do I build digital products with Medusa?",
"How do I build subscription-based purchases with Medusa?",
"What other recipes are available in the Medusa documentation?",
],
},
]
: [
{
title: "FAQ",
items: [
"What is Medusa?",
"How can I create an ecommerce store with Medusa?",
"How can I build a marketplace with Medusa?",
"How can I build subscription-based purchases with Medusa?",
"How can I build digital products with Medusa?",
"What can I build with Medusa?",
"What is Medusa Admin?",
"How do I configure the database in Medusa?",
],
},
]
}, [version])
const handleSubmit = (selectedQuestion?: string) => {
if (!selectedQuestion?.length && !question.length) {
return
}
setLoading(true)
setAnswer("")
setThread((prevThread) => [
...prevThread,
{
type: "question",
content: selectedQuestion || question,
order: getNewOrder(prevThread),
},
])
setMessagesCount((prev) => prev + 1)
}
useSearchNavigation({
getInputElm: () => inputRef.current,
focusInput: () => inputRef.current?.focus(),
handleSubmit,
})
const sortThread = (threadArr: ThreadType[]) => {
const sortedThread = [...threadArr]
sortedThread.sort((itemA, itemB) => {
if (itemA.order < itemB.order) {
return -1
}
return itemA.order < itemB.order ? 1 : 0
})
return sortedThread
}
const getNewOrder = (prevThread: ThreadType[]) => {
const sortedThread = sortThread(prevThread)
return sortedThread.length === 0
? messagesCount + 1
: sortedThread[prevThread.length - 1].order + 1
}
const setError = (logMessage?: string) => {
if (logMessage?.length) {
console.error(`[AI ERROR]: ${logMessage}`)
}
setThread((prevThread) => [
...prevThread,
{
type: "error",
content:
"I'm sorry, but I'm having trouble connecting to my knowledge base. Please try again. If the issue keeps persisting, please consider reporting an issue.",
order: getNewOrder(prevThread),
},
])
setMessagesCount((prev) => prev + 1)
setLoading(false)
setQuestion("")
setAnswer("")
inputRef.current?.focus()
}
const scrollToBottom = () => {
const parent = contentRef.current?.parentElement as HTMLElement
parent.scrollTop = parent.scrollHeight
}
const lastAnswerIndex = useMemo(() => {
const index = thread.reverse().findIndex((item) => item.type === "answer")
return index !== -1 ? index : 0
}, [thread])
const process_stream = useCallback(async (response: Response) => {
const reader = response.body?.getReader()
if (!reader) {
return
}
const decoder = new TextDecoder("utf-8")
const delimiter = "\u241E"
const delimiterBytes = new TextEncoder().encode(delimiter)
let buffer = new Uint8Array()
const findDelimiterIndex = (arr: Uint8Array) => {
for (let i = 0; i < arr.length - delimiterBytes.length + 1; i++) {
let found = true
for (let j = 0; j < delimiterBytes.length; j++) {
if (arr[i + j] !== delimiterBytes[j]) {
found = false
break
}
}
if (found) {
return i
}
}
return -1
}
let result
let loop = true
while (loop) {
result = await reader.read()
if (result.done) {
loop = false
continue
}
buffer = new Uint8Array([...buffer, ...result.value])
let delimiterIndex
while ((delimiterIndex = findDelimiterIndex(buffer)) !== -1) {
const chunkBytes = buffer.slice(0, delimiterIndex)
const chunkText = decoder.decode(chunkBytes)
buffer = buffer.slice(delimiterIndex + delimiterBytes.length)
const chunk = JSON.parse(chunkText).chunk as ChunkType
if (chunk.type === "partial_answer") {
setAnswer((prevAnswer) => {
return prevAnswer + chunk.content.text
})
} else if (chunk.type === "identifiers") {
setIdentifiers(chunk.content)
} else if (chunk.type === "error") {
setError(chunk.content.reason)
loop = false
return
}
}
}
setLoading(false)
setQuestion("")
}, [])
const fetchAnswer = useCallback(async () => {
try {
const response = await getAnswer(question, identifiers?.thread_id)
if (response.status === 200) {
await process_stream(response)
} else {
const message = await response.text()
setError(message)
}
} catch (error: any) {
setError(JSON.stringify(error))
}
}, [question, identifiers, process_stream])
useEffect(() => {
if (loading && !answer) {
void fetchAnswer()
}
}, [loading, fetchAnswer])
useEffect(() => {
if (
!loading &&
answer.length &&
thread[lastAnswerIndex]?.content !== answer
) {
setThread((prevThread) => [
...prevThread,
{
type: "answer",
content: answer,
question_id: identifiers?.question_answer_id,
order: getNewOrder(prevThread),
},
])
setAnswer("")
setMessagesCount((prev) => prev + 1)
inputRef.current?.focus()
}
}, [loading, answer, thread, lastAnswerIndex, inputRef.current])
useResizeObserver(contentRef as React.RefObject<HTMLDivElement>, () => {
if (!loading) {
return
}
scrollToBottom()
})
const getThreadItems = useCallback(() => {
const sortedThread = sortThread(thread)
return sortedThread.map((item, index) => (
<AiAssistantThreadItem item={item} key={index} />
))
}, [thread])
return (
<div className="h-full">
<div className={clsx("px-docs_1 pt-docs_1")}>
<Tooltip
tooltipChildren={
<>
This site is protected by reCAPTCHA and the{" "}
<Link href="https://policies.google.com/privacy">
Google Privacy Policy
</Link>{" "}
and <Link href="https://policies.google.com/terms">ToS</Link>{" "}
apply
</>
}
clickable={true}
>
<Badge variant="neutral">AI Assistant</Badge>
</Tooltip>
</div>
<div
className={clsx(
"flex gap-docs_1 px-docs_1 py-docs_0.75",
"h-[57px] w-full md:rounded-t-docs_xl relative border-0 border-solid",
"border-b border-medusa-border-base relative"
)}
>
<Button
variant="transparent"
onClick={() => setCommand(null)}
className="text-medusa-fg-muted p-[6.5px]"
>
<ArrowUturnLeft />
</Button>
<InputText
value={question}
onChange={(e) => setQuestion(e.target.value)}
className={clsx(
"bg-transparent border-0 focus:outline-none hover:!bg-transparent",
"!shadow-none flex-1 text-medusa-fg-base",
"disabled:!bg-transparent disabled:cursor-not-allowed"
)}
placeholder="Ask me a question about Medusa..."
autoFocus={true}
passedRef={inputRef}
disabled={loading}
/>
<span
onClick={() => {
setQuestion("")
inputRef.current?.focus()
}}
className={clsx(
"text-medusa-fg-muted hover:text-medusa-fg-subtle",
"absolute top-docs_0.75 right-docs_1",
"cursor-pointer",
question.length === 0 && "hidden"
)}
>
Clear
</span>
</div>
<div className="h-[calc(100%-95px)] lg:max-h-[calc(100%-140px)] lg:min-h-[calc(100%-140px)] overflow-auto">
<div ref={contentRef}>
{!thread.length && (
<div className="mx-docs_0.5">
{suggestions.map((suggestion, index) => (
<React.Fragment key={index}>
<SearchHitGroupName name={suggestion.title} />
{suggestion.items.map((item, itemIndex) => (
<SearchSuggestionItem
onClick={() => {
setQuestion(item)
handleSubmit(item)
}}
key={itemIndex}
tabIndex={itemIndex}
>
{item}
</SearchSuggestionItem>
))}
</React.Fragment>
))}
</div>
)}
{getThreadItems()}
{(answer.length || loading) && (
<AiAssistantThreadItem
item={{
type: "answer",
content: answer,
order: 0,
}}
/>
)}
</div>
</div>
<div
className={clsx(
"py-docs_0.75 hidden md:flex items-center justify-end px-docs_1",
"border-medusa-border-base border-t",
"bg-medusa-bg-field-component"
)}
>
<div className="flex items-center gap-docs_0.75">
<div className="flex items-center gap-docs_0.5">
{thread.length === 0 && (
<>
<span
className={clsx(
"text-medusa-fg-subtle",
"text-compact-x-small"
)}
>
Navigate FAQ
</span>
<span className="gap-[5px] flex">
<Kbd
className={clsx(
"!bg-medusa-bg-field-component !border-medusa-border-strong",
"!text-medusa-fg-subtle h-[18px] w-[18px] p-0"
)}
>
↑
</Kbd>
<Kbd
className={clsx(
"!bg-medusa-bg-field-component !border-medusa-border-strong",
"!text-medusa-fg-subtle h-[18px] w-[18px] p-0"
)}
>
↓
</Kbd>
</span>
</>
)}
{thread.length > 0 && (
<span
className={clsx("text-medusa-fg-muted", "text-compact-x-small")}
>
Chat is cleared on exit
</span>
)}
</div>
<div
className={clsx("h-docs_0.75 w-px bg-medusa-border-strong")}
></div>
<div className="flex items-center gap-docs_0.5">
<span
className={clsx("text-medusa-fg-subtle", "text-compact-x-small")}
>
Ask Question
</span>
<Kbd
className={clsx(
"!bg-medusa-bg-field-component !border-medusa-border-strong",
"!text-medusa-fg-subtle h-[18px] w-[18px] p-0"
)}
>
↵
</Kbd>
</div>
</div>
</div>
</div>
)
}
@@ -16,7 +16,7 @@ export const ApiRunnerParamInputs = ({
}: ApiRunnerParamInputsProps) => { }: ApiRunnerParamInputsProps) => {
return ( return (
<div className="flex flex-col gap-docs_0.25 w-full"> <div className="flex flex-col gap-docs_0.25 w-full">
<span className="txt-small-plus text-medusa-fg-subtle">{title}</span> <span className="txt-small-plus text-medusa-fg-base">{title}</span>
<div className="flex flex-col gap-docs_0.5"> <div className="flex flex-col gap-docs_0.5">
{Object.keys(data).map((pathParam, index) => ( {Object.keys(data).map((pathParam, index) => (
<ApiRunnerParamInput <ApiRunnerParamInput
@@ -163,7 +163,7 @@ export const Feedback = ({
)} )}
ref={inlineFeedbackRef} ref={inlineFeedbackRef}
> >
<Label className={"text-compact-small text-medusa-fg-subtle"}> <Label className={"text-compact-small text-medusa-fg-base"}>
{question} {question}
</Label> </Label>
<div <div
@@ -16,9 +16,9 @@ export const Kbd = ({
className={clsx( className={clsx(
"rounded-docs_xs border-solid border border-medusa-border-base", "rounded-docs_xs border-solid border border-medusa-border-base",
"inline-flex items-center justify-center", "inline-flex items-center justify-center",
"py-0 px-docs_0.25", "p-0",
"bg-medusa-bg-field", "bg-medusa-bg-field",
"text-medusa-fg-subtle", "text-medusa-fg-base",
"font-base shadow-none", "font-base shadow-none",
variant === "small" variant === "small"
? "text-compact-x-small" ? "text-compact-x-small"
@@ -38,7 +38,7 @@ export const MDXComponents: MDXComponentsType = {
return ( return (
<p <p
className={clsx( className={clsx(
"text-medusa-fg-subtle [&:not(:last-child)]:mb-docs_1.5 last:!mb-0", "text-medusa-fg-base [&:not(:last-child)]:mb-docs_1.5 last:!mb-0",
className className
)} )}
{...props} {...props}
@@ -87,7 +87,7 @@ export const MDXComponents: MDXComponentsType = {
return ( return (
<li <li
className={clsx( className={clsx(
"text-medusa-fg-subtle [&:not(:last-child)]:mb-docs_0.5", "text-medusa-fg-base [&:not(:last-child)]:mb-docs_0.5",
"[&_ol]:mt-docs_0.5 [&_ul]:mt-docs_0.5", "[&_ol]:mt-docs_0.5 [&_ul]:mt-docs_0.5",
className className
)} )}
@@ -47,7 +47,9 @@ export const MainNavVersion = () => {
className="relative text-compact-small-plus" className="relative text-compact-small-plus"
onMouseOut={afterHover} onMouseOut={afterHover}
> >
<span>v{version.number}</span> <span className="flex justify-center items-center">
v{version.number}
</span>
{showNewBadge && ( {showNewBadge && (
<span <span
className={clsx( className={clsx(
@@ -7,6 +7,7 @@ import {
Button, Button,
LinkButton, LinkButton,
SearchModalOpener, SearchModalOpener,
useLayout,
useMainNav, useMainNav,
useSidebar, useSidebar,
useSiteConfig, useSiteConfig,
@@ -18,6 +19,7 @@ import { SidebarLeftIcon } from "../Icons/SidebarLeft"
import { MainNavMobileMenu } from "./MobileMenu" import { MainNavMobileMenu } from "./MobileMenu"
import Link from "next/link" import Link from "next/link"
import { MainNavVersion } from "./Version" import { MainNavVersion } from "./Version"
import { AiAssistantTriggerButton } from "../AiAssistant/TriggerButton"
type MainNavProps = { type MainNavProps = {
className?: string className?: string
@@ -28,17 +30,18 @@ export const MainNav = ({ className, itemsClassName }: MainNavProps) => {
const { editDate } = useMainNav() const { editDate } = useMainNav()
const { setMobileSidebarOpen, isSidebarShown } = useSidebar() const { setMobileSidebarOpen, isSidebarShown } = useSidebar()
const { config } = useSiteConfig() const { config } = useSiteConfig()
const { showCollapsedNavbar } = useLayout()
return ( return (
<div <div
className={clsx( className={clsx("w-full z-20 sticky top-0 bg-medusa-bg-base", className)}
"flex justify-between items-center",
"px-docs_1 w-full z-20",
"sticky top-0 bg-medusa-bg-base",
className
)}
> >
<div className="flex items-center gap-docs_1"> <div
className={clsx(
"flex justify-between items-center px-docs_1 w-full gap-docs_1",
showCollapsedNavbar && "border-b border-medusa-border-base"
)}
>
<div className="flex items-center gap-[10px]"> <div className="flex items-center gap-[10px]">
{isSidebarShown && ( {isSidebarShown && (
<Button <Button
@@ -53,32 +56,46 @@ export const MainNav = ({ className, itemsClassName }: MainNavProps) => {
<BorderedIcon <BorderedIcon
icon={config.logo} icon={config.logo}
iconWrapperClassName="my-[14px]" iconWrapperClassName="my-[14px]"
wrapperClassName="w-[20px] h-[20px]"
iconWidth={20} iconWidth={20}
iconHeight={20} iconHeight={20}
/> />
</Link> </Link>
</div> </div>
<MainNavItems className={itemsClassName} /> {!showCollapsedNavbar && (
</div> <MainNavItems className={clsx("flex-grow", itemsClassName)} />
<div className="flex items-center gap-docs_0.75 my-docs_0.75"> )}
<div className="lg:flex items-center gap-docs_0.5 text-medusa-fg-subtle hidden"> <div
<MainNavVersion /> className={clsx(
{editDate && <MainNavEditDate date={editDate} />} "flex items-center gap-docs_0.75 my-docs_0.75",
<LinkButton showCollapsedNavbar && "flex-grow justify-between"
href={config.reportIssueLink || ""} )}
variant="subtle" >
target="_blank" <div className="lg:flex items-center gap-docs_0.5 text-medusa-fg-subtle hidden">
className="text-compact-small-plus" <MainNavVersion />
> {editDate && <MainNavEditDate date={editDate} />}
Report Issue <LinkButton
</LinkButton> href={config.reportIssueLink || ""}
</div> variant="subtle"
<div className="flex items-center gap-docs_0.25"> target="_blank"
<SearchModalOpener /> className="text-compact-small-plus"
<MainNavDesktopMenu /> >
<MainNavMobileMenu /> Report Issue
</LinkButton>
</div>
<div className="flex items-center gap-docs_0.25">
<AiAssistantTriggerButton />
<SearchModalOpener />
<MainNavDesktopMenu />
<MainNavMobileMenu />
</div>
</div> </div>
</div> </div>
{showCollapsedNavbar && (
<div className={clsx("border-b border-medusa-border-base px-docs_1")}>
<MainNavItems className={clsx("flex-wrap", itemsClassName)} />
</div>
)}
</div> </div>
) )
} }
@@ -2,21 +2,29 @@ import React from "react"
import { import {
BrowserProvider, BrowserProvider,
ColorModeProvider, ColorModeProvider,
LayoutProvider,
LayoutProviderProps,
MobileProvider, MobileProvider,
ModalProvider, ModalProvider,
} from "../../providers" } from "../../providers"
type RootProvidersProps = { type RootProvidersProps = {
children: React.ReactNode children: React.ReactNode
layoutProviderProps?: Omit<LayoutProviderProps, "children">
} }
export const RootProviders = ({ children }: RootProvidersProps) => { export const RootProviders = ({
children,
layoutProviderProps = {},
}: RootProvidersProps) => {
return ( return (
<BrowserProvider> <BrowserProvider>
<MobileProvider> <MobileProvider>
<ColorModeProvider> <LayoutProvider {...layoutProviderProps}>
<ModalProvider>{children}</ModalProvider> <ColorModeProvider>
</ColorModeProvider> <ModalProvider>{children}</ModalProvider>
</ColorModeProvider>
</LayoutProvider>
</MobileProvider> </MobileProvider>
</BrowserProvider> </BrowserProvider>
) )
+2 -1
View File
@@ -1,4 +1,5 @@
export * from "./AiAssistant" export * from "./AiAssistant/SearchWindow"
export * from "./AiAssistant/ChatWindow"
export * from "./ApiRunner" export * from "./ApiRunner"
export * from "./Badge" export * from "./Badge"
export * from "./BetaBadge" export * from "./BetaBadge"
+1
View File
@@ -1,4 +1,5 @@
export * from "./use-active-on-scroll" export * from "./use-active-on-scroll"
export * from "./use-ai-assistant-chat-navigation"
export * from "./use-child-docs" export * from "./use-child-docs"
export * from "./use-click-outside" export * from "./use-click-outside"
export * from "./use-collapsible" export * from "./use-collapsible"
@@ -0,0 +1,141 @@
"use client"
import { useCallback, useEffect, useMemo } from "react"
import { useAiAssistant } from "@/providers"
import { findNextSibling, findPrevSibling } from "@/utils"
import {
useKeyboardShortcut,
type useKeyboardShortcutOptions,
} from "../use-keyboard-shortcut"
export type UseAiAssistantChatNavigationProps = {
getChatWindowElm: () => HTMLElement | null
getInputElm: () => HTMLTextAreaElement | null
focusInput: () => void
keyboardProps?: Partial<useKeyboardShortcutOptions>
}
export const useAiAssistantChatNavigation = ({
getInputElm,
focusInput,
keyboardProps,
getChatWindowElm,
}: UseAiAssistantChatNavigationProps) => {
const shortcutKeys = useMemo(() => ["ArrowUp", "ArrowDown", "Enter"], [])
const { chatOpened } = useAiAssistant()
const handleKeyAction = (e: KeyboardEvent) => {
const chatElm = getChatWindowElm()
if (
!chatOpened ||
e.metaKey ||
e.ctrlKey ||
!chatElm?.contains(document.activeElement)
) {
return
}
e.preventDefault()
const focusedItem = chatElm?.querySelector(":focus") as HTMLElement
if (!focusedItem) {
// focus the first data-hit
const nextItem = chatElm?.querySelector("[data-hit]") as HTMLElement
nextItem?.focus()
return
}
const isHit = focusedItem.hasAttribute("data-hit")
const isInput = focusedItem.tagName.toLowerCase() === "textarea"
if (!isHit && !isInput) {
// ignore if focused items aren't input/data-hit
return
}
const lowerPressedKey = e.key.toLowerCase()
if (lowerPressedKey === "enter") {
if (isHit) {
// trigger click event of the focused element
focusedItem.click()
}
return
}
if (lowerPressedKey === "arrowdown") {
// only hit items has action on arrow down
if (isHit) {
// find if there's a data-hit item before this one
const beforeItem = findNextSibling(focusedItem, "[data-hit]")
if (!beforeItem) {
// focus the input
focusInput()
} else {
// focus the previous item
beforeItem.focus()
}
}
} else if (lowerPressedKey === "arrowup") {
// check if item is input or hit
if (isInput) {
// go to the first data-hit item
const nextItem = chatElm?.querySelector(
"[data-hit]:last-child"
) as HTMLElement
nextItem?.focus()
} else {
// handle go down for hit items
// find if there's a data-hit item after this one
const afterItem = findPrevSibling(focusedItem, "[data-hit]")
if (afterItem) {
// focus the next item
afterItem.focus()
}
}
}
}
/** Handles starting to type which focuses the input */
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (!chatOpened) {
return
}
// check if shortcut keys were pressed
const lowerPressedKey = e.key.toLowerCase()
const pressedShortcut = [...shortcutKeys, "Escape"].some(
(s) => s.toLowerCase() === lowerPressedKey
)
if (pressedShortcut) {
return
}
const chatElm = getChatWindowElm()
if (!chatElm?.contains(document.activeElement)) {
return
}
const focusedItem = chatElm?.querySelector(":focus") as HTMLElement
const inputElm = getInputElm()
if (inputElm && focusedItem !== inputElm) {
focusInput()
}
},
[shortcutKeys, chatOpened, shortcutKeys, getInputElm, focusInput]
)
useEffect(() => {
window.addEventListener("keydown", handleKeyDown)
return () => {
window.removeEventListener("keydown", handleKeyDown)
}
}, [handleKeyDown])
useKeyboardShortcut({
metakey: false,
shortcutKeys: shortcutKeys,
checkEditing: false,
isLoading: false,
action: handleKeyAction,
...keyboardProps,
})
}
@@ -379,10 +379,10 @@ export const useChildDocs = ({
{!searchResult.length && ( {!searchResult.length && (
<div className="flex flex-col justify-center items-center gap-docs_0.75"> <div className="flex flex-col justify-center items-center gap-docs_0.75">
<ExclamationCircle className="text-medusa-fg-subtle" /> <ExclamationCircle className="text-medusa-fg-subtle" />
<span className="text-compact-small-plus text-medusa-fg-subtle text-center"> <span className="text-compact-small-plus text-medusa-fg-base text-center">
No results found matching your query. No results found matching your query.
</span> </span>
<span className="text-compact-small text-medusa-fg-muted text-center"> <span className="text-compact-small text-medusa-fg-subtle text-center">
Try searching with another term or clearing the search. Try searching with another term or clearing the search.
</span> </span>
</div> </div>
@@ -40,7 +40,7 @@ export const useKeyboardShortcut = ({
[shortcutKeys] [shortcutKeys]
) )
const sidebarShortcut = useCallback( const onKeyDown = useCallback(
(e: KeyboardEvent) => { (e: KeyboardEvent) => {
// the event is triggered when an input // the event is triggered when an input
// autocompletes, and in that case // autocompletes, and in that case
@@ -63,10 +63,10 @@ export const useKeyboardShortcut = ({
) )
useEffect(() => { useEffect(() => {
window.addEventListener("keydown", sidebarShortcut) window.addEventListener("keydown", onKeyDown)
return () => { return () => {
window.removeEventListener("keydown", sidebarShortcut) window.removeEventListener("keydown", onKeyDown)
} }
}, [sidebarShortcut]) }, [onKeyDown])
} }
@@ -3,7 +3,7 @@
import React, { useEffect } from "react" import React, { useEffect } from "react"
import { useSidebar } from "../providers/Sidebar" import { useSidebar } from "../providers/Sidebar"
import clsx from "clsx" import clsx from "clsx"
import { MainNav, useIsBrowser } from ".." import { MainNav, useIsBrowser, useLayout } from ".."
export type MainContentLayoutProps = { export type MainContentLayoutProps = {
mainWrapperClasses?: string mainWrapperClasses?: string
@@ -18,6 +18,7 @@ export const MainContentLayout = ({
}: MainContentLayoutProps) => { }: MainContentLayoutProps) => {
const { isBrowser } = useIsBrowser() const { isBrowser } = useIsBrowser()
const { desktopSidebarOpen } = useSidebar() const { desktopSidebarOpen } = useSidebar()
const { mainContentRef } = useLayout()
useEffect(() => { useEffect(() => {
if (!isBrowser) { if (!isBrowser) {
@@ -52,6 +53,7 @@ export const MainContentLayout = ({
mainWrapperClasses mainWrapperClasses
)} )}
id="main" id="main"
ref={mainContentRef}
> >
<MainNav /> <MainNav />
<div <div
+3 -1
View File
@@ -3,6 +3,7 @@ import clsx from "clsx"
import { RootProviders, Sidebar, SidebarProps } from "@/components" import { RootProviders, Sidebar, SidebarProps } from "@/components"
import { Toc } from "../components/Toc" import { Toc } from "../components/Toc"
import { MainContentLayout, MainContentLayoutProps } from "./main-content" import { MainContentLayout, MainContentLayoutProps } from "./main-content"
import { AiAssistantChatWindow } from "../components/AiAssistant/ChatWindow"
export type RootLayoutProps = { export type RootLayoutProps = {
bodyClassName?: string bodyClassName?: string
@@ -26,7 +27,7 @@ export const RootLayout = ({
<body <body
className={clsx( className={clsx(
"bg-medusa-bg-subtle font-base text-medium w-full", "bg-medusa-bg-subtle font-base text-medium w-full",
"text-medusa-fg-subtle", "text-medusa-fg-base",
"h-screen overflow-hidden", "h-screen overflow-hidden",
"grid grid-cols-1 lg:mx-auto lg:grid-cols-[221px_1fr]", "grid grid-cols-1 lg:mx-auto lg:grid-cols-[221px_1fr]",
bodyClassName bodyClassName
@@ -38,6 +39,7 @@ export const RootLayout = ({
<div className={clsx("relative", "h-screen", "flex")}> <div className={clsx("relative", "h-screen", "flex")}>
<MainContentLayout {...mainProps} /> <MainContentLayout {...mainProps} />
{showToc && <Toc />} {showToc && <Toc />}
<AiAssistantChatWindow />
</div> </div>
</ProvidersComponent> </ProvidersComponent>
</RootProviders> </RootProviders>
@@ -0,0 +1,345 @@
"use client"
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import { useAiAssistant } from ".."
import useResizeObserver from "@react-hook/resize-observer"
export type AiAssistantChatContextType = {
handleSubmit: (selectedQuestion?: string) => void
getThreadItems: () => AiAssistantThread[]
question: string
setQuestion: React.Dispatch<React.SetStateAction<string>>
inputRef: React.RefObject<HTMLInputElement | HTMLTextAreaElement | null>
contentRef: React.RefObject<HTMLDivElement | null>
loading: boolean
thread: AiAssistantThread[]
answer: string
}
const AiAssistantChatContext = createContext<AiAssistantChatContextType | null>(
null
)
export type AiAssistantChunk = {
stream_end: boolean
} & (
| {
type: "relevant_sources"
content: {
relevant_sources: AiAssistantRelevantSources[]
}
}
| {
type: "partial_answer"
content: AiAssistantPartialAnswer
}
| {
type: "identifiers"
content: AiAssistantIdentifier
}
| {
type: "error"
content: AiAssistantError
}
)
export type AiAssistantRelevantSources = {
title: string
source_url: string
}
export type AiAssistantPartialAnswer = {
text: string
}
export type AiAssistantIdentifier = {
thread_id: string
question_answer_id: string
}
export type AiAssistantError = {
reason: string
}
export type AiAssistantThread = {
type: "question" | "answer" | "error"
content: string
question_id?: string
sources?: AiAssistantRelevantSources[]
// for some reason, items in the array get reordered
// sometimes, so this is one way to avoid it
order: number
}
type AiAssistantChatProvider = {
children: React.ReactNode
}
export const AiAssistantChatProvider = ({
children,
}: AiAssistantChatProvider) => {
const [question, setQuestion] = useState("")
// this helps set the `order` field of the threadtype
const [messagesCount, setMessagesCount] = useState(0)
const [thread, setThread] = useState<AiAssistantThread[]>([])
const [answer, setAnswer] = useState("")
const [answerSources, setAnswerSources] = useState<
AiAssistantRelevantSources[]
>([])
const [identifiers, setIdentifiers] = useState<AiAssistantIdentifier | null>(
null
)
const [loading, setLoading] = useState(false)
const { getAnswer } = useAiAssistant()
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
const handleSubmit = (selectedQuestion?: string) => {
if (!selectedQuestion?.length && !question.length) {
return
}
setLoading(true)
setAnswer("")
setThread((prevThread) => [
...prevThread,
{
type: "question",
content: selectedQuestion || question,
order: getNewOrder(prevThread),
},
])
setMessagesCount((prev) => prev + 1)
}
const sortThread = (threadArr: AiAssistantThread[]) => {
const sortedThread = [...threadArr]
sortedThread.sort((itemA, itemB) => {
if (itemA.order < itemB.order) {
return -1
}
return itemA.order < itemB.order ? 1 : 0
})
return sortedThread
}
const getNewOrder = (prevThread: AiAssistantThread[]) => {
const sortedThread = sortThread(prevThread)
return sortedThread.length === 0
? messagesCount + 1
: sortedThread[prevThread.length - 1].order + 1
}
const setError = (logMessage?: string) => {
if (logMessage?.length) {
console.error(`[AI ERROR]: ${logMessage}`)
}
setThread((prevThread) => [
...prevThread,
{
type: "error",
content:
"I'm sorry, but I'm having trouble connecting to my knowledge base. Please try again. If the issue keeps persisting, please consider reporting an issue.",
order: getNewOrder(prevThread),
},
])
setMessagesCount((prev) => prev + 1)
setLoading(false)
setQuestion("")
setAnswer("")
inputRef.current?.focus()
}
const scrollToBottom = () => {
const parent = contentRef.current?.parentElement as HTMLElement
parent.scrollTop = parent.scrollHeight
}
const lastAnswerIndex = useMemo(() => {
const index = thread.reverse().findIndex((item) => item.type === "answer")
return index !== -1 ? index : 0
}, [thread])
const process_stream = useCallback(async (response: Response) => {
const reader = response.body?.getReader()
if (!reader) {
return
}
const decoder = new TextDecoder("utf-8")
const delimiter = "\u241E"
const delimiterBytes = new TextEncoder().encode(delimiter)
let buffer = new Uint8Array()
const findDelimiterIndex = (arr: Uint8Array) => {
for (let i = 0; i < arr.length - delimiterBytes.length + 1; i++) {
let found = true
for (let j = 0; j < delimiterBytes.length; j++) {
if (arr[i + j] !== delimiterBytes[j]) {
found = false
break
}
}
if (found) {
return i
}
}
return -1
}
let result
let loop = true
while (loop) {
result = await reader.read()
if (result.done) {
loop = false
continue
}
buffer = new Uint8Array([...buffer, ...result.value])
let delimiterIndex
while ((delimiterIndex = findDelimiterIndex(buffer)) !== -1) {
const chunkBytes = buffer.slice(0, delimiterIndex)
const chunkText = decoder.decode(chunkBytes)
buffer = buffer.slice(delimiterIndex + delimiterBytes.length)
const chunk = JSON.parse(chunkText).chunk as AiAssistantChunk
switch (chunk.type) {
case "partial_answer":
setAnswer((prevAnswer) => prevAnswer + chunk.content.text)
break
case "identifiers":
setIdentifiers(chunk.content)
break
case "error":
setError(chunk.content.reason)
loop = false
return
case "relevant_sources":
setAnswerSources((prev) => [
...prev,
...chunk.content.relevant_sources,
])
break
}
}
}
setLoading(false)
setQuestion("")
}, [])
const fetchAnswer = useCallback(async () => {
try {
const response = await getAnswer(question, identifiers?.thread_id)
if (response.status === 200) {
await process_stream(response)
} else {
const message = await response.text()
setError(message)
}
} catch (error: any) {
setError(JSON.stringify(error))
}
}, [question, identifiers, process_stream])
useEffect(() => {
if (loading && !answer) {
void fetchAnswer()
}
}, [loading, fetchAnswer])
useEffect(() => {
if (
!loading &&
answer.length &&
thread[lastAnswerIndex]?.content !== answer
) {
const uniqueAnswerSources = answerSources
.filter(
(source, index) =>
answerSources.findIndex(
(s) => s.source_url === source.source_url
) === index
)
.map((source) => {
const separatorIndex = source.title.indexOf("|")
return {
...source,
title:
separatorIndex !== -1
? source.title.slice(0, separatorIndex)
: source.title,
}
})
setThread((prevThread) => [
...prevThread,
{
type: "answer",
content: answer,
question_id: identifiers?.question_answer_id,
order: getNewOrder(prevThread),
sources:
uniqueAnswerSources.length > 3
? uniqueAnswerSources.slice(0, 3)
: uniqueAnswerSources,
},
])
setAnswer("")
setAnswerSources([])
setMessagesCount((prev) => prev + 1)
inputRef.current?.focus()
}
}, [loading, answer, thread, lastAnswerIndex, inputRef.current])
useResizeObserver(contentRef as React.RefObject<HTMLDivElement>, () => {
if (!loading) {
return
}
scrollToBottom()
})
const getThreadItems = useCallback(() => {
return sortThread(thread)
}, [thread])
return (
<AiAssistantChatContext.Provider
value={{
handleSubmit,
getThreadItems,
question,
setQuestion,
inputRef,
contentRef,
loading,
thread,
answer,
}}
>
{children}
</AiAssistantChatContext.Provider>
)
}
export const useAiAssistantChat = () => {
const context = useContext(AiAssistantChatContext)
if (!context) {
throw new Error(
"useAiAssistantChat must be used within a AiAssistantChatContext"
)
}
return context
}
@@ -1,12 +1,15 @@
"use client" "use client"
import React, { createContext, useContext } from "react" import React, { createContext, useContext, useEffect, useState } from "react"
import { useAnalytics } from "@/providers" import { useAnalytics, useSearch } from "@/providers"
import { AiAssistant } from "@/components" import { AiAssistantIcon, AiAssistantSearchWindow } from "@/components"
import { RecaptchaAction, useRecaptcha } from "../../hooks/use-recaptcha" import { RecaptchaAction, useRecaptcha } from "../../hooks/use-recaptcha"
import { AiAssistantChatProvider } from "./Chat"
export type AiAssistantFeedbackType = "upvote" | "downvote" export type AiAssistantFeedbackType = "upvote" | "downvote"
export type AiAssistantChatType = "default" | "popover"
export type AiAssistantContextType = { export type AiAssistantContextType = {
getAnswer: (question: string, thread_id?: string) => Promise<Response> getAnswer: (question: string, thread_id?: string) => Promise<Response>
sendFeedback: ( sendFeedback: (
@@ -14,6 +17,9 @@ export type AiAssistantContextType = {
reaction: AiAssistantFeedbackType reaction: AiAssistantFeedbackType
) => Promise<Response> ) => Promise<Response>
version: "v1" | "v2" version: "v1" | "v2"
chatOpened: boolean
setChatOpened: React.Dispatch<React.SetStateAction<boolean>>
chatType: AiAssistantChatType
} }
const AiAssistantContext = createContext<AiAssistantContextType | null>(null) const AiAssistantContext = createContext<AiAssistantContextType | null>(null)
@@ -24,6 +30,8 @@ export type AiAssistantProviderProps = {
recaptchaSiteKey: string recaptchaSiteKey: string
websiteId: string websiteId: string
version?: "v1" | "v2" version?: "v1" | "v2"
type?: "search" | "chat"
chatType?: AiAssistantChatType
} }
export const AiAssistantProvider = ({ export const AiAssistantProvider = ({
@@ -32,7 +40,11 @@ export const AiAssistantProvider = ({
websiteId, websiteId,
version = "v2", version = "v2",
children, children,
type = "chat",
chatType = "default",
}: AiAssistantProviderProps) => { }: AiAssistantProviderProps) => {
const [chatOpened, setChatOpened] = useState(false)
const { setCommands, setIsOpen, setCommand } = useSearch()
const { analytics } = useAnalytics() const { analytics } = useAnalytics()
const { execute: getReCaptchaToken } = useRecaptcha({ const { execute: getReCaptchaToken } = useRecaptcha({
siteKey: recaptchaSiteKey, siteKey: recaptchaSiteKey,
@@ -85,16 +97,47 @@ export const AiAssistantProvider = ({
) )
} }
useEffect(() => {
setCommands((prevCommands) => {
const newCommands = [...prevCommands]
if (!newCommands.find((c) => c.name === "ai-assistant")) {
newCommands.push({
name: "ai-assistant",
icon: <AiAssistantIcon />,
title: "AI Assistant",
badge: {
variant: "blue",
badgeType: "shaded",
children: "Beta",
},
action: () => {
setIsOpen(false)
setChatOpened(true)
setCommand(null)
},
})
}
return newCommands
})
}, [])
return ( return (
<AiAssistantContext.Provider <AiAssistantContext.Provider
value={{ value={{
getAnswer, getAnswer,
sendFeedback, sendFeedback,
version, version,
chatOpened,
setChatOpened,
chatType,
}} }}
> >
{children} <AiAssistantChatProvider>
<AiAssistant /> {children}
{type === "search" && <AiAssistantSearchWindow />}
</AiAssistantChatProvider>
</AiAssistantContext.Provider> </AiAssistantContext.Provider>
) )
} }
@@ -0,0 +1,57 @@
"use client"
import useResizeObserver from "@react-hook/resize-observer"
import React, { createContext, createRef, useContext, useState } from "react"
export type LayoutProviderContextType = {
mainContentRef: React.RefObject<HTMLDivElement | null>
showCollapsedNavbar: boolean
}
export const LayoutProviderContext =
createContext<LayoutProviderContextType | null>(null)
export type LayoutProviderProps = {
children: React.ReactNode
disableResizeObserver?: boolean
}
export const LayoutProvider = ({
children,
disableResizeObserver = false,
}: LayoutProviderProps) => {
const mainContentRef = createRef<HTMLDivElement>()
const [showCollapsedNavbar, setShowCollapsedNavbar] = useState(false)
useResizeObserver(mainContentRef as React.RefObject<HTMLDivElement>, () => {
if (disableResizeObserver) {
setShowCollapsedNavbar(false)
return
}
if (window.innerWidth < 992) {
setShowCollapsedNavbar(false)
return
}
if (mainContentRef.current) {
setShowCollapsedNavbar(mainContentRef.current.clientWidth < 992)
}
})
return (
<LayoutProviderContext.Provider
value={{ mainContentRef, showCollapsedNavbar }}
>
{children}
</LayoutProviderContext.Provider>
)
}
export const useLayout = (): LayoutProviderContextType => {
const context = useContext(LayoutProviderContext)
if (!context) {
throw new Error("useLayout must be used inside a LayoutProvider")
}
return context
}
@@ -23,7 +23,8 @@ import { CSSTransition, SwitchTransition } from "react-transition-group"
export type SearchCommand = { export type SearchCommand = {
name: string name: string
component: React.ReactNode component?: React.ReactNode
action?: () => void
icon?: React.ReactNode icon?: React.ReactNode
title: string title: string
badge?: BadgeProps badge?: BadgeProps
@@ -38,6 +39,7 @@ export type SearchContextType = {
commands: SearchCommand[] commands: SearchCommand[]
command: SearchCommand | null command: SearchCommand | null
setCommand: React.Dispatch<React.SetStateAction<SearchCommand | null>> setCommand: React.Dispatch<React.SetStateAction<SearchCommand | null>>
setCommands: React.Dispatch<React.SetStateAction<SearchCommand[]>>
modalRef: React.MutableRefObject<HTMLDialogElement | null> modalRef: React.MutableRefObject<HTMLDialogElement | null>
} }
@@ -64,13 +66,14 @@ export const SearchProvider = ({
initialDefaultFilters = [], initialDefaultFilters = [],
searchProps, searchProps,
algolia, algolia,
commands = [], commands: initialCommands = [],
modalClassName, modalClassName,
}: SearchProviderProps) => { }: SearchProviderProps) => {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const [defaultFilters, setDefaultFilters] = useState<string[]>( const [defaultFilters, setDefaultFilters] = useState<string[]>(
initialDefaultFilters initialDefaultFilters
) )
const [commands, setCommands] = useState<SearchCommand[]>(initialCommands)
const [command, setCommand] = useState<SearchCommand | null>(null) const [command, setCommand] = useState<SearchCommand | null>(null)
const modalRef = useRef<HTMLDialogElement | null>(null) const modalRef = useRef<HTMLDialogElement | null>(null)
@@ -211,6 +214,10 @@ export const SearchProvider = ({
const componentWrapperRef = useRef(null) const componentWrapperRef = useRef(null)
useEffect(() => {
command?.action?.()
}, [command])
return ( return (
<SearchContext.Provider <SearchContext.Provider
value={{ value={{
@@ -223,6 +230,7 @@ export const SearchProvider = ({
command, command,
setCommand, setCommand,
modalRef, modalRef,
setCommands,
}} }}
> >
{children} {children}
@@ -241,20 +249,20 @@ export const SearchProvider = ({
<CSSTransition <CSSTransition
classNames={{ classNames={{
enter: enter:
command === null command === null || !command.component
? "animate-fadeInLeft animate-fast" ? "animate-fadeInLeft animate-fast"
: "animate-fadeInRight animate-fast", : "animate-fadeInRight animate-fast",
exit: exit:
command === null command === null || !command.component
? "animate-fadeOutLeft animate-fast" ? "animate-fadeOutLeft animate-fast"
: "animate-fadeOutRight animate-fast", : "animate-fadeOutRight animate-fast",
}} }}
timeout={250} timeout={250}
key={command?.name || "search"} key={command?.component ? command.name : "search"}
nodeRef={componentWrapperRef} nodeRef={componentWrapperRef}
> >
<div ref={componentWrapperRef} className="h-full"> <div ref={componentWrapperRef} className="h-full">
{command === null && ( {!command?.component && (
<Search {...searchProps} algolia={algolia} /> <Search {...searchProps} algolia={algolia} />
)} )}
{command?.component} {command?.component}
@@ -2,6 +2,7 @@ export * from "./AiAssistant"
export * from "./Analytics" export * from "./Analytics"
export * from "./BrowserProvider" export * from "./BrowserProvider"
export * from "./ColorMode" export * from "./ColorMode"
export * from "./Layout"
export * from "./LearningPath" export * from "./LearningPath"
export * from "./MainNav" export * from "./MainNav"
export * from "./Mobile" export * from "./Mobile"
@@ -275,6 +275,8 @@ module.exports = {
"subtle-code-fade-right-to-left-dark": `linear-gradient(90deg, #30303380, #303033)`, "subtle-code-fade-right-to-left-dark": `linear-gradient(90deg, #30303380, #303033)`,
"border-dotted": "border-dotted":
"linear-gradient(90deg,var(--docs-border-strong) 1px,transparent 1px)", "linear-gradient(90deg,var(--docs-border-strong) 1px,transparent 1px)",
"ai-assistant-bottom":
"linear-gradient(180deg, rgba(255, 255, 255, 0.00) 0%, var(--docs-bg-base) 100%)",
}, },
screens: { screens: {
xs: "568px", xs: "568px",
@@ -283,7 +285,8 @@ module.exports = {
lg: "1024px", lg: "1024px",
xl: "1280px", xl: "1280px",
xxl: "1536px", xxl: "1536px",
xxxl: "3840px", xxxl: "1800px",
xxxxl: "3840px",
}, },
transitionTimingFunction: { transitionTimingFunction: {
ease: "ease", ease: "ease",
@@ -291,6 +294,7 @@ module.exports = {
width: { width: {
toc: "221px", toc: "221px",
"sidebar-xs": "calc(100% - 20px)", "sidebar-xs": "calc(100% - 20px)",
"ai-assistant": "500px"
}, },
maxWidth: { maxWidth: {
// sidebar // sidebar
@@ -326,6 +330,8 @@ module.exports = {
"modal-sm": "624px", "modal-sm": "624px",
"modal-md": "752px", "modal-md": "752px",
"modal-lg": "640px", "modal-lg": "640px",
// ai-assistant
"ai-assistant": "500px"
}, },
minWidth: { minWidth: {
xl: "1419px", xl: "1419px",
+2 -2
View File
@@ -22,8 +22,8 @@ const light = {
"--docs-fg-base": "rgba(24, 24, 27, 1)", "--docs-fg-base": "rgba(24, 24, 27, 1)",
"--docs-fg-subtle": "rgba(82, 82, 91, 1)", "--docs-fg-subtle": "rgba(82, 82, 91, 1)",
"--docs-fg-muted": "rgba(161, 161, 170, 1)", "--docs-fg-muted": "rgba(113, 113, 122, 1)",
"--docs-fg-disabled": "rgba(212, 212, 216, 1)", "--docs-fg-disabled": "rgba(161, 161, 170, 1)",
"--docs-fg-on-color": "rgba(255, 255, 255, 1)", "--docs-fg-on-color": "rgba(255, 255, 255, 1)",
"--docs-fg-on-inverted": "rgba(255, 255, 255, 1)", "--docs-fg-on-inverted": "rgba(255, 255, 255, 1)",
"--docs-fg-interactive": "rgba(59, 130, 246, 1)", "--docs-fg-interactive": "rgba(59, 130, 246, 1)",