Initial commit
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import type { TextAreaRef } from 'rc-textarea'
|
||||
|
||||
export const useTextAreaHeight = () => {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
const textareaRef = useRef<TextAreaRef>(null)
|
||||
const textValueRef = useRef<HTMLDivElement>(null)
|
||||
const holdSpaceRef = useRef<HTMLDivElement>(null)
|
||||
const [isMultipleLine, setIsMultipleLine] = useState(false)
|
||||
|
||||
const handleComputeHeight = useCallback(() => {
|
||||
const textareaElement = textareaRef.current?.resizableTextArea.textArea
|
||||
if (wrapperRef.current && textareaElement && textValueRef.current && holdSpaceRef.current) {
|
||||
const { width: wrapperWidth } = wrapperRef.current.getBoundingClientRect()
|
||||
const { height: textareaHeight } = textareaElement.getBoundingClientRect()
|
||||
const { width: textValueWidth } = textValueRef.current.getBoundingClientRect()
|
||||
const { width: holdSpaceWidth } = holdSpaceRef.current.getBoundingClientRect()
|
||||
|
||||
if (textareaHeight > 32) {
|
||||
setIsMultipleLine(true)
|
||||
}
|
||||
else {
|
||||
if (textValueWidth + holdSpaceWidth >= wrapperWidth)
|
||||
setIsMultipleLine(true)
|
||||
else
|
||||
setIsMultipleLine(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleTextareaResize = useCallback(() => {
|
||||
handleComputeHeight()
|
||||
}, [handleComputeHeight])
|
||||
|
||||
return {
|
||||
wrapperRef,
|
||||
textareaRef,
|
||||
textValueRef,
|
||||
holdSpaceRef,
|
||||
handleTextareaResize,
|
||||
isMultipleLine,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import {
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import Textarea from 'rc-textarea'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Recorder from 'js-audio-recorder'
|
||||
import type {
|
||||
EnableType,
|
||||
OnSend,
|
||||
} from '../../types'
|
||||
import type { Theme } from '../embedded-chatbot/theme/theme-context'
|
||||
import type { InputForm } from '../type'
|
||||
|
||||
import { useCheckInputsForms } from '../check-input-forms-hooks'
|
||||
import { useTextAreaHeight } from './hooks'
|
||||
import Operation from './operation'
|
||||
import cn from '@/utils/classnames'
|
||||
import { FileListInChatInput } from '@/app/components/base/file-uploader'
|
||||
import { useFile } from '@/app/components/base/file-uploader/hooks'
|
||||
import {
|
||||
FileContextProvider,
|
||||
useFileStore,
|
||||
} from '@/app/components/base/file-uploader/store'
|
||||
import VoiceInput from '@/app/components/base/voice-input'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
// TODO mars
|
||||
// import FeatureBar from '@/app/components/base/features/new-feature-panel/feature-bar'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
|
||||
type ChatInputAreaProps = {
|
||||
showFeatureBar?: boolean
|
||||
showFileUpload?: boolean
|
||||
featureBarDisabled?: boolean
|
||||
onFeatureBarClick?: (state: boolean) => void
|
||||
visionConfig?: FileUpload
|
||||
speechToTextConfig?: EnableType
|
||||
onSend?: OnSend
|
||||
inputs?: Record<string, any>
|
||||
inputsForm?: InputForm[]
|
||||
theme?: Theme | null
|
||||
isResponding?: boolean
|
||||
}
|
||||
const ChatInputArea = ({
|
||||
showFeatureBar,
|
||||
showFileUpload,
|
||||
featureBarDisabled,
|
||||
onFeatureBarClick,
|
||||
visionConfig,
|
||||
speechToTextConfig = { enabled: true },
|
||||
onSend,
|
||||
inputs = {},
|
||||
inputsForm = [],
|
||||
theme,
|
||||
isResponding,
|
||||
}: ChatInputAreaProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useToastContext()
|
||||
const {
|
||||
wrapperRef,
|
||||
textareaRef,
|
||||
textValueRef,
|
||||
holdSpaceRef,
|
||||
handleTextareaResize,
|
||||
isMultipleLine,
|
||||
} = useTextAreaHeight()
|
||||
const [query, setQuery] = useState('')
|
||||
const [showVoiceInput, setShowVoiceInput] = useState(false)
|
||||
const filesStore = useFileStore()
|
||||
const {
|
||||
handleDragFileEnter,
|
||||
handleDragFileLeave,
|
||||
handleDragFileOver,
|
||||
handleDropFile,
|
||||
handleClipboardPasteFile,
|
||||
isDragActive,
|
||||
} = useFile(visionConfig!)
|
||||
const { checkInputsForm } = useCheckInputsForms()
|
||||
const historyRef = useRef([''])
|
||||
const [currentIndex, setCurrentIndex] = useState(-1)
|
||||
const handleSend = () => {
|
||||
if (isResponding) {
|
||||
notify({ type: 'info', message: t('appDebug.errorMessage.waitForResponse') })
|
||||
return
|
||||
}
|
||||
|
||||
if (onSend) {
|
||||
const { files, setFiles } = filesStore.getState()
|
||||
if (files.find(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId)) {
|
||||
notify({ type: 'info', message: t('appDebug.errorMessage.waitForFileUpload') })
|
||||
return
|
||||
}
|
||||
if (!query || !query.trim()) {
|
||||
notify({ type: 'info', message: t('appAnnotation.errorMessage.queryRequired') })
|
||||
return
|
||||
}
|
||||
if (checkInputsForm(inputs, inputsForm)) {
|
||||
// debugger
|
||||
onSend(query, files)
|
||||
setQuery('')
|
||||
setFiles([])
|
||||
}
|
||||
}
|
||||
}
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault()
|
||||
setQuery(query.replace(/\n$/, ''))
|
||||
historyRef.current.push(query)
|
||||
setCurrentIndex(historyRef.current.length)
|
||||
handleSend()
|
||||
}
|
||||
else if (e.key === 'ArrowUp' && !e.shiftKey && !e.nativeEvent.isComposing && e.metaKey) {
|
||||
// When the cmd + up key is pressed, output the previous element
|
||||
if (currentIndex > 0) {
|
||||
setCurrentIndex(currentIndex - 1)
|
||||
setQuery(historyRef.current[currentIndex - 1])
|
||||
}
|
||||
}
|
||||
else if (e.key === 'ArrowDown' && !e.shiftKey && !e.nativeEvent.isComposing && e.metaKey) {
|
||||
// When the cmd + down key is pressed, output the next element
|
||||
if (currentIndex < historyRef.current.length - 1) {
|
||||
setCurrentIndex(currentIndex + 1)
|
||||
setQuery(historyRef.current[currentIndex + 1])
|
||||
}
|
||||
else if (currentIndex === historyRef.current.length - 1) {
|
||||
// If it is the last element, clear the input box
|
||||
setCurrentIndex(historyRef.current.length)
|
||||
setQuery('')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleShowVoiceInput = useCallback(() => {
|
||||
(Recorder as any).getPermission().then(() => {
|
||||
setShowVoiceInput(true)
|
||||
}, () => {
|
||||
notify({ type: 'error', message: t('common.voiceInput.notAllow') })
|
||||
})
|
||||
}, [t, notify])
|
||||
|
||||
const operation = (
|
||||
<Operation
|
||||
ref={holdSpaceRef}
|
||||
fileConfig={visionConfig}
|
||||
speechToTextConfig={speechToTextConfig}
|
||||
onShowVoiceInput={handleShowVoiceInput}
|
||||
onSend={handleSend}
|
||||
theme={theme}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'relative pb-[9px] bg-components-panel-bg-blur border border-components-chat-input-border rounded-xl shadow-md z-10',
|
||||
isDragActive && 'border border-dashed border-components-option-card-option-selected-border',
|
||||
)}
|
||||
>
|
||||
<div className='relative px-[9px] pt-[9px] max-h-[158px] overflow-x-hidden overflow-y-auto'>
|
||||
<FileListInChatInput fileConfig={visionConfig!} />
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className='flex items-center justify-between'
|
||||
>
|
||||
<div className='flex items-center relative grow w-full'>
|
||||
<div
|
||||
ref={textValueRef}
|
||||
className='absolute w-auto h-auto p-1 leading-6 body-lg-regular pointer-events-none whitespace-pre invisible'
|
||||
>
|
||||
{query}
|
||||
</div>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
className={cn(
|
||||
'p-1 w-full leading-6 body-lg-regular text-text-tertiary bg-transparent outline-none',
|
||||
)}
|
||||
placeholder={t('common.chat.inputPlaceholder') || ''}
|
||||
autoFocus
|
||||
autoSize={{ minRows: 1 }}
|
||||
onResize={handleTextareaResize}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
handleTextareaResize()
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handleClipboardPasteFile}
|
||||
onDragEnter={handleDragFileEnter}
|
||||
onDragLeave={handleDragFileLeave}
|
||||
onDragOver={handleDragFileOver}
|
||||
onDrop={handleDropFile}
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
!isMultipleLine && operation
|
||||
}
|
||||
</div>
|
||||
{
|
||||
showVoiceInput && (
|
||||
<VoiceInput
|
||||
onCancel={() => setShowVoiceInput(false)}
|
||||
onConverted={text => setQuery(text)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
isMultipleLine && (
|
||||
<div className='px-[9px]'>{operation}</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{/* {showFeatureBar && <FeatureBar showFileUpload={showFileUpload} disabled={featureBarDisabled} onFeatureBarClick={onFeatureBarClick} />} */}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ChatInputAreaWrapper = (props: ChatInputAreaProps) => {
|
||||
return (
|
||||
<FileContextProvider>
|
||||
<ChatInputArea {...props} />
|
||||
</FileContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatInputAreaWrapper
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
forwardRef,
|
||||
memo,
|
||||
} from 'react'
|
||||
import {
|
||||
RiMicLine,
|
||||
RiSendPlane2Fill,
|
||||
} from '@remixicon/react'
|
||||
import type {
|
||||
EnableType,
|
||||
} from '../../types'
|
||||
import type { Theme } from '../embedded-chatbot/theme/theme-context'
|
||||
import Button from '@/app/components/base/button'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import { FileUploaderInChatInput } from '@/app/components/base/file-uploader'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type OperationProps = {
|
||||
fileConfig?: FileUpload
|
||||
speechToTextConfig?: EnableType
|
||||
onShowVoiceInput?: () => void
|
||||
onSend: () => void
|
||||
theme?: Theme | null
|
||||
}
|
||||
const Operation = forwardRef<HTMLDivElement, OperationProps>(({
|
||||
fileConfig,
|
||||
speechToTextConfig,
|
||||
onShowVoiceInput,
|
||||
onSend,
|
||||
theme,
|
||||
}, ref) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 flex items-center justify-end',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className='flex items-center pl-1'
|
||||
ref={ref}
|
||||
>
|
||||
<div className='flex items-center space-x-1'>
|
||||
{fileConfig?.enabled && <FileUploaderInChatInput fileConfig={fileConfig} />}
|
||||
{
|
||||
speechToTextConfig?.enabled && (
|
||||
<ActionButton
|
||||
size='l'
|
||||
onClick={onShowVoiceInput}
|
||||
>
|
||||
<RiMicLine className='w-5 h-5' />
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<Button
|
||||
className='ml-3 px-0 w-8'
|
||||
variant='primary'
|
||||
onClick={onSend}
|
||||
style={
|
||||
theme
|
||||
? {
|
||||
backgroundColor: theme.primaryColor,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<RiSendPlane2Fill className='w-4 h-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
Operation.displayName = 'Operation'
|
||||
|
||||
export default memo(Operation)
|
||||
Reference in New Issue
Block a user