DocsAiPage.tsx331 lines · main
1'use client'
2
3import { useBreakpoint } from 'common'
4import { User } from 'lucide-react'
5import { Fragment, useCallback, useEffect, useRef, useState } from 'react'
6import ReactMarkdown from 'react-markdown'
7import remarkGfm from 'remark-gfm'
8import { AiIconAnimation, Button, cn, CommandGroup, CommandItem, CommandList } from 'ui'
9import { markdownComponents } from 'ui-patterns/Markdown'
10import { StatusIcon } from 'ui/src/components/StatusIcon'
11
12import {
13 Breadcrumb,
14 CommandHeader,
15 CommandMenuInput,
16 CommandWrapper,
17 generateCommandClassNames,
18 useHistoryKeys,
19 useQuery,
20 useSetCommandMenuSize,
21 useSetQuery,
22} from '../..'
23import { AiWarning, Message, MessageRole, MessageStatus, useAiChat } from '../ai'
24
25const questions = [
26 'How do I get started with Briven?',
27 'How do I run Briven locally?',
28 'How do I connect to my database?',
29 'How do I run migrations? ',
30 'How do I listen to changes in a table?',
31 'How do I set up authentication?',
32]
33
34const DocsAiPage = () => {
35 const setQuery = useSetQuery()
36
37 const isBelowSm = useBreakpoint('sm')
38
39 /**
40 * Interface for AI interaction is larger to allow more reading space.
41 */
42 useSetCommandMenuSize('xlarge')
43
44 const [isLoading, setIsLoading] = useState(false)
45
46 const { submit, reset, messages, isResponding, hasError } = useAiChat({
47 setIsLoading,
48 })
49
50 const handleSubmit = useCallback(
51 (message: string) => {
52 setQuery('')
53 submit(message)
54 },
55 [submit]
56 )
57
58 const handleReset = useCallback(() => {
59 setQuery('')
60 reset()
61 }, [reset])
62
63 return (
64 <CommandWrapper
65 className={cn(
66 'flex flex-col',
67 !isBelowSm &&
68 '**:[[cmdk-input-wrapper]]:border-b-0 **:[[cmdk-input-wrapper]]:border-t **:[[cmdk-input-wrapper]]:border-solid [&_[cmdk-input-wrapper]]:border-bg-control'
69 )}
70 >
71 <CommandHeader>
72 <Breadcrumb />
73 {isBelowSm && (
74 <PromptInput
75 submit={handleSubmit}
76 reset={handleReset}
77 messages={messages}
78 isLoading={isLoading}
79 isResponding={isResponding}
80 />
81 )}
82 </CommandHeader>
83 <div className={cn('grow min-h-0 overflow-auto')}>
84 {!hasError && messages.length > 0 && <AiMessages messages={messages} />}
85 {!hasError && messages.length === 0 && <EmptyState handleSubmit={handleSubmit} />}
86 {hasError && <ErrorState handleReset={handleReset} />}
87 </div>
88 {!isBelowSm && (
89 <PromptInput
90 submit={handleSubmit}
91 reset={handleReset}
92 messages={messages}
93 isLoading={isLoading}
94 isResponding={isResponding}
95 />
96 )}
97 {messages.length > 0 && !hasError && (
98 <AiWarning className={isBelowSm ? 'rounded-b-none' : 'rounded-t-none'} />
99 )}
100 </CommandWrapper>
101 )
102}
103
104function PromptInput({
105 submit,
106 reset,
107 messages,
108 isLoading,
109 isResponding,
110 className,
111}: {
112 submit: (query: string) => void
113 reset: () => void
114 messages: Array<Message>
115 isLoading: boolean
116 isResponding: boolean
117 className?: string
118}) {
119 const query = useQuery()
120 const previousQuery = useRef(query)
121 const setQuery = useSetQuery()
122 // If the user has already typed something when they select Briven AI, we want to
123 // submit it immediately.
124 useEffect(() => {
125 if (query) {
126 submit(query)
127 }
128 return reset
129 }, [])
130
131 const [inputValue, setInputValue] = useState('')
132 // Support CJK IME input
133 const [isImeComposing, setIsImeComposing] = useState(false)
134 useEffect(() => {
135 if (query !== previousQuery.current) {
136 setInputValue(query)
137 previousQuery.current = query
138 } else if (!isImeComposing) {
139 setQuery(inputValue)
140 previousQuery.current = inputValue
141 }
142 }, [inputValue, query, isImeComposing])
143
144 useHistoryKeys({
145 enable: !isResponding,
146 stack: messages.filter(({ role }) => role === MessageRole.User).map(({ content }) => content),
147 })
148
149 return (
150 <CommandMenuInput
151 className={cn(
152 'w-full h-11',
153 'border-none outline-hidden bg-transparent rounded-none rounded-t-md px-4 py-7',
154 'flex',
155 'text-base text-foreground-light',
156 'focus:ring-0 focus:shadow-none focus:ring-transparent',
157 'focus-visible:ring-0 focus-visible:shadow-none focus-visible:ring-transparent',
158 'placeholder:text-foreground-muted',
159 'disabled:cursor-not-allowed disabled:opacity-50',
160 className
161 )}
162 placeholder={
163 isLoading || isResponding ? 'Waiting on an answer...' : 'Ask Briven AI a question...'
164 }
165 value={inputValue}
166 onValueChange={setInputValue}
167 onCompositionStart={() => setIsImeComposing(true)}
168 onCompositionEnd={() => setIsImeComposing(false)}
169 onKeyDown={(e) => {
170 switch (e.key) {
171 case 'Enter':
172 if (!query || isLoading || isResponding || isImeComposing) {
173 return
174 }
175 return submit(query)
176 default:
177 return
178 }
179 }}
180 />
181 )
182}
183
184function AiMessages({ messages }: { messages: Array<Message> }) {
185 return (
186 <>
187 {/* cmdk menus need a CommandList descendent in order to not throw an
188 error. This display doesn't actually need the Command Menu, but it's
189 needed for the empty state to work with the input, so this is a somewhat
190 hacktastic way of making it not error. */}
191 <CommandList />
192 <div
193 className={cn(
194 'grow min-h-0 overflow-auto p-4',
195 'grid gap-6 md:grid-cols-[max-content_1fr] grid-rows-[max-content]'
196 )}
197 >
198 {messages.map((message, index) => {
199 switch (message.role) {
200 case MessageRole.User:
201 return (
202 <Fragment key={index}>
203 <div className="flex items-center gap-4">
204 <div
205 className={cn(
206 'w-7 h-7',
207 'border border-muted bg-background shadow-xs rounded-full',
208 'flex items-center justify-center',
209 'text-foreground-lighter'
210 )}
211 >
212 <User strokeWidth={1.5} size={16} />
213 </div>
214 <span className="font-mono text-sm text-foreground-muted uppercase tracking-widest md:hidden">
215 You
216 </span>
217 </div>
218 <div className="prose text-foreground-lighter">{message.content}</div>
219 </Fragment>
220 )
221 case MessageRole.Assistant:
222 return (
223 <Fragment key={index}>
224 <div className="flex items-center md:items-start gap-4">
225 <AiIconAnimation
226 className="ml-0.5"
227 loading={
228 message.status === MessageStatus.Pending ||
229 message.status === MessageStatus.InProgress
230 }
231 allowHoverEffect
232 />
233 <span className="font-mono text-sm text-foreground-muted uppercase tracking-widest md:hidden">
234 Briven AI
235 </span>
236 </div>
237 <div>
238 {message.status === MessageStatus.Pending && (
239 <span className="inline-block h-lh w-[0.8lh] mt-1 bg-border-strong animate-bounce" />
240 )}
241 <div className="prose dark:prose-dark wrap-break-word">
242 <ReactMarkdown
243 remarkPlugins={[remarkGfm]}
244 components={{
245 ...markdownComponents,
246 a: (props) => <a {...props} target="_blank" rel="noopener noreferrer" />,
247 }}
248 >
249 {message.content}
250 </ReactMarkdown>
251 </div>
252 {message.sources && message.sources.length > 0 && (
253 <div className="mt-4 pt-4 border-t border-border-muted">
254 <p className="text-sm text-foreground-muted mb-2">Sources:</p>
255 <ul className="space-y-1">
256 {message.sources.map((source, idx) => (
257 <li key={idx}>
258 <a
259 href={source.url}
260 target="_blank"
261 rel="noopener noreferrer"
262 className="text-sm text-brand hover:underline"
263 >
264 {source.url}
265 </a>
266 </li>
267 ))}
268 </ul>
269 </div>
270 )}
271 </div>
272 </Fragment>
273 )
274 }
275 })}
276 </div>
277 </>
278 )
279}
280
281function EmptyState({ handleSubmit }: { handleSubmit: (message: string) => void }) {
282 const query = useQuery()
283
284 return (
285 <CommandList className="max-h-[unset]">
286 <CommandGroup
287 heading="Examples"
288 className={cn(
289 'text-border-strong',
290 '**:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:pb-1.5',
291 '**:[[cmdk-group-heading]]:text-sm **:[[cmdk-group-heading]]:font-normal [&_[cmdk-group-heading]]:text-foreground-muted'
292 )}
293 >
294 {questions.map((question) => {
295 const key = question.replace(/\s+/g, '_')
296 return (
297 <CommandItem
298 className={generateCommandClassNames(false)}
299 onSelect={() => {
300 if (!query) {
301 handleSubmit(question)
302 }
303 }}
304 key={key}
305 >
306 <AiIconAnimation />
307 {question}
308 </CommandItem>
309 )
310 })}
311 </CommandGroup>
312 </CommandList>
313 )
314}
315
316function ErrorState({ handleReset }: { handleReset: () => void }) {
317 return (
318 <div className="p-6 flex flex-col items-center gap-2 mt-4">
319 <StatusIcon variant="warning" />
320 <p className="text-sm text-foreground text-center">
321 Sorry, looks like Briven AI is having a hard time!
322 </p>
323 <p className="text-sm text-foreground-lighter text-center">Please try again in a bit.</p>
324 <Button size="tiny" type="default" onClick={handleReset}>
325 Try again?
326 </Button>
327 </div>
328 )
329}
330
331export { DocsAiPage }