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:
@@ -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 { ThreadType } from "../.."
|
||||
import clsx from "clsx"
|
||||
import { Button, type ButtonProps } from "@/components"
|
||||
import { Check, SquareTwoStackMini, ThumbDown, ThumbUp } from "@medusajs/icons"
|
||||
import { useCopy } from "@/hooks"
|
||||
import { Badge, Button, Link, type ButtonProps } from "@/components"
|
||||
import { ThumbDown, ThumbUp } from "@medusajs/icons"
|
||||
import { AiAssistantFeedbackType, useAiAssistant } from "@/providers"
|
||||
import { AiAssistantThread } from "../../../../providers/AiAssistant/Chat"
|
||||
|
||||
export type AiAssistantThreadItemActionsProps = {
|
||||
item: ThreadType
|
||||
item: AiAssistantThread
|
||||
}
|
||||
|
||||
export const AiAssistantThreadItemActions = ({
|
||||
item,
|
||||
}: AiAssistantThreadItemActionsProps) => {
|
||||
const { isCopied, handleCopy } = useCopy(item.content)
|
||||
const [feedback, setFeedback] = useState<AiAssistantFeedbackType | null>(null)
|
||||
const { sendFeedback } = useAiAssistant()
|
||||
|
||||
@@ -37,28 +35,36 @@ export const AiAssistantThreadItemActions = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("hidden md:flex gap-docs_0.25", "text-medusa-fg-muted")}
|
||||
>
|
||||
<ActionButton onClick={handleCopy}>
|
||||
{isCopied ? <Check /> : <SquareTwoStackMini />}
|
||||
</ActionButton>
|
||||
{(feedback === null || feedback === "upvote") && (
|
||||
<ActionButton
|
||||
onClick={async () => handleFeedback("upvote", item.question_id)}
|
||||
className={clsx(feedback === "upvote" && "!text-medusa-fg-subtle")}
|
||||
>
|
||||
<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={clsx("flex gap-docs_0.75 justify-between items-center")}>
|
||||
{item.sources !== undefined && item.sources.length > 0 && (
|
||||
<div className="flex gap-[6px] items-center flex-wrap">
|
||||
{item.sources.map((source) => (
|
||||
<Badge key={source.source_url} variant="neutral">
|
||||
<Link href={source.source_url} className="!text-inherit">
|
||||
{source.title}
|
||||
</Link>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -68,7 +74,7 @@ const ActionButton = ({ children, className, ...props }: ButtonProps) => {
|
||||
<Button
|
||||
variant="transparent"
|
||||
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",
|
||||
"!p-[4.5px] rounded-docs_sm",
|
||||
className
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import clsx from "clsx"
|
||||
import React from "react"
|
||||
import { ThreadType } from ".."
|
||||
import { AiAssistantIcon, DotsLoading, MarkdownContent } from "@/components"
|
||||
import { AiAssistantThreadItemActions } from "./Actions"
|
||||
import { AiAssistantThread } from "../../../providers/AiAssistant/Chat"
|
||||
|
||||
export type AiAssistantThreadItemProps = {
|
||||
item: ThreadType
|
||||
item: AiAssistantThread
|
||||
}
|
||||
|
||||
export const AiAssistantThreadItem = ({ item }: AiAssistantThreadItemProps) => {
|
||||
@@ -24,16 +24,24 @@ export const AiAssistantThreadItem = ({ item }: AiAssistantThreadItemProps) => {
|
||||
)}
|
||||
<div
|
||||
className={clsx(
|
||||
"txt-small text-medusa-fg-subtle",
|
||||
"txt-small text-medusa-fg-base",
|
||||
item.type === "question" && [
|
||||
"rounded-docs_xl bg-medusa-tag-neutral-bg",
|
||||
"px-docs_0.75 py-docs_0.5",
|
||||
],
|
||||
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" && (
|
||||
<div className="flex flex-col gap-docs_0.75">
|
||||
{!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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user