| @@ -21,6 +21,7 @@ import { useFeatures } from '@/app/components/base/features/hooks' | |||
| import { getLastAnswer, isValidGeneratedAnswer } from '@/app/components/base/chat/utils' | |||
| import type { InputForm } from '@/app/components/base/chat/chat/type' | |||
| import { canFindTool } from '@/utils' | |||
| import type { FileEntity } from '@/app/components/base/file-uploader/types' | |||
| type DebugWithSingleModelProps = { | |||
| checkCanSend?: () => boolean | |||
| @@ -125,10 +126,14 @@ const DebugWithSingleModel = ( | |||
| ) | |||
| }, [appId, chatList, checkCanSend, completionParams, config, handleSend, inputs, modelConfig.mode, modelConfig.model_id, modelConfig.provider, textGenerationModelList]) | |||
| const doRegenerate = useCallback((chatItem: ChatItemInTree) => { | |||
| const question = chatList.find(item => item.id === chatItem.parentMessageId)! | |||
| const doRegenerate = useCallback((chatItem: ChatItemInTree, editedQuestion?: { message: string, files?: FileEntity[] }) => { | |||
| const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)! | |||
| const parentAnswer = chatList.find(item => item.id === question.parentMessageId) | |||
| doSend(question.content, question.message_files, true, isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null) | |||
| doSend(editedQuestion ? editedQuestion.message : question.content, | |||
| editedQuestion ? editedQuestion.files : question.message_files, | |||
| true, | |||
| isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null, | |||
| ) | |||
| }, [chatList, doSend]) | |||
| const allToolIcons = useMemo(() => { | |||
| @@ -22,6 +22,7 @@ import AnswerIcon from '@/app/components/base/answer-icon' | |||
| import SuggestedQuestions from '@/app/components/base/chat/chat/answer/suggested-questions' | |||
| import { Markdown } from '@/app/components/base/markdown' | |||
| import cn from '@/utils/classnames' | |||
| import type { FileEntity } from '../../file-uploader/types' | |||
| const ChatWrapper = () => { | |||
| const { | |||
| @@ -139,22 +140,16 @@ const ChatWrapper = () => { | |||
| isPublicAPI: !isInstalledApp, | |||
| }, | |||
| ) | |||
| }, [ | |||
| chatList, | |||
| handleNewConversationCompleted, | |||
| handleSend, | |||
| currentConversationId, | |||
| currentConversationItem, | |||
| currentConversationInputs, | |||
| newConversationInputs, | |||
| isInstalledApp, | |||
| appId, | |||
| ]) | |||
| }, [chatList, handleNewConversationCompleted, handleSend, currentConversationId, currentConversationInputs, newConversationInputs, isInstalledApp, appId]) | |||
| const doRegenerate = useCallback((chatItem: ChatItemInTree) => { | |||
| const question = chatList.find(item => item.id === chatItem.parentMessageId)! | |||
| const doRegenerate = useCallback((chatItem: ChatItemInTree, editedQuestion?: { message: string, files?: FileEntity[] }) => { | |||
| const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)! | |||
| const parentAnswer = chatList.find(item => item.id === question.parentMessageId) | |||
| doSend(question.content, question.message_files, true, isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null) | |||
| doSend(editedQuestion ? editedQuestion.message : question.content, | |||
| editedQuestion ? editedQuestion.files : question.message_files, | |||
| true, | |||
| isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null, | |||
| ) | |||
| }, [chatList, doSend]) | |||
| const messageList = useMemo(() => { | |||
| @@ -2,7 +2,7 @@ import type { | |||
| FC, | |||
| ReactNode, | |||
| } from 'react' | |||
| import { memo, useEffect, useRef, useState } from 'react' | |||
| import { memo, useCallback, useEffect, useRef, useState } from 'react' | |||
| import { useTranslation } from 'react-i18next' | |||
| import type { | |||
| ChatConfig, | |||
| @@ -19,9 +19,9 @@ import Citation from '@/app/components/base/chat/chat/citation' | |||
| import { EditTitle } from '@/app/components/app/annotation/edit-annotation-modal/edit-item' | |||
| import type { AppData } from '@/models/share' | |||
| import AnswerIcon from '@/app/components/base/answer-icon' | |||
| import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows' | |||
| import cn from '@/utils/classnames' | |||
| import { FileList } from '@/app/components/base/file-uploader' | |||
| import ContentSwitch from '../content-switch' | |||
| type AnswerProps = { | |||
| item: ChatItem | |||
| @@ -100,12 +100,19 @@ const Answer: FC<AnswerProps> = ({ | |||
| } | |||
| }, []) | |||
| const handleSwitchSibling = useCallback((direction: 'prev' | 'next') => { | |||
| if (direction === 'prev') | |||
| item.prevSibling && switchSibling?.(item.prevSibling) | |||
| else | |||
| item.nextSibling && switchSibling?.(item.nextSibling) | |||
| }, [switchSibling, item.prevSibling, item.nextSibling]) | |||
| return ( | |||
| <div className='mb-2 flex last:mb-0'> | |||
| <div className='relative h-10 w-10 shrink-0'> | |||
| {answerIcon || <AnswerIcon />} | |||
| {responding && ( | |||
| <div className='absolute -left-[3px] -top-[3px] flex h-4 w-4 items-center rounded-full border-[0.5px] border-divider-subtle bg-background-section-burn pl-[6px] shadow-xs'> | |||
| <div className='absolute left-[-3px] top-[-3px] flex h-4 w-4 items-center rounded-full border-[0.5px] border-divider-subtle bg-background-section-burn pl-[6px] shadow-xs'> | |||
| <LoadingAnim type='avatar' /> | |||
| </div> | |||
| )} | |||
| @@ -208,23 +215,17 @@ const Answer: FC<AnswerProps> = ({ | |||
| <Citation data={citation} showHitInfo={config?.supportCitationHitInfo} /> | |||
| ) | |||
| } | |||
| {item.siblingCount && item.siblingCount > 1 && item.siblingIndex !== undefined && <div className="flex items-center justify-center pt-3.5 text-sm"> | |||
| <button | |||
| className={`${item.prevSibling ? 'opacity-100' : 'opacity-30'}`} | |||
| disabled={!item.prevSibling} | |||
| onClick={() => item.prevSibling && switchSibling?.(item.prevSibling)} | |||
| > | |||
| <ChevronRight className="h-[14px] w-[14px] rotate-180 text-text-primary" /> | |||
| </button> | |||
| <span className="px-2 text-xs text-text-primary">{item.siblingIndex + 1} / {item.siblingCount}</span> | |||
| <button | |||
| className={`${item.nextSibling ? 'opacity-100' : 'opacity-30'}`} | |||
| disabled={!item.nextSibling} | |||
| onClick={() => item.nextSibling && switchSibling?.(item.nextSibling)} | |||
| > | |||
| <ChevronRight className="h-[14px] w-[14px] text-text-primary" /> | |||
| </button> | |||
| </div>} | |||
| { | |||
| item.siblingCount && item.siblingCount > 1 && item.siblingIndex !== undefined && ( | |||
| <ContentSwitch | |||
| count={item.siblingCount} | |||
| currentIndex={item.siblingIndex} | |||
| prevDisabled={!item.prevSibling} | |||
| nextDisabled={!item.nextSibling} | |||
| switchSibling={handleSwitchSibling} | |||
| /> | |||
| ) | |||
| } | |||
| </div> | |||
| </div> | |||
| <More more={more} /> | |||
| @@ -0,0 +1,39 @@ | |||
| import { ChevronRight } from '../../icons/src/vender/line/arrows' | |||
| export default function ContentSwitch({ | |||
| count, | |||
| currentIndex, | |||
| prevDisabled, | |||
| nextDisabled, | |||
| switchSibling, | |||
| }: { | |||
| count?: number | |||
| currentIndex?: number | |||
| prevDisabled: boolean | |||
| nextDisabled: boolean | |||
| switchSibling: (direction: 'prev' | 'next') => void | |||
| }) { | |||
| return ( | |||
| count && count > 1 && currentIndex !== undefined && ( | |||
| <div className="flex items-center justify-center pt-3.5 text-sm"> | |||
| <button | |||
| className={`${prevDisabled ? 'opacity-30' : 'opacity-100'}`} | |||
| disabled={prevDisabled} | |||
| onClick={() => !prevDisabled && switchSibling('prev')} | |||
| > | |||
| <ChevronRight className="h-[14px] w-[14px] rotate-180 text-text-primary" /> | |||
| </button> | |||
| <span className="px-2 text-xs text-text-primary"> | |||
| {currentIndex + 1} / {count} | |||
| </span> | |||
| <button | |||
| className={`${nextDisabled ? 'opacity-30' : 'opacity-100'}`} | |||
| disabled={nextDisabled} | |||
| onClick={() => !nextDisabled && switchSibling('next')} | |||
| > | |||
| <ChevronRight className="h-[14px] w-[14px] text-text-primary" /> | |||
| </button> | |||
| </div> | |||
| ) | |||
| ) | |||
| } | |||
| @@ -208,7 +208,7 @@ const Chat: FC<ChatProps> = ({ | |||
| useEffect(() => { | |||
| if (!sidebarCollapseState) | |||
| setTimeout(() => handleWindowResize(), 200) | |||
| }, [sidebarCollapseState]) | |||
| }, [handleWindowResize, sidebarCollapseState]) | |||
| const hasTryToAsk = config?.suggested_questions_after_answer?.enabled && !!suggestedQuestions?.length && onSend | |||
| @@ -265,6 +265,7 @@ const Chat: FC<ChatProps> = ({ | |||
| item={item} | |||
| questionIcon={questionIcon} | |||
| theme={themeBuilder?.theme} | |||
| switchSibling={switchSibling} | |||
| /> | |||
| ) | |||
| }) | |||
| @@ -4,46 +4,137 @@ import type { | |||
| } from 'react' | |||
| import { | |||
| memo, | |||
| useCallback, | |||
| useState, | |||
| } from 'react' | |||
| import type { ChatItem } from '../types' | |||
| import type { Theme } from '../embedded-chatbot/theme/theme-context' | |||
| import { CssTransform } from '../embedded-chatbot/theme/utils' | |||
| import ContentSwitch from './content-switch' | |||
| import { User } from '@/app/components/base/icons/src/public/avatar' | |||
| import { Markdown } from '@/app/components/base/markdown' | |||
| import { FileList } from '@/app/components/base/file-uploader' | |||
| import ActionButton from '../../action-button' | |||
| import { RiClipboardLine, RiEditLine } from '@remixicon/react' | |||
| import Toast from '../../toast' | |||
| import copy from 'copy-to-clipboard' | |||
| import { useTranslation } from 'react-i18next' | |||
| import cn from '@/utils/classnames' | |||
| import Textarea from 'react-textarea-autosize' | |||
| import Button from '../../button' | |||
| import { useChatContext } from './context' | |||
| type QuestionProps = { | |||
| item: ChatItem | |||
| questionIcon?: ReactNode | |||
| theme: Theme | null | undefined | |||
| switchSibling?: (siblingMessageId: string) => void | |||
| } | |||
| const Question: FC<QuestionProps> = ({ | |||
| item, | |||
| questionIcon, | |||
| theme, | |||
| switchSibling, | |||
| }) => { | |||
| const { t } = useTranslation() | |||
| const { | |||
| content, | |||
| message_files, | |||
| } = item | |||
| const { | |||
| onRegenerate, | |||
| } = useChatContext() | |||
| const [isEditing, setIsEditing] = useState(false) | |||
| const [editedContent, setEditedContent] = useState(content) | |||
| const handleEdit = useCallback(() => { | |||
| setIsEditing(true) | |||
| setEditedContent(content) | |||
| }, [content]) | |||
| const handleResend = useCallback(() => { | |||
| setIsEditing(false) | |||
| onRegenerate?.(item, { message: editedContent, files: message_files }) | |||
| }, [editedContent, message_files, item, onRegenerate]) | |||
| const handleCancelEditing = useCallback(() => { | |||
| setIsEditing(false) | |||
| setEditedContent(content) | |||
| }, [content]) | |||
| const handleSwitchSibling = useCallback((direction: 'prev' | 'next') => { | |||
| if (direction === 'prev') | |||
| item.prevSibling && switchSibling?.(item.prevSibling) | |||
| else | |||
| item.nextSibling && switchSibling?.(item.nextSibling) | |||
| }, [switchSibling, item.prevSibling, item.nextSibling]) | |||
| return ( | |||
| <div className='mb-2 flex justify-end pl-14 last:mb-0'> | |||
| <div className='group relative mr-4 max-w-full'> | |||
| <div className={cn('group relative mr-4 flex max-w-full items-start', isEditing && 'flex-1')}> | |||
| <div className={cn('mr-2 gap-1', isEditing ? 'hidden' : 'flex')}> | |||
| <div className=" | |||
| absolutegap-0.5 hidden rounded-[10px] border-[0.5px] border-components-actionbar-border | |||
| bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm group-hover:flex | |||
| "> | |||
| <ActionButton onClick={() => { | |||
| copy(content) | |||
| Toast.notify({ type: 'success', message: t('common.actionMsg.copySuccessfully') }) | |||
| }}> | |||
| <RiClipboardLine className='h-4 w-4' /> | |||
| </ActionButton> | |||
| <ActionButton onClick={handleEdit}> | |||
| <RiEditLine className='h-4 w-4' /> | |||
| </ActionButton> | |||
| </div> | |||
| </div> | |||
| <div | |||
| className='rounded-2xl bg-[#D1E9FF]/50 px-4 py-3 text-sm text-gray-900' | |||
| className='w-full rounded-2xl bg-[#D1E9FF]/50 px-4 py-3 text-sm text-gray-900' | |||
| style={theme?.chatBubbleColorStyle ? CssTransform(theme.chatBubbleColorStyle) : {}} | |||
| > | |||
| { | |||
| !!message_files?.length && ( | |||
| <FileList | |||
| className='mb-2' | |||
| files={message_files} | |||
| showDeleteAction={false} | |||
| showDownloadAction={true} | |||
| /> | |||
| ) | |||
| } | |||
| <Markdown content={content} /> | |||
| { !isEditing | |||
| ? <Markdown content={content} /> | |||
| : <div className=" | |||
| flex flex-col gap-2 rounded-xl | |||
| border border-components-chat-input-border bg-components-panel-bg-blur p-[9px] shadow-md | |||
| "> | |||
| <div className="max-h-[158px] overflow-y-auto overflow-x-hidden"> | |||
| <Textarea | |||
| className={cn( | |||
| 'body-lg-regular w-full p-1 leading-6 text-text-tertiary outline-none', | |||
| )} | |||
| autoFocus | |||
| minRows={1} | |||
| value={editedContent} | |||
| onChange={e => setEditedContent(e.target.value)} | |||
| /> | |||
| </div> | |||
| <div className="flex justify-end gap-2"> | |||
| <Button variant='ghost' onClick={handleCancelEditing}>{t('common.operation.cancel')}</Button> | |||
| <Button variant='primary' onClick={handleResend}>{t('common.chat.resend')}</Button> | |||
| </div> | |||
| </div> } | |||
| { !isEditing && <ContentSwitch | |||
| count={item.siblingCount} | |||
| currentIndex={item.siblingIndex} | |||
| prevDisabled={!item.prevSibling} | |||
| nextDisabled={!item.nextSibling} | |||
| switchSibling={handleSwitchSibling} | |||
| />} | |||
| </div> | |||
| <div className='mt-1 h-[18px]' /> | |||
| </div> | |||
| @@ -24,6 +24,7 @@ import AnswerIcon from '@/app/components/base/answer-icon' | |||
| import SuggestedQuestions from '@/app/components/base/chat/chat/answer/suggested-questions' | |||
| import { Markdown } from '@/app/components/base/markdown' | |||
| import cn from '@/utils/classnames' | |||
| import type { FileEntity } from '../../file-uploader/types' | |||
| const ChatWrapper = () => { | |||
| const { | |||
| @@ -140,10 +141,14 @@ const ChatWrapper = () => { | |||
| ) | |||
| }, [currentConversationId, currentConversationInputs, newConversationInputs, chatList, handleSend, isInstalledApp, appId, handleNewConversationCompleted]) | |||
| const doRegenerate = useCallback((chatItem: ChatItemInTree) => { | |||
| const question = chatList.find(item => item.id === chatItem.parentMessageId)! | |||
| const doRegenerate = useCallback((chatItem: ChatItemInTree, editedQuestion?: { message: string, files?: FileEntity[] }) => { | |||
| const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)! | |||
| const parentAnswer = chatList.find(item => item.id === question.parentMessageId) | |||
| doSend(question.content, question.message_files, true, isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null) | |||
| doSend(editedQuestion ? editedQuestion.message : question.content, | |||
| editedQuestion ? editedQuestion.files : question.message_files, | |||
| true, | |||
| isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null, | |||
| ) | |||
| }, [chatList, doSend]) | |||
| const messageList = useMemo(() => { | |||
| @@ -20,6 +20,7 @@ import { | |||
| } from '@/service/debug' | |||
| import { useStore as useAppStore } from '@/app/components/app/store' | |||
| import { getLastAnswer, isValidGeneratedAnswer } from '@/app/components/base/chat/utils' | |||
| import type { FileEntity } from '@/app/components/base/file-uploader/types' | |||
| type ChatWrapperProps = { | |||
| showConversationVariableModal: boolean | |||
| @@ -94,10 +95,14 @@ const ChatWrapper = ( | |||
| ) | |||
| }, [handleSend, workflowStore, conversationId, chatList, appDetail]) | |||
| const doRegenerate = useCallback((chatItem: ChatItemInTree) => { | |||
| const question = chatList.find(item => item.id === chatItem.parentMessageId)! | |||
| const doRegenerate = useCallback((chatItem: ChatItemInTree, editedQuestion?: { message: string, files?: FileEntity[] }) => { | |||
| const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)! | |||
| const parentAnswer = chatList.find(item => item.id === question.parentMessageId) | |||
| doSend(question.content, question.message_files, true, isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null) | |||
| doSend(editedQuestion ? editedQuestion.message : question.content, | |||
| editedQuestion ? editedQuestion.files : question.message_files, | |||
| true, | |||
| isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null, | |||
| ) | |||
| }, [chatList, doSend]) | |||
| useImperativeHandle(ref, () => { | |||
| @@ -562,6 +562,7 @@ const translation = { | |||
| inputPlaceholder: 'Talk to Bot', | |||
| thinking: 'Thinking...', | |||
| thought: 'Thought', | |||
| resend: 'Resend', | |||
| }, | |||
| promptEditor: { | |||
| placeholder: 'Write your prompt word here, enter \'{\' to insert a variable, enter \'/\' to insert a prompt content block', | |||
| @@ -562,6 +562,7 @@ const translation = { | |||
| inputPlaceholder: '和机器人聊天', | |||
| thinking: '深度思考中...', | |||
| thought: '已深度思考', | |||
| resend: '重新发送', | |||
| }, | |||
| promptEditor: { | |||
| placeholder: '在这里写你的提示词,输入\'{\' 插入变量、输入\'/\' 插入提示内容块', | |||