diff --git a/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx b/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx index bb60648adb..d439b00939 100644 --- a/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx +++ b/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx @@ -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(() => { diff --git a/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx b/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx index a23be569cc..63de13596f 100644 --- a/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx +++ b/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx @@ -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(() => { diff --git a/web/app/components/base/chat/chat/answer/index.tsx b/web/app/components/base/chat/chat/answer/index.tsx index 349bc7477e..3722556931 100644 --- a/web/app/components/base/chat/chat/answer/index.tsx +++ b/web/app/components/base/chat/chat/answer/index.tsx @@ -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 = ({ } }, []) + 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 (
{answerIcon || } {responding && ( -
+
)} @@ -208,23 +215,17 @@ const Answer: FC = ({ ) } - {item.siblingCount && item.siblingCount > 1 && item.siblingIndex !== undefined &&
- - {item.siblingIndex + 1} / {item.siblingCount} - -
} + { + item.siblingCount && item.siblingCount > 1 && item.siblingIndex !== undefined && ( + + ) + }
diff --git a/web/app/components/base/chat/chat/content-switch.tsx b/web/app/components/base/chat/chat/content-switch.tsx new file mode 100644 index 0000000000..cf428f4cb4 --- /dev/null +++ b/web/app/components/base/chat/chat/content-switch.tsx @@ -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 && ( +
+ + + {currentIndex + 1} / {count} + + +
+ ) + ) +} diff --git a/web/app/components/base/chat/chat/index.tsx b/web/app/components/base/chat/chat/index.tsx index fe38e405a1..27952fe468 100644 --- a/web/app/components/base/chat/chat/index.tsx +++ b/web/app/components/base/chat/chat/index.tsx @@ -208,7 +208,7 @@ const Chat: FC = ({ 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 = ({ item={item} questionIcon={questionIcon} theme={themeBuilder?.theme} + switchSibling={switchSibling} /> ) }) diff --git a/web/app/components/base/chat/chat/question.tsx b/web/app/components/base/chat/chat/question.tsx index 4a2518061a..af4d64964c 100644 --- a/web/app/components/base/chat/chat/question.tsx +++ b/web/app/components/base/chat/chat/question.tsx @@ -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 = ({ 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 (
-
+
+
+
+ { + copy(content) + Toast.notify({ type: 'success', message: t('common.actionMsg.copySuccessfully') }) + }}> + + + + + +
+
{ !!message_files?.length && ( ) } - + { !isEditing + ? + :
+
+