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
2025-01-29 19:13:51 +02:00
committed by GitHub
parent 51d2960a57
commit 5634a4762b
57 changed files with 1571 additions and 743 deletions
@@ -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>
</>
)
}