feat: multiple model configuration (#2196)

Co-authored-by: Joel <iamjoel007@gmail.com>
This commit is contained in:
zxhlyh
2024-01-25 12:36:55 +08:00
committed by GitHub
parent 6bfdfab6f3
commit d5361b8d09
56 changed files with 3209 additions and 188 deletions

View File

@@ -0,0 +1,58 @@
import type { FC } from 'react'
import type {
ChatItem,
VisionFile,
} from '../../types'
import { useChatContext } from '../context'
import { Markdown } from '@/app/components/base/markdown'
import Thought from '@/app/components/app/chat/thought'
import ImageGallery from '@/app/components/base/image-gallery'
type AgentContentProps = {
item: ChatItem
}
const AgentContent: FC<AgentContentProps> = ({
item,
}) => {
const { allToolIcons } = useChatContext()
const {
annotation,
agent_thoughts,
} = item
const getImgs = (list?: VisionFile[]) => {
if (!list)
return []
return list.filter(file => file.type === 'image' && file.belongs_to === 'assistant')
}
if (annotation?.logAnnotation)
return <Markdown content={annotation?.logAnnotation.content || ''} />
return (
<div>
{agent_thoughts?.map((thought, index) => (
<div key={index}>
{thought.thought && (
<Markdown content={thought.thought} />
)}
{/* {item.tool} */}
{/* perhaps not use tool */}
{!!thought.tool && (
<Thought
thought={thought}
allToolIcons={allToolIcons || {}}
isFinished={!!thought.observation}
/>
)}
{getImgs(thought.message_files).length > 0 && (
<ImageGallery srcs={getImgs(thought.message_files).map(file => file.url)} />
)}
</div>
))}
</div>
)
}
export default AgentContent

View File

@@ -0,0 +1,22 @@
import type { FC } from 'react'
import type { ChatItem } from '../../types'
import { Markdown } from '@/app/components/base/markdown'
type BasicContentProps = {
item: ChatItem
}
const BasicContent: FC<BasicContentProps> = ({
item,
}) => {
const {
annotation,
content,
} = item
if (annotation?.logAnnotation)
return <Markdown content={annotation?.logAnnotation.content || ''} />
return <Markdown content={content} />
}
export default BasicContent

View File

@@ -0,0 +1,99 @@
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import type { ChatItem } from '../../types'
import { useChatContext } from '../context'
import { useCurrentAnswerIsResponsing } from '../hooks'
import Operation from './operation'
import AgentContent from './agent-content'
import BasicContent from './basic-content'
import SuggestedQuestions from './suggested-questions'
import More from './more'
import { AnswerTriangle } from '@/app/components/base/icons/src/vender/solid/general'
import LoadingAnim from '@/app/components/app/chat/loading-anim'
import Citation from '@/app/components/app/chat/citation'
import { EditTitle } from '@/app/components/app/annotation/edit-annotation-modal/edit-item'
type AnswerProps = {
item: ChatItem
}
const Answer: FC<AnswerProps> = ({
item,
}) => {
const { t } = useTranslation()
const {
config,
answerIcon,
} = useChatContext()
const responsing = useCurrentAnswerIsResponsing(item.id)
const {
content,
citation,
agent_thoughts,
more,
annotation,
} = item
const hasAgentThoughts = !!agent_thoughts?.length
return (
<div className='flex mb-2 last:mb-0'>
<div className='shrink-0 relative w-10 h-10'>
{
answerIcon || (
<div className='flex items-center justify-center w-full h-full rounded-full bg-[#d5f5f6] border-[0.5px] border-black/5 text-xl'>
🤖
</div>
)
}
{
responsing && (
<div className='absolute -top-[3px] -left-[3px] pl-[6px] flex items-center w-4 h-4 bg-white rounded-full shadow-xs border-[0.5px] border-gray-50'>
<LoadingAnim type='avatar' />
</div>
)
}
</div>
<div className='chat-answer-container grow w-0 group ml-4'>
<div className='relative pr-10'>
<AnswerTriangle className='absolute -left-2 top-0 w-2 h-3 text-gray-100' />
<div className='group relative inline-block px-4 py-3 max-w-full bg-gray-100 rounded-b-2xl rounded-tr-2xl text-sm text-gray-900'>
<Operation item={item} />
{
responsing && !content && !hasAgentThoughts && (
<div className='flex items-center justify-center w-6 h-5'>
<LoadingAnim type='text' />
</div>
)
}
{
content && !hasAgentThoughts && (
<BasicContent item={item} />
)
}
{
hasAgentThoughts && !content && (
<AgentContent item={item} />
)
}
{
annotation?.id && !annotation?.logAnnotation && (
<EditTitle
className='mt-1'
title={t('appAnnotation.editBy', { author: annotation.authorName })}
/>
)
}
<SuggestedQuestions item={item} />
{
!!citation?.length && config?.retriever_resource?.enabled && !responsing && (
<Citation data={citation} showHitInfo />
)
}
</div>
</div>
<More more={more} />
</div>
</div>
)
}
export default Answer

View File

@@ -0,0 +1,45 @@
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import type { ChatItem } from '../../types'
import { formatNumber } from '@/utils/format'
type MoreProps = {
more: ChatItem['more']
}
const More: FC<MoreProps> = ({
more,
}) => {
const { t } = useTranslation()
return (
<div className='flex items-center mt-1 h-[18px] text-xs text-gray-400 opacity-0 group-hover:opacity-100'>
{
more && (
<>
<div
className='mr-2 shrink-0 truncate max-w-[33.3%]'
title={`${t('appLog.detail.timeConsuming')} ${more.latency}${t('appLog.detail.second')}`}
>
{`${t('appLog.detail.timeConsuming')} ${more.latency}${t('appLog.detail.second')}`}
</div>
<div
className='shrink-0 truncate max-w-[33.3%]'
title={`${t('appLog.detail.tokenCost')} ${formatNumber(more.tokens)}`}
>
{`${t('appLog.detail.tokenCost')} ${formatNumber(more.tokens)}`}
</div>
<div className='shrink-0 mx-2'>·</div>
<div
className='shrink-0 truncate max-w-[33.3%]'
title={more.time}
>
{more.time}
</div>
</>
)
}
</div>
)
}
export default More

View File

@@ -0,0 +1,54 @@
import type { FC } from 'react'
import type { ChatItem } from '../../types'
import { useCurrentAnswerIsResponsing } from '../hooks'
import { useChatContext } from '../context'
import CopyBtn from '@/app/components/app/chat/copy-btn'
import { MessageFast } from '@/app/components/base/icons/src/vender/solid/communication'
import AudioBtn from '@/app/components/base/audio-btn'
type OperationProps = {
item: ChatItem
}
const Operation: FC<OperationProps> = ({
item,
}) => {
const { config } = useChatContext()
const responsing = useCurrentAnswerIsResponsing(item.id)
const {
isOpeningStatement,
content,
annotation,
} = item
return (
<div className='absolute top-[-14px] right-[-14px] flex justify-end gap-1'>
{
!isOpeningStatement && !responsing && (
<CopyBtn
value={content}
className='hidden group-hover:block'
/>
)
}
{!isOpeningStatement && config?.text_to_speech && (
<AudioBtn
value={content}
className='hidden group-hover:block'
/>
)}
{
annotation?.id && (
<div
className='relative box-border flex items-center justify-center h-7 w-7 p-0.5 rounded-lg bg-white cursor-pointer text-[#444CE7] shadow-md'
>
<div className='p-1 rounded-lg bg-[#EEF4FF] '>
<MessageFast className='w-4 h-4' />
</div>
</div>
)
}
</div>
)
}
export default Operation

View File

@@ -0,0 +1,35 @@
import type { FC } from 'react'
import type { ChatItem } from '../../types'
import { useChatContext } from '../context'
type SuggestedQuestionsProps = {
item: ChatItem
}
const SuggestedQuestions: FC<SuggestedQuestionsProps> = ({
item,
}) => {
const { onSend } = useChatContext()
const {
isOpeningStatement,
suggestedQuestions,
} = item
if (!isOpeningStatement || !suggestedQuestions?.length)
return null
return (
<div className='flex flex-wrap'>
{suggestedQuestions.map((question, index) => (
<div
key={index}
className='mt-1 mr-1 max-w-full last:mr-0 shrink-0 py-[5px] leading-[18px] items-center px-4 rounded-lg border border-gray-200 shadow-xs bg-white text-xs font-medium text-primary-600 cursor-pointer'
onClick={() => onSend?.(question)}
>
{question}
</div>),
)}
</div>
)
}
export default SuggestedQuestions

View File

@@ -0,0 +1,220 @@
import type { FC } from 'react'
import {
useRef,
useState,
} from 'react'
import { useContext } from 'use-context-selector'
import Recorder from 'js-audio-recorder'
import { useTranslation } from 'react-i18next'
import Textarea from 'rc-textarea'
import type {
EnableType,
OnSend,
VisionConfig,
} from '../types'
import { TransferMethod } from '../types'
import TooltipPlus from '@/app/components/base/tooltip-plus'
import { ToastContext } from '@/app/components/base/toast'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import VoiceInput from '@/app/components/base/voice-input'
import { Microphone01 } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
import { Microphone01 as Microphone01Solid } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
import { XCircle } from '@/app/components/base/icons/src/vender/solid/general'
import { Send03 } from '@/app/components/base/icons/src/vender/solid/communication'
import ChatImageUploader from '@/app/components/base/image-uploader/chat-image-uploader'
import ImageList from '@/app/components/base/image-uploader/image-list'
import {
useClipboardUploader,
useDraggableUploader,
useImageFiles,
} from '@/app/components/base/image-uploader/hooks'
type ChatInputProps = {
visionConfig?: VisionConfig
speechToTextConfig?: EnableType
onSend?: OnSend
}
const ChatInput: FC<ChatInputProps> = ({
visionConfig,
speechToTextConfig,
onSend,
}) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const [voiceInputShow, setVoiceInputShow] = useState(false)
const {
files,
onUpload,
onRemove,
onReUpload,
onImageLinkLoadError,
onImageLinkLoadSuccess,
onClear,
} = useImageFiles()
const { onPaste } = useClipboardUploader({ onUpload, visionConfig, files })
const { onDragEnter, onDragLeave, onDragOver, onDrop, isDragActive } = useDraggableUploader<HTMLTextAreaElement>({ onUpload, files, visionConfig })
const isUseInputMethod = useRef(false)
const [query, setQuery] = useState('')
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value
setQuery(value)
}
const handleSend = () => {
if (onSend) {
onSend(query, files.filter(file => file.progress !== -1).map(fileItem => ({
type: 'image',
transfer_method: fileItem.type,
url: fileItem.url,
upload_file_id: fileItem.fileId,
})))
setQuery('')
}
if (!files.find(item => item.type === TransferMethod.local_file && !item.fileId)) {
if (files.length)
onClear()
}
}
const handleKeyUp = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.code === 'Enter') {
e.preventDefault()
// prevent send message when using input method enter
if (!e.shiftKey && !isUseInputMethod.current)
handleSend()
}
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
isUseInputMethod.current = e.nativeEvent.isComposing
if (e.code === 'Enter' && !e.shiftKey) {
setQuery(query.replace(/\n$/, ''))
e.preventDefault()
}
}
const logError = (message: string) => {
notify({ type: 'error', message, duration: 3000 })
}
const handleVoiceInputShow = () => {
(Recorder as any).getPermission().then(() => {
setVoiceInputShow(true)
}, () => {
logError(t('common.voiceInput.notAllow'))
})
}
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const sendBtn = (
<div
className='group flex items-center justify-center w-8 h-8 rounded-lg hover:bg-[#EBF5FF] cursor-pointer'
onClick={handleSend}
>
<Send03
className={`
w-5 h-5 text-gray-300 group-hover:text-primary-600
${!!query.trim() && 'text-primary-600'}
`}
/>
</div>
)
return (
<div
className={`
relative p-[5.5px] max-h-[150px] bg-white border-[1.5px] border-gray-200 rounded-xl overflow-y-auto
${isDragActive && 'border-primary-600'}
`}
>
{
visionConfig?.enabled && (
<>
<div className='absolute bottom-2 left-2 flex items-center'>
<ChatImageUploader
settings={visionConfig}
onUpload={onUpload}
disabled={files.length >= visionConfig.number_limits}
/>
<div className='mx-1 w-[1px] h-4 bg-black/5' />
</div>
<div className='pl-[52px]'>
<ImageList
list={files}
onRemove={onRemove}
onReUpload={onReUpload}
onImageLinkLoadSuccess={onImageLinkLoadSuccess}
onImageLinkLoadError={onImageLinkLoadError}
/>
</div>
</>
)
}
<Textarea
className={`
block w-full px-2 pr-[118px] py-[7px] leading-5 max-h-none text-sm text-gray-700 outline-none appearance-none resize-none
${visionConfig?.enabled && 'pl-12'}
`}
value={query}
onChange={handleContentChange}
onKeyUp={handleKeyUp}
onKeyDown={handleKeyDown}
onPaste={onPaste}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
autoSize
/>
<div className='absolute bottom-[7px] right-2 flex items-center h-8'>
<div className='flex items-center px-1 h-5 rounded-md bg-gray-100 text-xs font-medium text-gray-500'>
{query.trim().length}
</div>
{
query
? (
<div className='flex justify-center items-center ml-2 w-8 h-8 cursor-pointer hover:bg-gray-100 rounded-lg' onClick={() => setQuery('')}>
<XCircle className='w-4 h-4 text-[#98A2B3]' />
</div>
)
: speechToTextConfig?.enabled
? (
<div
className='group flex justify-center items-center ml-2 w-8 h-8 hover:bg-primary-50 rounded-lg cursor-pointer'
onClick={handleVoiceInputShow}
>
<Microphone01 className='block w-4 h-4 text-gray-500 group-hover:hidden' />
<Microphone01Solid className='hidden w-4 h-4 text-primary-600 group-hover:block' />
</div>
)
: null
}
<div className='mx-2 w-[1px] h-4 bg-black opacity-5' />
{isMobile
? sendBtn
: (
<TooltipPlus
popupContent={
<div>
<div>{t('common.operation.send')} Enter</div>
<div>{t('common.operation.lineBreak')} Shift Enter</div>
</div>
}
>
{sendBtn}
</TooltipPlus>
)}
</div>
{
voiceInputShow && (
<VoiceInput
onCancel={() => setVoiceInputShow(false)}
onConverted={text => setQuery(text)}
/>
)
}
</div>
)
}
export default ChatInput

View File

@@ -0,0 +1,60 @@
'use client'
import type { ReactNode } from 'react'
import { createContext, useContext } from 'use-context-selector'
import type {
ChatConfig,
ChatItem,
OnSend,
} from '../types'
import type { Emoji } from '@/app/components/tools/types'
export type ChatContextValue = {
config?: ChatConfig
isResponsing?: boolean
chatList: ChatItem[]
showPromptLog?: boolean
questionIcon?: ReactNode
answerIcon?: ReactNode
allToolIcons?: Record<string, string | Emoji>
onSend?: OnSend
}
const ChatContext = createContext<ChatContextValue>({
chatList: [],
})
type ChatContextProviderProps = {
children: ReactNode
} & ChatContextValue
export const ChatContextProvider = ({
children,
config,
isResponsing,
chatList,
showPromptLog,
questionIcon,
answerIcon,
allToolIcons,
onSend,
}: ChatContextProviderProps) => {
return (
<ChatContext.Provider value={{
config,
isResponsing,
chatList: chatList || [],
showPromptLog,
questionIcon,
answerIcon,
allToolIcons,
onSend,
}}>
{children}
</ChatContext.Provider>
)
}
export const useChatContext = () => useContext(ChatContext)
export default ChatContext

View File

@@ -0,0 +1,395 @@
import {
useEffect,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { produce } from 'immer'
import { useGetState } from 'ahooks'
import dayjs from 'dayjs'
import type {
ChatConfig,
ChatItem,
Inputs,
PromptVariable,
VisionFile,
} from '../types'
import { useChatContext } from './context'
import { TransferMethod } from '@/types/app'
import { useToastContext } from '@/app/components/base/toast'
import { ssePost } from '@/service/base'
import { replaceStringWithValues } from '@/app/components/app/configuration/prompt-value-panel'
type GetAbortController = (abortController: AbortController) => void
type SendCallback = {
onGetConvesationMessages: (conversationId: string, getAbortController: GetAbortController) => Promise<any>
onGetSuggestedQuestions?: (responseItemId: string, getAbortController: GetAbortController) => Promise<any>
}
export const useChat = (
config: ChatConfig,
promptVariablesConfig?: {
inputs: Inputs
promptVariables: PromptVariable[]
},
prevChatList?: ChatItem[],
stopChat?: (taskId: string) => void,
) => {
const { t } = useTranslation()
const { notify } = useToastContext()
const connversationId = useRef('')
const hasStopResponded = useRef(false)
const [isResponsing, setIsResponsing] = useState(false)
const [chatList, setChatList, getChatList] = useGetState<ChatItem[]>(prevChatList || [])
const [taskId, setTaskId] = useState('')
const [suggestedQuestions, setSuggestQuestions] = useState<string[]>([])
const [abortController, setAbortController] = useState<AbortController | null>(null)
const [conversationMessagesAbortController, setConversationMessagesAbortController] = useState<AbortController | null>(null)
const [suggestedQuestionsAbortController, setSuggestedQuestionsAbortController] = useState<AbortController | null>(null)
const getIntroduction = (str: string) => {
return replaceStringWithValues(str, promptVariablesConfig?.promptVariables || [], promptVariablesConfig?.inputs || {})
}
useEffect(() => {
if (config.opening_statement && !chatList.some(item => !item.isAnswer)) {
setChatList([{
id: `${Date.now()}`,
content: getIntroduction(config.opening_statement),
isAnswer: true,
isOpeningStatement: true,
suggestedQuestions: config.suggested_questions,
}])
}
}, [config.opening_statement, config.suggested_questions, promptVariablesConfig?.inputs])
const handleStop = () => {
if (stopChat && taskId)
stopChat(taskId)
if (abortController)
abortController.abort()
if (conversationMessagesAbortController)
conversationMessagesAbortController.abort()
if (suggestedQuestionsAbortController)
suggestedQuestionsAbortController.abort()
}
const handleRestart = () => {
handleStop()
hasStopResponded.current = true
connversationId.current = ''
setIsResponsing(false)
setChatList(config.opening_statement
? [{
id: `${Date.now()}`,
content: config.opening_statement,
isAnswer: true,
isOpeningStatement: true,
suggestedQuestions: config.suggested_questions,
}]
: [])
setSuggestQuestions([])
}
const handleSend = async (
url: string,
data: any,
{
onGetConvesationMessages,
onGetSuggestedQuestions,
}: SendCallback,
) => {
setSuggestQuestions([])
if (isResponsing) {
notify({ type: 'info', message: t('appDebug.errorMessage.waitForResponse') })
return false
}
if (promptVariablesConfig?.inputs && promptVariablesConfig?.promptVariables) {
const {
promptVariables,
inputs,
} = promptVariablesConfig
let hasEmptyInput = ''
const requiredVars = promptVariables.filter(({ key, name, required, type }) => {
if (type === 'api')
return false
const res = (!key || !key.trim()) || (!name || !name.trim()) || (required || required === undefined || required === null)
return res
})
if (requiredVars?.length) {
requiredVars.forEach(({ key, name }) => {
if (hasEmptyInput)
return
if (!inputs[key])
hasEmptyInput = name
})
}
if (hasEmptyInput) {
notify({ type: 'error', message: t('appDebug.errorMessage.valueOfVarRequired', { key: hasEmptyInput }) })
return false
}
}
const updateCurrentQA = ({
responseItem,
questionId,
placeholderAnswerId,
questionItem,
}: {
responseItem: ChatItem
questionId: string
placeholderAnswerId: string
questionItem: ChatItem
}) => {
// closesure new list is outdated.
const newListWithAnswer = produce(
getChatList().filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId),
(draft) => {
if (!draft.find(item => item.id === questionId))
draft.push({ ...questionItem })
draft.push({ ...responseItem })
})
setChatList(newListWithAnswer)
}
const questionId = `question-${Date.now()}`
const questionItem = {
id: questionId,
content: data.query,
isAnswer: false,
message_files: data.files,
}
const placeholderAnswerId = `answer-placeholder-${Date.now()}`
const placeholderAnswerItem = {
id: placeholderAnswerId,
content: '',
isAnswer: true,
}
const newList = [...getChatList(), questionItem, placeholderAnswerItem]
setChatList(newList)
// answer
const responseItem: ChatItem = {
id: `${Date.now()}`,
content: '',
agent_thoughts: [],
message_files: [],
isAnswer: true,
}
setIsResponsing(true)
hasStopResponded.current = false
const bodyParams = {
response_mode: 'streaming',
conversation_id: connversationId.current,
...data,
}
if (bodyParams?.files?.length) {
bodyParams.files = bodyParams.files.map((item: VisionFile) => {
if (item.transfer_method === TransferMethod.local_file) {
return {
...item,
url: '',
}
}
return item
})
}
let isAgentMode = false
let hasSetResponseId = false
ssePost(
url,
{
body: bodyParams,
},
{
getAbortController: (abortController) => {
setAbortController(abortController)
},
onData: (message: string, isFirstMessage: boolean, { conversationId: newConversationId, messageId, taskId }: any) => {
if (!isAgentMode) {
responseItem.content = responseItem.content + message
}
else {
const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1]
if (lastThought)
lastThought.thought = lastThought.thought + message // need immer setAutoFreeze
}
if (messageId && !hasSetResponseId) {
responseItem.id = messageId
hasSetResponseId = true
}
if (isFirstMessage && newConversationId)
connversationId.current = newConversationId
setTaskId(taskId)
if (messageId)
responseItem.id = messageId
updateCurrentQA({
responseItem,
questionId,
placeholderAnswerId,
questionItem,
})
},
async onCompleted(hasError?: boolean) {
setIsResponsing(false)
if (hasError)
return
if (connversationId.current) {
const { data }: any = await onGetConvesationMessages(
connversationId.current,
newAbortController => setConversationMessagesAbortController(newAbortController),
)
const newResponseItem = data.find((item: any) => item.id === responseItem.id)
if (!newResponseItem)
return
setChatList(produce(getChatList(), (draft) => {
const index = draft.findIndex(item => item.id === responseItem.id)
if (index !== -1) {
const requestion = draft[index - 1]
draft[index - 1] = {
...requestion,
log: newResponseItem.message,
}
draft[index] = {
...draft[index],
more: {
time: dayjs.unix(newResponseItem.created_at).format('hh:mm A'),
tokens: newResponseItem.answer_tokens + newResponseItem.message_tokens,
latency: newResponseItem.provider_response_latency.toFixed(2),
},
}
}
}))
}
if (config.suggested_questions_after_answer?.enabled && !hasStopResponded.current && onGetSuggestedQuestions) {
const { data }: any = await onGetSuggestedQuestions(
responseItem.id,
newAbortController => setSuggestedQuestionsAbortController(newAbortController),
)
setSuggestQuestions(data)
}
},
onFile(file) {
const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1]
if (lastThought)
responseItem.agent_thoughts![responseItem.agent_thoughts!.length - 1].message_files = [...(lastThought as any).message_files, file]
updateCurrentQA({
responseItem,
questionId,
placeholderAnswerId,
questionItem,
})
},
onThought(thought) {
isAgentMode = true
const response = responseItem as any
if (thought.message_id && !hasSetResponseId)
response.id = thought.message_id
if (response.agent_thoughts.length === 0) {
response.agent_thoughts.push(thought)
}
else {
const lastThought = response.agent_thoughts[response.agent_thoughts.length - 1]
// thought changed but still the same thought, so update.
if (lastThought.id === thought.id) {
thought.thought = lastThought.thought
thought.message_files = lastThought.message_files
responseItem.agent_thoughts![response.agent_thoughts.length - 1] = thought
}
else {
responseItem.agent_thoughts!.push(thought)
}
}
updateCurrentQA({
responseItem,
questionId,
placeholderAnswerId,
questionItem,
})
},
onMessageEnd: (messageEnd) => {
if (messageEnd.metadata?.annotation_reply) {
responseItem.id = messageEnd.id
responseItem.annotation = ({
id: messageEnd.metadata.annotation_reply.id,
authorName: messageEnd.metadata.annotation_reply.account.name,
})
const newListWithAnswer = produce(
getChatList().filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId),
(draft) => {
if (!draft.find(item => item.id === questionId))
draft.push({ ...questionItem })
draft.push({
...responseItem,
})
})
setChatList(newListWithAnswer)
return
}
responseItem.citation = messageEnd.metadata?.retriever_resources || []
const newListWithAnswer = produce(
getChatList().filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId),
(draft) => {
if (!draft.find(item => item.id === questionId))
draft.push({ ...questionItem })
draft.push({ ...responseItem })
})
setChatList(newListWithAnswer)
},
onMessageReplace: (messageReplace) => {
responseItem.content = messageReplace.answer
},
onError() {
setIsResponsing(false)
// role back placeholder answer
setChatList(produce(getChatList(), (draft) => {
draft.splice(draft.findIndex(item => item.id === placeholderAnswerId), 1)
}))
},
})
return true
}
return {
chatList,
getChatList,
setChatList,
conversationId: connversationId.current,
isResponsing,
setIsResponsing,
handleSend,
suggestedQuestions,
handleRestart,
handleStop,
}
}
export const useCurrentAnswerIsResponsing = (answerId: string) => {
const {
isResponsing,
chatList,
} = useChatContext()
const isLast = answerId === chatList[chatList.length - 1]?.id
return isLast && isResponsing
}

View File

@@ -0,0 +1,129 @@
import type {
FC,
ReactNode,
} from 'react'
import {
memo,
useRef,
} from 'react'
import { useThrottleEffect } from 'ahooks'
import type {
ChatConfig,
ChatItem,
OnSend,
} from '../types'
import Question from './question'
import Answer from './answer'
import ChatInput from './chat-input'
import TryToAsk from './try-to-ask'
import { ChatContextProvider } from './context'
import type { Emoji } from '@/app/components/tools/types'
export type ChatProps = {
config: ChatConfig
onSend?: OnSend
chatList: ChatItem[]
isResponsing: boolean
noChatInput?: boolean
chatContainerclassName?: string
chatFooterClassName?: string
suggestedQuestions?: string[]
showPromptLog?: boolean
questionIcon?: ReactNode
answerIcon?: ReactNode
allToolIcons?: Record<string, string | Emoji>
}
const Chat: FC<ChatProps> = ({
config,
onSend,
chatList,
isResponsing,
noChatInput,
chatContainerclassName,
chatFooterClassName,
suggestedQuestions,
showPromptLog,
questionIcon,
answerIcon,
allToolIcons,
}) => {
const ref = useRef<HTMLDivElement>(null)
const chatFooterRef = useRef<HTMLDivElement>(null)
useThrottleEffect(() => {
if (ref.current)
ref.current.scrollTop = ref.current.scrollHeight
}, [chatList], { wait: 500 })
const hasTryToAsk = config.suggested_questions_after_answer?.enabled && !!suggestedQuestions?.length && onSend
return (
<ChatContextProvider
config={config}
chatList={chatList}
isResponsing={isResponsing}
showPromptLog={showPromptLog}
questionIcon={questionIcon}
answerIcon={answerIcon}
allToolIcons={allToolIcons}
onSend={onSend}
>
<div className='relative h-full'>
<div
ref={ref}
className={`relative h-full overflow-y-auto ${chatContainerclassName}`}
>
{
chatList.map((item) => {
if (item.isAnswer) {
return (
<Answer
key={item.id}
item={item}
/>
)
}
return (
<Question
key={item.id}
item={item}
/>
)
})
}
{
(hasTryToAsk || !noChatInput) && (
<div
className={`sticky bottom-0 w-full backdrop-blur-[20px] ${chatFooterClassName}`}
ref={chatFooterRef}
style={{
background: 'linear-gradient(0deg, #FFF 0%, rgba(255, 255, 255, 0.40) 100%)',
}}
>
{
hasTryToAsk && (
<TryToAsk
suggestedQuestions={suggestedQuestions}
onSend={onSend}
/>
)
}
{
!noChatInput && (
<ChatInput
visionConfig={config?.file_upload?.image}
speechToTextConfig={config.speech_to_text}
onSend={onSend}
/>
)
}
</div>
)
}
</div>
</div>
</ChatContextProvider>
)
}
export default memo(Chat)

View File

@@ -0,0 +1,62 @@
import type { FC } from 'react'
import { useRef } from 'react'
import type { ChatItem } from '../types'
import { useChatContext } from './context'
import { QuestionTriangle } from '@/app/components/base/icons/src/vender/solid/general'
import { User } from '@/app/components/base/icons/src/public/avatar'
import Log from '@/app/components/app/chat/log'
import { Markdown } from '@/app/components/base/markdown'
import ImageGallery from '@/app/components/base/image-gallery'
type QuestionProps = {
item: ChatItem
}
const Question: FC<QuestionProps> = ({
item,
}) => {
const ref = useRef(null)
const {
showPromptLog,
isResponsing,
questionIcon,
} = useChatContext()
const {
content,
message_files,
} = item
const imgSrcs = message_files?.length ? message_files.map(item => item.url) : []
return (
<div className='flex justify-end mb-2 last:mb-0 pl-10' ref={ref}>
<div className='group relative mr-4'>
<QuestionTriangle className='absolute -right-2 top-0 w-2 h-3 text-[#D1E9FF]/50' />
{
showPromptLog && !isResponsing && (
<Log log={item.log!} containerRef={ref} />
)
}
<div className='px-4 py-3 bg-[#D1E9FF]/50 rounded-b-2xl rounded-tl-2xl text-sm text-gray-900'>
{
!!imgSrcs.length && (
<ImageGallery srcs={imgSrcs} />
)
}
<Markdown content={content} />
</div>
<div className='mt-1 h-[18px]' />
</div>
<div className='shrink-0 w-10 h-10'>
{
questionIcon || (
<div className='w-full h-full rounded-full border-[0.5px] border-black/5'>
<User className='w-full h-full' />
</div>
)
}
</div>
</div>
)
}
export default Question

View File

@@ -0,0 +1,54 @@
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import type { OnSend } from '../types'
import { Star04 } from '@/app/components/base/icons/src/vender/solid/shapes'
import Button from '@/app/components/base/button'
type TryToAskProps = {
suggestedQuestions: string[]
onSend: OnSend
}
const TryToAsk: FC<TryToAskProps> = ({
suggestedQuestions,
onSend,
}) => {
const { t } = useTranslation()
return (
<div>
<div className='flex items-center mb-2.5 py-2'>
<div
className='grow h-[1px]'
style={{
background: 'linear-gradient(270deg, #F3F4F6 0%, rgba(243, 244, 246, 0) 100%)',
}}
/>
<div className='shrink-0 flex items-center px-3 text-gray-500'>
<Star04 className='mr-1 w-2.5 h-2.5' />
<span className='text-xs text-gray-500 font-medium'>{t('appDebug.feature.suggestedQuestionsAfterAnswer.tryToAsk')}</span>
</div>
<div
className='grow h-[1px]'
style={{
background: 'linear-gradient(270deg, rgba(243, 244, 246, 0) 0%, #F3F4F6 100%)',
}}
/>
</div>
<div className='flex flex-wrap'>
{
suggestedQuestions.map((suggestQuestion, index) => (
<Button
key={index}
className='mb-2 mr-2 last:mr-0 px-3 py-[5px] bg-white text-primary-600 text-xs font-medium'
onClick={() => onSend(suggestQuestion)}
>
{suggestQuestion}
</Button>
))
}
</div>
</div>
)
}
export default TryToAsk

View File

@@ -0,0 +1,48 @@
import type {
ModelConfig,
VisionFile,
VisionSettings,
} from '@/types/app'
import type { IChatItem } from '@/app/components/app/chat/type'
export type { VisionFile } from '@/types/app'
export { TransferMethod } from '@/types/app'
export type {
Inputs,
PromptVariable,
} from '@/models/debug'
export type UserInputForm = {
default: string
label: string
required: boolean
variable: string
}
export type UserInputFormTextInput = {
'text-inpput': UserInputForm & {
max_length: number
}
}
export type UserInputFormSelect = {
'select': UserInputForm & {
options: string[]
}
}
export type UserInputFormParagraph = {
'paragraph': UserInputForm
}
export type VisionConfig = VisionSettings
export type EnableType = {
enabled: boolean
}
export type ChatConfig = Omit<ModelConfig, 'model'>
export type ChatItem = IChatItem
export type OnSend = (message: string, files?: VisionFile[]) => void