mirror of
https://github.com/supabase/supabase.git
synced 2026-10-17 15:19:08 +03:00
320 lines
9.5 KiB
TypeScript
320 lines
9.5 KiB
TypeScript
'use client'
|
|
|
|
import { User } from 'lucide-react'
|
|
import { Fragment, useCallback, useEffect, useRef, useState } from 'react'
|
|
import ReactMarkdown from 'react-markdown'
|
|
import remarkGfm from 'remark-gfm'
|
|
|
|
import { useBreakpoint } from 'common'
|
|
import {
|
|
AiIconAnimation,
|
|
Button,
|
|
CommandGroup_Shadcn_,
|
|
CommandItem_Shadcn_,
|
|
CommandList_Shadcn_,
|
|
cn,
|
|
markdownComponents,
|
|
} from 'ui'
|
|
import { StatusIcon } from 'ui/src/components/StatusIcon'
|
|
|
|
import {
|
|
Breadcrumb,
|
|
CommandHeader,
|
|
CommandInput,
|
|
CommandWrapper,
|
|
generateCommandClassNames,
|
|
useHistoryKeys,
|
|
useQuery,
|
|
useSetCommandMenuSize,
|
|
useSetQuery,
|
|
} from '../..'
|
|
import { AiWarning, Message, MessageRole, MessageStatus, useAiChat } from '../ai'
|
|
|
|
const questions = [
|
|
'How do I get started with Supabase?',
|
|
'How do I run Supabase locally?',
|
|
'How do I connect to my database?',
|
|
'How do I run migrations? ',
|
|
'How do I listen to changes in a table?',
|
|
'How do I set up authentication?',
|
|
]
|
|
|
|
const DocsAiPage = () => {
|
|
const setQuery = useSetQuery()
|
|
|
|
const isBelowSm = useBreakpoint('sm')
|
|
|
|
/**
|
|
* Interface for AI interaction is larger to allow more reading space.
|
|
*/
|
|
useSetCommandMenuSize('xlarge')
|
|
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
|
|
const { submit, reset, messages, isResponding, hasError } = useAiChat({
|
|
setIsLoading,
|
|
})
|
|
|
|
const handleSubmit = useCallback(
|
|
(message: string) => {
|
|
setQuery('')
|
|
submit(message)
|
|
},
|
|
[submit]
|
|
)
|
|
|
|
const handleReset = useCallback(() => {
|
|
setQuery('')
|
|
reset()
|
|
}, [reset])
|
|
|
|
return (
|
|
<CommandWrapper
|
|
className={cn(
|
|
'flex flex-col',
|
|
!isBelowSm &&
|
|
'[&_[cmdk-input-wrapper]]:border-b-0 [&_[cmdk-input-wrapper]]:border-t [&_[cmdk-input-wrapper]]:border-solid [&_[cmdk-input-wrapper]]:border-bg-control'
|
|
)}
|
|
>
|
|
<CommandHeader>
|
|
<Breadcrumb />
|
|
{isBelowSm && (
|
|
<PromptInput
|
|
submit={handleSubmit}
|
|
reset={handleReset}
|
|
messages={messages}
|
|
isLoading={isLoading}
|
|
isResponding={isResponding}
|
|
/>
|
|
)}
|
|
</CommandHeader>
|
|
<div className={cn('flex-grow min-h-0 overflow-auto')}>
|
|
{!hasError && messages.length > 0 && <AiMessages messages={messages} />}
|
|
{!hasError && messages.length === 0 && <EmptyState handleSubmit={handleSubmit} />}
|
|
{hasError && <ErrorState handleReset={handleReset} />}
|
|
</div>
|
|
{!isBelowSm && (
|
|
<PromptInput
|
|
submit={handleSubmit}
|
|
reset={handleReset}
|
|
messages={messages}
|
|
isLoading={isLoading}
|
|
isResponding={isResponding}
|
|
/>
|
|
)}
|
|
{messages.length > 0 && !hasError && (
|
|
<AiWarning className={isBelowSm ? 'rounded-b-none' : 'rounded-t-none'} />
|
|
)}
|
|
</CommandWrapper>
|
|
)
|
|
}
|
|
|
|
function PromptInput({
|
|
submit,
|
|
reset,
|
|
messages,
|
|
isLoading,
|
|
isResponding,
|
|
className,
|
|
}: {
|
|
submit: (query: string) => void
|
|
reset: () => void
|
|
messages: Array<Message>
|
|
isLoading: boolean
|
|
isResponding: boolean
|
|
className?: string
|
|
}) {
|
|
const query = useQuery()
|
|
const previousQuery = useRef(query)
|
|
const setQuery = useSetQuery()
|
|
// If the user has already typed something when they select Supabase AI, we want to
|
|
// submit it immediately.
|
|
useEffect(() => {
|
|
if (query) {
|
|
submit(query)
|
|
}
|
|
return reset
|
|
}, [])
|
|
|
|
const [inputValue, setInputValue] = useState('')
|
|
// Support CJK IME input
|
|
const [isImeComposing, setIsImeComposing] = useState(false)
|
|
useEffect(() => {
|
|
if (query !== previousQuery.current) {
|
|
setInputValue(query)
|
|
previousQuery.current = query
|
|
} else if (!isImeComposing) {
|
|
setQuery(inputValue)
|
|
previousQuery.current = inputValue
|
|
}
|
|
}, [inputValue, query, isImeComposing])
|
|
|
|
useHistoryKeys({
|
|
enable: !isResponding,
|
|
stack: messages.filter(({ role }) => role === MessageRole.User).map(({ content }) => content),
|
|
})
|
|
|
|
return (
|
|
<CommandInput
|
|
className={cn(
|
|
'w-full h-11',
|
|
'border-none outline-none bg-transparent rounded-none rounded-t-md px-4 py-7',
|
|
'flex',
|
|
'text-base text-foreground-light',
|
|
'focus:ring-0 focus:shadow-none focus:ring-transparent',
|
|
'focus-visible:ring-0 focus-visible:shadow-none focus-visible:ring-transparent',
|
|
'placeholder:text-foreground-muted',
|
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
className
|
|
)}
|
|
placeholder={
|
|
isLoading || isResponding ? 'Waiting on an answer...' : 'Ask Supabase AI a question...'
|
|
}
|
|
value={inputValue}
|
|
onValueChange={setInputValue}
|
|
onCompositionStart={() => setIsImeComposing(true)}
|
|
onCompositionEnd={() => setIsImeComposing(false)}
|
|
onKeyDown={(e) => {
|
|
switch (e.key) {
|
|
case 'Enter':
|
|
if (!query || isLoading || isResponding || isImeComposing) {
|
|
return
|
|
}
|
|
return submit(query)
|
|
default:
|
|
return
|
|
}
|
|
}}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function AiMessages({ messages }: { messages: Array<Message> }) {
|
|
return (
|
|
<>
|
|
{/* cmdk menus need a CommandList descendent in order to not throw an
|
|
error. This display doesn't actually need the Command Menu, but it's
|
|
needed for the empty state to work with the input, so this is a somewhat
|
|
hacktastic way of making it not error. */}
|
|
<CommandList_Shadcn_ />
|
|
<div
|
|
className={cn(
|
|
'flex-grow min-h-0 overflow-auto p-4',
|
|
'grid gap-6 md:grid-cols-[max-content,1fr] grid-rows-[max-content]'
|
|
)}
|
|
>
|
|
{messages.map((message, index) => {
|
|
switch (message.role) {
|
|
case MessageRole.User:
|
|
return (
|
|
<Fragment key={index}>
|
|
<div className="flex items-center gap-4">
|
|
<div
|
|
className={cn(
|
|
'w-7 h-7',
|
|
'border border-muted bg-background shadow-sm rounded-full',
|
|
'flex items-center justify-center',
|
|
'text-foreground-lighter'
|
|
)}
|
|
>
|
|
<User strokeWidth={1.5} size={16} />
|
|
</div>
|
|
<span className="font-mono text-sm text-foreground-muted uppercase tracking-widest md:hidden">
|
|
You
|
|
</span>
|
|
</div>
|
|
<div className="prose text-foreground-lighter">{message.content}</div>
|
|
</Fragment>
|
|
)
|
|
case MessageRole.Assistant:
|
|
return (
|
|
<Fragment key={index}>
|
|
<div className="flex items-center md:items-start gap-4">
|
|
<AiIconAnimation
|
|
className="ml-0.5"
|
|
loading={
|
|
message.status === MessageStatus.Pending ||
|
|
message.status === MessageStatus.InProgress
|
|
}
|
|
allowHoverEffect
|
|
/>
|
|
<span className="font-mono text-sm text-foreground-muted uppercase tracking-widest md:hidden">
|
|
Supabase AI
|
|
</span>
|
|
</div>
|
|
<div>
|
|
{message.status === MessageStatus.Pending && (
|
|
<span className="inline-block h-[1lh] w-[0.8lh] mt-1 bg-border-strong animate-bounce" />
|
|
)}
|
|
<ReactMarkdown
|
|
remarkPlugins={[remarkGfm]}
|
|
components={{
|
|
...markdownComponents,
|
|
a: (props) => <a {...props} target="_blank" rel="noopener noreferrer" />,
|
|
}}
|
|
className="prose dark:prose-dark break-words"
|
|
>
|
|
{message.content}
|
|
</ReactMarkdown>
|
|
</div>
|
|
</Fragment>
|
|
)
|
|
}
|
|
})}
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function EmptyState({ handleSubmit }: { handleSubmit: (message: string) => void }) {
|
|
const query = useQuery()
|
|
|
|
return (
|
|
<CommandList_Shadcn_ className="max-h-[unset]">
|
|
<CommandGroup_Shadcn_
|
|
heading="Examples"
|
|
className={cn(
|
|
'text-border-strong',
|
|
'[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:pb-1.5',
|
|
'[&_[cmdk-group-heading]]:text-sm [&_[cmdk-group-heading]]:font-normal [&_[cmdk-group-heading]]:text-foreground-muted'
|
|
)}
|
|
>
|
|
{questions.map((question) => {
|
|
const key = question.replace(/\s+/g, '_')
|
|
return (
|
|
<CommandItem_Shadcn_
|
|
className={generateCommandClassNames(false)}
|
|
onSelect={() => {
|
|
if (!query) {
|
|
handleSubmit(question)
|
|
}
|
|
}}
|
|
key={key}
|
|
>
|
|
<AiIconAnimation />
|
|
{question}
|
|
</CommandItem_Shadcn_>
|
|
)
|
|
})}
|
|
</CommandGroup_Shadcn_>
|
|
</CommandList_Shadcn_>
|
|
)
|
|
}
|
|
|
|
function ErrorState({ handleReset }: { handleReset: () => void }) {
|
|
return (
|
|
<div className="p-6 flex flex-col items-center gap-2 mt-4">
|
|
<StatusIcon variant="warning" />
|
|
<p className="text-sm text-foreground text-center">
|
|
Sorry, looks like Supabase AI is having a hard time!
|
|
</p>
|
|
<p className="text-sm text-foreground-lighter text-center">Please try again in a bit.</p>
|
|
<Button size="tiny" type="default" onClick={handleReset}>
|
|
Try again?
|
|
</Button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export { DocsAiPage }
|