fix: prepare for submitting prompt

feat-adding-an-auto-completion-component
liangfung 2023-11-06 23:55:19 +08:00
parent ad17668534
commit 992f9f1cf4
6 changed files with 211 additions and 95 deletions

View File

@ -1,7 +1,12 @@
import { UseChatHelpers } from 'ai/react' import { UseChatHelpers } from 'ai/react'
import * as React from 'react' import * as React from 'react'
import useSWR from 'swr'
import { Button, buttonVariants } from '@/components/ui/button' import { Button, buttonVariants } from '@/components/ui/button'
import { IconArrowElbow, IconEdit } from '@/components/ui/icons' import {
IconArrowElbow,
IconEdit,
IconSymbolFunction
} from '@/components/ui/icons'
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@ -16,11 +21,11 @@ import {
} from '@/components/ui/combobox' } from '@/components/ui/combobox'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { useEnterSubmit } from '@/lib/hooks/use-enter-submit' import { useEnterSubmit } from '@/lib/hooks/use-enter-submit'
import { cn, getSearchCompletionQueryName } from '@/lib/utils' import { cn } from '@/lib/utils'
import useSWR from 'swr'
import fetcher from '@/lib/tabby-fetcher' import fetcher from '@/lib/tabby-fetcher'
import { debounce, has } from 'lodash' import { debounce, has } from 'lodash-es'
import { ISearchHit, SearchReponse } from '@/lib/types' import type { ISearchHit, SearchReponse } from '@/lib/types'
import { lightfair } from 'react-syntax-highlighter/dist/esm/styles/hljs'
export interface PromptProps export interface PromptProps
extends Pick<UseChatHelpers, 'input' | 'setInput'> { extends Pick<UseChatHelpers, 'input' | 'setInput'> {
@ -39,6 +44,12 @@ export function PromptForm({
string | null string | null
>(null) >(null)
const latestFetchKey = React.useRef('') const latestFetchKey = React.useRef('')
const inputRef = React.useRef<HTMLTextAreaElement>(null)
const nextInputSelectionRange = React.useRef<[number, number]>()
const [options, setOptions] = React.useState<SearchReponse['hits']>([])
const [selectedCompletionsMap, setSelectedCompletionsMap] = React.useState<
Record<string, ISearchHit>
>({})
useSWR<SearchReponse>(queryCompletionUrl, fetcher, { useSWR<SearchReponse>(queryCompletionUrl, fetcher, {
revalidateOnFocus: false, revalidateOnFocus: false,
@ -50,14 +61,24 @@ export function PromptForm({
} }
}) })
const [options, setOptions] = React.useState<SearchReponse['hits']>([]) React.useLayoutEffect(() => {
const onSearch = React.useMemo(() => { if (nextInputSelectionRange.current?.length) {
inputRef.current?.setSelectionRange?.(
nextInputSelectionRange.current[0],
nextInputSelectionRange.current[1]
)
nextInputSelectionRange.current = undefined
}
})
const handleSearchCompletion = React.useMemo(() => {
return debounce((e: React.ChangeEvent<HTMLTextAreaElement>) => { return debounce((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target?.value ?? '' const value = e.target?.value ?? ''
const end = e.target?.selectionEnd ?? 0 const end = e.target?.selectionEnd ?? 0
const queryname = getSearchCompletionQueryName(value, end) const queryNameMatches = getSearchCompletionQueryName(value, end)
if (queryname) { const queryName = queryNameMatches?.[1]
const query = encodeURIComponent(`name:${queryname} kind:function`) if (queryName) {
const query = encodeURIComponent(`name:${queryName} kind:function`)
const url = `/v1beta/search?q=${query}` const url = `/v1beta/search?q=${query}`
latestFetchKey.current = url latestFetchKey.current = url
setQueryCompletionUrl(url) setQueryCompletionUrl(url)
@ -67,39 +88,60 @@ export function PromptForm({
}, 200) }, 200)
}, []) }, [])
const onSelectCompletion = ( const handleCompletionSelect = (
inputRef: React.MutableRefObject< inputRef: React.RefObject<HTMLTextAreaElement | HTMLInputElement>,
HTMLTextAreaElement | HTMLInputElement | null
>,
item: ISearchHit item: ISearchHit
) => { ) => {
const replaceString = '`@' + item?.doc?.name + '` '
const selectionEnd = inputRef.current?.selectionEnd ?? 0 const selectionEnd = inputRef.current?.selectionEnd ?? 0
const queryname = getSearchCompletionQueryName(input, selectionEnd) const queryNameMatches = getSearchCompletionQueryName(input, selectionEnd)
const prevInput = input if (queryNameMatches) {
.substring(0, selectionEnd) setSelectedCompletionsMap({
.replace(new RegExp(`@${queryname}$`), '') ...selectedCompletionsMap,
if (queryname) { [queryNameMatches[0]]: item
setInput(prevInput + replaceString + input.substring(selectionEnd)) })
// insert a space to break the search query
setInput(input.slice(0, selectionEnd) + ' ' + input.slice(selectionEnd))
// store the selection range and update it when layout
nextInputSelectionRange.current = [selectionEnd + 1, selectionEnd + 1]
} }
setOptions([]) setOptions([])
} }
return ( const handlePromptSubmit: React.FormEventHandler<
<form HTMLFormElement
onSubmit={async e => { > = async e => {
e.preventDefault() e.preventDefault()
if (!input?.trim()) { if (!input?.trim()) {
return return
} }
let finalInput = input
// replace queryname to doc.body of selected completions
Object.keys(selectedCompletionsMap).forEach(key => {
const completion = selectedCompletionsMap[key]
if (!completion?.doc) return
finalInput = finalInput.replaceAll(
key,
`${'```'}${completion.doc?.language ?? ''}\n${
completion.doc.body ?? ''
}\n${'```'}\n`
)
})
setInput('') setInput('')
await onSubmit(input) await onSubmit(finalInput)
}} }
ref={formRef}
return (
<form onSubmit={handlePromptSubmit} ref={formRef}>
<Combobox
inputRef={inputRef}
options={options}
onSelect={handleCompletionSelect}
> >
<Combobox options={options} onSelect={onSelectCompletion}> {({ open, highlightedIndex }) => {
{({ open, inputRef, highlightedIndex }) => { const highlightedOption = options?.[highlightedIndex]
return ( return (
<> <>
<ComboboxAnchor> <ComboboxAnchor>
@ -119,14 +161,11 @@ export function PromptForm({
spellCheck={false} spellCheck={false}
className="min-h-[60px] w-full resize-none bg-transparent px-4 py-[1.3rem] focus-within:outline-none sm:text-sm" className="min-h-[60px] w-full resize-none bg-transparent px-4 py-[1.3rem] focus-within:outline-none sm:text-sm"
value={input} value={input}
ref={ ref={inputRef}
inputRef as React.MutableRefObject<HTMLTextAreaElement>
}
onChange={e => { onChange={e => {
if (has(e, 'target.value')) { if (has(e, 'target.value')) {
let event = e as React.ChangeEvent<HTMLTextAreaElement> setInput(e.target.value)
setInput(event.target.value) handleSearchCompletion(e)
onSearch?.(event)
} }
}} }}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
@ -151,11 +190,10 @@ export function PromptForm({
<ComboboxContent <ComboboxContent
align="start" align="start"
onOpenAutoFocus={e => e.preventDefault()} onOpenAutoFocus={e => e.preventDefault()}
// popupMatchAnchorWidth className="w-[60vw] md:w-[430px]"
className="w-1/2 max-w-xl"
> >
<Popover open={!!options?.[highlightedIndex]}> <Popover open={open && !!highlightedOption}>
<PopoverAnchor> <PopoverAnchor asChild>
<div className="max-h-[300px] overflow-y-scroll"> <div className="max-h-[300px] overflow-y-scroll">
{open && {open &&
!!options?.length && !!options?.length &&
@ -165,9 +203,14 @@ export function PromptForm({
index={index} index={index}
key={item?.id} key={item?.id}
> >
<div className="flex flex-col overflow-x-hidden"> <div className="flex w-full items-center justify-between gap-8 overflow-x-hidden">
<div className="truncate">{item?.doc?.name}</div> <div className="flex items-center gap-1">
<div className="text-muted-foreground truncate text-sm"> <IconForCompletionKind kind={item?.doc?.kind} />
<div className="max-w-[200px] truncate">
{item?.doc?.name}(...)
</div>
</div>
<div className="text-muted-foreground flex-1 truncate text-right text-sm">
{item?.doc?.body} {item?.doc?.body}
</div> </div>
</div> </div>
@ -177,20 +220,23 @@ export function PromptForm({
</PopoverAnchor> </PopoverAnchor>
<PopoverContent <PopoverContent
asChild asChild
align="end" align="start"
side="right" side="right"
alignOffset={-4} alignOffset={-4}
onOpenAutoFocus={e => e.preventDefault()} onOpenAutoFocus={e => e.preventDefault()}
onFocus={e => e.preventDefault()} onKeyDownCapture={e => e.preventDefault()}
onClick={e => e.preventDefault()} className="rounded-none"
className="max-w-xl rounded-none" collisionPadding={{ bottom: 120 }}
> >
<div className="flex flex-col px-2"> <div className="flex max-h-[70vh] w-[20vw] flex-col overflow-y-auto px-2 md:w-[240px] lg:w-[340px]">
<div className="mb-2"> <div className="mb-2">
{options?.[highlightedIndex]?.doc?.name} {highlightedOption?.doc?.kind
? `(${highlightedOption?.doc?.kind}) `
: ''}
{highlightedOption?.doc?.name}
</div> </div>
<div className="text-muted-foreground flex-1 overflow-auto whitespace-pre"> <div className="text-muted-foreground flex-1 whitespace-pre-wrap break-all">
{options?.[highlightedIndex]?.doc?.body} {highlightedOption?.doc?.body}
</div> </div>
</div> </div>
</PopoverContent> </PopoverContent>
@ -203,3 +249,30 @@ export function PromptForm({
</form> </form>
) )
} }
/**
* Retrieves the name of the completion query from a given string@.
* @param {string} val - The input string to search for the completion query name.
* @param {number | undefined} selectionEnd - The index at which the selection ends in the input string.
* @return {string | undefined} - The name of the completion query if found, otherwise undefined.
*/
export function getSearchCompletionQueryName(
val: string,
selectionEnd: number | undefined
): RegExpExecArray | null {
const queryString = val.substring(0, selectionEnd)
const matches = /@(\w+)$/.exec(queryString)
return matches
}
function IconForCompletionKind({
kind,
...rest
}: { kind: string | undefined } & React.ComponentProps<'svg'>) {
switch (kind) {
case 'function':
return <IconSymbolFunction {...rest} />
default:
return <IconSymbolFunction {...rest} />
}
}

View File

@ -8,16 +8,18 @@ import {
} from '@/components/ui/popover' } from '@/components/ui/popover'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useCombobox } from 'downshift' import { useCombobox } from 'downshift'
import type { UseComboboxReturnValue } from 'downshift' import type {
UseComboboxReturnValue,
UseComboboxState,
UseComboboxStateChangeOptions
} from 'downshift'
import Textarea from 'react-textarea-autosize' import Textarea from 'react-textarea-autosize'
import { isNil, omitBy } from 'lodash' import { isNil, omitBy } from 'lodash-es'
interface ComboboxContextValue<T = any> extends UseComboboxReturnValue<T> { interface ComboboxContextValue<T = any> extends UseComboboxReturnValue<T> {
open: boolean open: boolean
inputRef: React.MutableRefObject< inputRef: React.RefObject<HTMLInputElement | HTMLTextAreaElement>
HTMLInputElement | HTMLTextAreaElement | null anchorRef: React.RefObject<HTMLElement>
>
anchorRef: React.MutableRefObject<HTMLElement | null>
} }
export const ComboboxContext = React.createContext({} as ComboboxContextValue) export const ComboboxContext = React.createContext({} as ComboboxContextValue)
@ -133,7 +135,12 @@ export const ComboboxOption = React.forwardRef<
disabled && 'pointer-events-none opacity-50', disabled && 'pointer-events-none opacity-50',
className className
)} )}
{...getItemProps({ item, index })} {...getItemProps({
item,
index,
onMouseLeave: e => e.preventDefault(),
onMouseOut: e => e.preventDefault()
})}
{...rest} {...rest}
> >
{typeof children === 'function' {typeof children === 'function'
@ -148,24 +155,48 @@ ComboboxOption.displayName = 'ComboboxOption'
interface ComboboxProps<T> { interface ComboboxProps<T> {
options: T[] | undefined options: T[] | undefined
onSelect?: ( onSelect?: (
ref: React.MutableRefObject<HTMLTextAreaElement | HTMLInputElement | null>, ref: React.RefObject<HTMLTextAreaElement | HTMLInputElement>,
data: T data: T
) => void ) => void
inputRef?: React.RefObject<HTMLTextAreaElement | HTMLInputElement>
children?: children?:
| React.ReactNode | React.ReactNode
| React.ReactNode[] | React.ReactNode[]
| ((p: ComboboxContextValue) => React.ReactNode) | ((contextValue: ComboboxContextValue) => React.ReactNode)
} }
export function Combobox<T extends { id: number }>({ export function Combobox<T extends { id: number }>({
options, options,
onSelect, onSelect,
inputRef: propsInputRef,
children children
}: ComboboxProps<T>) { }: ComboboxProps<T>) {
const [manualOpen, setManualOpen] = React.useState(false) const [manualOpen, setManualOpen] = React.useState(false)
const inputRef = React.useRef<HTMLTextAreaElement | HTMLInputElement | null>( const internalInputRef = React.useRef<HTMLTextAreaElement | HTMLInputElement>(
null null
) )
const anchorRef = React.useRef<HTMLElement | null>(null) const inputRef = propsInputRef ?? internalInputRef
const anchorRef = React.useRef<HTMLElement>(null)
const stateReducer = React.useCallback(
(
state: UseComboboxState<T>,
actionAndChanges: UseComboboxStateChangeOptions<T>
) => {
const { type, changes } = actionAndChanges
switch (type) {
case useCombobox.stateChangeTypes.MenuMouseLeave:
return {
...changes,
highlightedIndex: state.highlightedIndex
}
default:
return changes
}
},
[]
)
const comboboxValue = useCombobox({ const comboboxValue = useCombobox({
items: options ?? [], items: options ?? [],
isOpen: manualOpen, isOpen: manualOpen,
@ -179,11 +210,12 @@ export function Combobox<T extends { id: number }>({
if (!isOpen) { if (!isOpen) {
setManualOpen(false) setManualOpen(false)
} }
} },
stateReducer
}) })
const { setHighlightedIndex, highlightedIndex, isOpen } = comboboxValue const { setHighlightedIndex, highlightedIndex } = comboboxValue
const open = isOpen && !!options?.length const open = manualOpen && !!options?.length
React.useEffect(() => { React.useEffect(() => {
if (open && !!options.length && highlightedIndex === -1) { if (open && !!options.length && highlightedIndex === -1) {
@ -192,7 +224,7 @@ export function Combobox<T extends { id: number }>({
if (open && !options.length) { if (open && !options.length) {
setManualOpen(false) setManualOpen(false)
} }
}, [open]) }, [open, options])
React.useEffect(() => { React.useEffect(() => {
if (options?.length) { if (options?.length) {

View File

@ -554,6 +554,24 @@ function IconNotice({ className, ...props }: React.ComponentProps<'svg'>) {
) )
} }
function IconSymbolFunction({
className,
...props
}: React.ComponentProps<'svg'>) {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
{...props}
>
<path d="M13.51 4l-5-3h-1l-5 3-.49.86v6l.49.85 5 3h1l5-3 .49-.85v-6L13.51 4zm-6 9.56l-4.5-2.7V5.7l4.5 2.45v5.41zM3.27 4.7l4.74-2.84 4.74 2.84-4.74 2.59L3.27 4.7zm9.74 6.16l-4.5 2.7V8.15l4.5-2.45v5.16z" />
</svg>
)
}
export { export {
IconEdit, IconEdit,
IconNextChat, IconNextChat,
@ -583,5 +601,6 @@ export {
IconExternalLink, IconExternalLink,
IconChevronUpDown, IconChevronUpDown,
IconSlack, IconSlack,
IconNotice IconNotice,
IconSymbolFunction
} }

View File

@ -41,18 +41,3 @@ export function formatDate(input: string | number | Date): string {
year: 'numeric' year: 'numeric'
}) })
} }
/**
* Retrieves the name of the completion query from a given string@.
* @param {string} val - The input string to search for the completion query name.
* @param {number | undefined} selectionEnd - The index at which the selection ends in the input string.
* @return {string | undefined} - The name of the completion query if found, otherwise undefined.
*/
export function getSearchCompletionQueryName(
val: string,
selectionEnd: number | undefined
): string | undefined {
const queryString = val.substring(0, selectionEnd)
const matches = /@(\w+)$/.exec(queryString)
return matches?.[1]
}

View File

@ -33,7 +33,7 @@
"compare-versions": "^6.1.0", "compare-versions": "^6.1.0",
"downshift": "^8.2.2", "downshift": "^8.2.2",
"focus-trap-react": "^10.1.1", "focus-trap-react": "^10.1.1",
"lodash": "^4.17.21", "lodash-es": "^4.17.21",
"nanoid": "^4.0.2", "nanoid": "^4.0.2",
"next": "^13.4.7", "next": "^13.4.7",
"next-auth": "0.0.0-manual.83c4ebd1", "next-auth": "0.0.0-manual.83c4ebd1",
@ -52,7 +52,7 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/typography": "^0.5.9", "@tailwindcss/typography": "^0.5.9",
"@types/lodash": "^4.14.200", "@types/lodash-es": "^4.17.10",
"@types/node": "^17.0.12", "@types/node": "^17.0.12",
"@types/react": "^18.0.22", "@types/react": "^18.0.22",
"@types/react-dom": "^18.0.7", "@types/react-dom": "^18.0.7",

View File

@ -734,7 +734,14 @@
resolved "https://registry.yarnpkg.com/@types/katex/-/katex-0.16.3.tgz#a341c89705145b7dd8e2a133b282a133eabe6076" resolved "https://registry.yarnpkg.com/@types/katex/-/katex-0.16.3.tgz#a341c89705145b7dd8e2a133b282a133eabe6076"
integrity sha512-CeVMX9EhVUW8MWnei05eIRks4D5Wscw/W9Byz1s3PA+yJvcdvq9SaDjiUKvRvEgjpdTyJMjQA43ae4KTwsvOPg== integrity sha512-CeVMX9EhVUW8MWnei05eIRks4D5Wscw/W9Byz1s3PA+yJvcdvq9SaDjiUKvRvEgjpdTyJMjQA43ae4KTwsvOPg==
"@types/lodash@^4.14.200": "@types/lodash-es@^4.17.10":
version "4.17.10"
resolved "https://registry.yarnpkg.com/@types/lodash-es/-/lodash-es-4.17.10.tgz#1b36a76ca9eda20c0263e19bbe1a3adb1b317707"
integrity sha512-YJP+w/2khSBwbUSFdGsSqmDvmnN3cCKoPOL7Zjle6s30ZtemkkqhjVfFqGwPN7ASil5VyjE2GtyU/yqYY6mC0A==
dependencies:
"@types/lodash" "*"
"@types/lodash@*":
version "4.14.200" version "4.14.200"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.200.tgz#435b6035c7eba9cdf1e039af8212c9e9281e7149" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.200.tgz#435b6035c7eba9cdf1e039af8212c9e9281e7149"
integrity sha512-YI/M/4HRImtNf3pJgbF+W6FrXovqj+T+/HpENLTooK9PnkacBsDpeP3IpHab40CClUfhNmdM2WTNP2sa2dni5Q== integrity sha512-YI/M/4HRImtNf3pJgbF+W6FrXovqj+T+/HpENLTooK9PnkacBsDpeP3IpHab40CClUfhNmdM2WTNP2sa2dni5Q==
@ -2636,6 +2643,11 @@ locate-path@^6.0.0:
dependencies: dependencies:
p-locate "^5.0.0" p-locate "^5.0.0"
lodash-es@^4.17.21:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee"
integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
lodash.castarray@^4.4.0: lodash.castarray@^4.4.0:
version "4.4.0" version "4.4.0"
resolved "https://registry.yarnpkg.com/lodash.castarray/-/lodash.castarray-4.4.0.tgz#c02513515e309daddd4c24c60cfddcf5976d9115" resolved "https://registry.yarnpkg.com/lodash.castarray/-/lodash.castarray-4.4.0.tgz#c02513515e309daddd4c24c60cfddcf5976d9115"
@ -2651,11 +2663,6 @@ lodash.merge@^4.6.2:
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
lodash@^4.17.21:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
longest-streak@^3.0.0: longest-streak@^3.0.0:
version "3.1.0" version "3.1.0"
resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-3.1.0.tgz#62fa67cd958742a1574af9f39866364102d90cd4" resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-3.1.0.tgz#62fa67cd958742a1574af9f39866364102d90cd4"