205 lines
7.1 KiB
TypeScript
205 lines
7.1 KiB
TypeScript
import { UseChatHelpers } from 'ai/react'
|
|
import * as React from 'react'
|
|
import { Button, buttonVariants } from '@/components/ui/button'
|
|
import { IconArrowElbow, IconEdit } from '@/components/ui/icons'
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger
|
|
} from '@/components/ui/tooltip'
|
|
import {
|
|
Combobox,
|
|
ComboboxAnchor,
|
|
ComboboxContent,
|
|
ComboboxOption,
|
|
ComboboxTextarea
|
|
} from '@/components/ui/combobox'
|
|
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
|
import { useEnterSubmit } from '@/lib/hooks/use-enter-submit'
|
|
import { cn, getSearchCompletionQueryName } from '@/lib/utils'
|
|
import useSWR from 'swr'
|
|
import fetcher from '@/lib/tabby-fetcher'
|
|
import { debounce, has } from 'lodash'
|
|
import { ISearchHit, SearchReponse } from '@/lib/types'
|
|
|
|
export interface PromptProps
|
|
extends Pick<UseChatHelpers, 'input' | 'setInput'> {
|
|
onSubmit: (value: string) => Promise<void>
|
|
isLoading: boolean
|
|
}
|
|
|
|
export function PromptForm({
|
|
onSubmit,
|
|
input,
|
|
setInput,
|
|
isLoading
|
|
}: PromptProps) {
|
|
const { formRef, onKeyDown } = useEnterSubmit()
|
|
const [queryCompletionUrl, setQueryCompletionUrl] = React.useState<
|
|
string | null
|
|
>(null)
|
|
const latestFetchKey = React.useRef('')
|
|
|
|
useSWR<SearchReponse>(queryCompletionUrl, fetcher, {
|
|
revalidateOnFocus: false,
|
|
dedupingInterval: 500,
|
|
onSuccess: (data, key) => {
|
|
if (key !== latestFetchKey.current) return
|
|
|
|
setOptions(data?.hits ?? [])
|
|
}
|
|
})
|
|
|
|
const [options, setOptions] = React.useState<SearchReponse['hits']>([])
|
|
const onSearch = React.useMemo(() => {
|
|
return debounce((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
const value = e.target?.value ?? ''
|
|
const end = e.target?.selectionEnd ?? 0
|
|
const queryname = getSearchCompletionQueryName(value, end)
|
|
if (queryname) {
|
|
let url = `/v1beta/search?q=name:${queryname}`
|
|
latestFetchKey.current = url
|
|
setQueryCompletionUrl(`/v1beta/search?q=name:${queryname}`)
|
|
} else {
|
|
setOptions([])
|
|
}
|
|
}, 200)
|
|
}, [])
|
|
|
|
const onSelectCompletion = (
|
|
inputRef: React.MutableRefObject<
|
|
HTMLTextAreaElement | HTMLInputElement | null
|
|
>,
|
|
item: ISearchHit
|
|
) => {
|
|
const replaceString = '`@' + item?.doc?.name + '` '
|
|
const selectionEnd = inputRef.current?.selectionEnd ?? 0
|
|
const queryname = getSearchCompletionQueryName(input, selectionEnd)
|
|
const prevInput = input
|
|
.substring(0, selectionEnd)
|
|
.replace(new RegExp(`@${queryname}$`), '')
|
|
if (queryname) {
|
|
setInput(prevInput + replaceString + input.substring(selectionEnd))
|
|
}
|
|
|
|
setOptions([])
|
|
}
|
|
|
|
return (
|
|
<form
|
|
onSubmit={async e => {
|
|
e.preventDefault()
|
|
if (!input?.trim()) {
|
|
return
|
|
}
|
|
setInput('')
|
|
await onSubmit(input)
|
|
}}
|
|
ref={formRef}
|
|
>
|
|
<Combobox options={options} onSelect={onSelectCompletion}>
|
|
{({ open, inputRef, highlightedIndex }) => {
|
|
return (
|
|
<>
|
|
<ComboboxAnchor>
|
|
<div className="bg-background relative flex max-h-60 w-full grow flex-col overflow-hidden px-8 sm:rounded-md sm:border sm:px-12">
|
|
<span
|
|
className={cn(
|
|
buttonVariants({ size: 'sm', variant: 'ghost' }),
|
|
'bg-background hover:bg-background absolute left-0 top-4 h-8 w-8 rounded-full p-0 sm:left-4'
|
|
)}
|
|
>
|
|
<IconEdit />
|
|
</span>
|
|
<ComboboxTextarea
|
|
tabIndex={0}
|
|
rows={1}
|
|
placeholder="Ask a question."
|
|
spellCheck={false}
|
|
className="min-h-[60px] w-full resize-none bg-transparent px-4 py-[1.3rem] focus-within:outline-none sm:text-sm"
|
|
value={input}
|
|
ref={
|
|
inputRef as React.MutableRefObject<HTMLTextAreaElement>
|
|
}
|
|
onChange={e => {
|
|
if (has(e, 'target.value')) {
|
|
let event = e as React.ChangeEvent<HTMLTextAreaElement>
|
|
setInput(event.target.value)
|
|
onSearch?.(event)
|
|
}
|
|
}}
|
|
onKeyDown={onKeyDown}
|
|
/>
|
|
<div className="absolute right-0 top-4 sm:right-4">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
type="submit"
|
|
size="icon"
|
|
disabled={isLoading || input === ''}
|
|
>
|
|
<IconArrowElbow />
|
|
<span className="sr-only">Send message</span>
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Send message</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
</ComboboxAnchor>
|
|
<ComboboxContent
|
|
align="start"
|
|
onOpenAutoFocus={e => e.preventDefault()}
|
|
// popupMatchAnchorWidth
|
|
className="w-1/2 max-w-xl"
|
|
>
|
|
<Popover open={!!options?.[highlightedIndex]}>
|
|
<PopoverAnchor>
|
|
<div className="max-h-[300px] overflow-y-scroll">
|
|
{open &&
|
|
!!options?.length &&
|
|
options.map((item, index) => (
|
|
<ComboboxOption
|
|
item={item}
|
|
index={index}
|
|
key={item?.id}
|
|
>
|
|
<div className="flex flex-col overflow-x-hidden">
|
|
<div className="truncate">{item?.doc?.name}</div>
|
|
<div className="text-muted-foreground truncate text-sm">
|
|
{item?.doc?.body}
|
|
</div>
|
|
</div>
|
|
</ComboboxOption>
|
|
))}
|
|
</div>
|
|
</PopoverAnchor>
|
|
<PopoverContent
|
|
asChild
|
|
align="end"
|
|
side="right"
|
|
alignOffset={-4}
|
|
onOpenAutoFocus={e => e.preventDefault()}
|
|
onFocus={e => e.preventDefault()}
|
|
onClick={e => e.preventDefault()}
|
|
className="max-w-xl rounded-none"
|
|
>
|
|
<div className="flex flex-col px-2">
|
|
<div className="mb-2">
|
|
{options?.[highlightedIndex]?.doc?.name}
|
|
</div>
|
|
<div className="text-muted-foreground flex-1 overflow-auto whitespace-pre">
|
|
{options?.[highlightedIndex]?.doc?.body}
|
|
</div>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</ComboboxContent>
|
|
</>
|
|
)
|
|
}}
|
|
</Combobox>
|
|
</form>
|
|
)
|
|
}
|