diff --git a/web/app/components/app/text-generate/item/content-section.tsx b/web/app/components/app/text-generate/item/content-section.tsx new file mode 100644 index 0000000000..eb3b43d1fb --- /dev/null +++ b/web/app/components/app/text-generate/item/content-section.tsx @@ -0,0 +1,112 @@ +import type { FC } from 'react' +import type { TFunction } from 'i18next' +import { RiPlayList2Line } from '@remixicon/react' +import WorkflowProcessItem from '@/app/components/base/chat/chat/answer/workflow-process' +import type { WorkflowProcess } from '@/app/components/base/chat/types' +import { Markdown } from '@/app/components/base/markdown' +import type { SiteInfo } from '@/models/share' +import cn from '@/utils/classnames' +import ResultTab from './result-tab' + +type ContentSectionProps = { + workflowProcessData?: WorkflowProcess + taskId?: string + depth: number + isError: boolean + content: any + hideProcessDetail?: boolean + siteInfo: SiteInfo | null + currentTab: 'DETAIL' | 'RESULT' + onSwitchTab: (tab: 'DETAIL' | 'RESULT') => void + showResultTabs: boolean + t: TFunction + inSidePanel?: boolean +} + +const ContentSection: FC = ({ + workflowProcessData, + taskId, + depth, + isError, + content, + hideProcessDetail, + siteInfo, + currentTab, + onSwitchTab, + showResultTabs, + t, + inSidePanel, +}) => { + return ( +
+ {workflowProcessData && ( + <> +
+ {taskId && ( +
+ + {t('share.generation.execution')} + · + {taskId} +
+ )} + {siteInfo && workflowProcessData && ( + + )} + {showResultTabs && ( +
+
onSwitchTab('RESULT')} + >{t('runLog.result')}
+
onSwitchTab('DETAIL')} + >{t('runLog.detail')}
+
+ )} +
+ {!isError && ( + + )} + + )} + {!workflowProcessData && taskId && ( +
+ + {t('share.generation.execution')} + · + {`${taskId}${depth > 1 ? `-${depth - 1}` : ''}`} +
+ )} + {isError && ( +
{t('share.generation.batchFailed.outputPlaceholder')}
+ )} + {!workflowProcessData && !isError && (typeof content === 'string') && ( +
+ +
+ )} +
+ ) +} + +export default ContentSection diff --git a/web/app/components/app/text-generate/item/hooks.ts b/web/app/components/app/text-generate/item/hooks.ts new file mode 100644 index 0000000000..a2b44613fc --- /dev/null +++ b/web/app/components/app/text-generate/item/hooks.ts @@ -0,0 +1,67 @@ +import { useBoolean } from 'ahooks' +import { useEffect, useState } from 'react' +import type { FeedbackType } from '@/app/components/base/chat/chat/type' +import type { WorkflowProcess } from '@/app/components/base/chat/types' + +type UseMoreLikeThisStateParams = { + controlClearMoreLikeThis?: number + isLoading?: boolean +} + +export const useMoreLikeThisState = ({ + controlClearMoreLikeThis, + isLoading, +}: UseMoreLikeThisStateParams) => { + const [completionRes, setCompletionRes] = useState('') + const [childMessageId, setChildMessageId] = useState(null) + const [childFeedback, setChildFeedback] = useState({ + rating: null, + }) + const [isQuerying, { setTrue: startQuerying, setFalse: stopQuerying }] = useBoolean(false) + + useEffect(() => { + if (controlClearMoreLikeThis) { + setChildMessageId(null) + setCompletionRes('') + } + }, [controlClearMoreLikeThis]) + + useEffect(() => { + if (isLoading) + setChildMessageId(null) + }, [isLoading]) + + return { + completionRes, + setCompletionRes, + childMessageId, + setChildMessageId, + childFeedback, + setChildFeedback, + isQuerying, + startQuerying, + stopQuerying, + } +} + +export const useWorkflowTabs = (workflowProcessData?: WorkflowProcess) => { + const [currentTab, setCurrentTab] = useState<'DETAIL' | 'RESULT'>('DETAIL') + const showResultTabs = !!workflowProcessData?.resultText || !!workflowProcessData?.files?.length + + useEffect(() => { + if (showResultTabs) + setCurrentTab('RESULT') + else + setCurrentTab('DETAIL') + }, [ + showResultTabs, + workflowProcessData?.resultText, + workflowProcessData?.files?.length, + ]) + + return { + currentTab, + setCurrentTab, + showResultTabs, + } +} diff --git a/web/app/components/app/text-generate/item/index.tsx b/web/app/components/app/text-generate/item/index.tsx index 92d86351e0..4d0a17d790 100644 --- a/web/app/components/app/text-generate/item/index.tsx +++ b/web/app/components/app/text-generate/item/index.tsx @@ -1,36 +1,23 @@ 'use client' import type { FC } from 'react' -import React, { useEffect, useState } from 'react' +import React, { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import { - RiBookmark3Line, - RiClipboardLine, - RiFileList3Line, - RiPlayList2Line, - RiReplay15Line, - RiSparklingFill, - RiSparklingLine, - RiThumbDownLine, - RiThumbUpLine, -} from '@remixicon/react' +import { RiSparklingFill } from '@remixicon/react' import copy from 'copy-to-clipboard' import { useParams } from 'next/navigation' -import { useBoolean } from 'ahooks' -import ResultTab from './result-tab' -import { Markdown } from '@/app/components/base/markdown' import Loading from '@/app/components/base/loading' import Toast from '@/app/components/base/toast' import type { FeedbackType } from '@/app/components/base/chat/chat/type' import { fetchMoreLikeThis, updateFeedback } from '@/service/share' import { fetchTextGenerationMessage } from '@/service/debug' import { useStore as useAppStore } from '@/app/components/app/store' -import WorkflowProcessItem from '@/app/components/base/chat/chat/answer/workflow-process' import type { WorkflowProcess } from '@/app/components/base/chat/types' import type { SiteInfo } from '@/models/share' import { useChatContext } from '@/app/components/base/chat/chat/context' -import ActionButton, { ActionButtonState } from '@/app/components/base/action-button' -import NewAudioButton from '@/app/components/base/new-audio-button' import cn from '@/utils/classnames' +import ContentSection from './content-section' +import MetaSection from './meta-section' +import { useMoreLikeThisState, useWorkflowTabs } from './hooks' const MAX_DEPTH = 3 @@ -69,6 +56,35 @@ export const copyIcon = ( ) +const formatLogItem = (data: any) => { + if (Array.isArray(data.message)) { + const assistantLog = data.message[data.message.length - 1]?.role !== 'assistant' + ? [{ + role: 'assistant', + text: data.answer, + files: data.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [], + }] + : [] + + return { + ...data, + log: [ + ...data.message, + ...assistantLog, + ], + } + } + + const message = typeof data.message === 'string' + ? { text: data.message } + : data.message + + return { + ...data, + log: [message], + } +} + const GenerationItem: FC = ({ isWorkflow, workflowProcessData, @@ -99,33 +115,97 @@ const GenerationItem: FC = ({ const { t } = useTranslation() const params = useParams() const isTop = depth === 1 - const [completionRes, setCompletionRes] = useState('') - const [childMessageId, setChildMessageId] = useState(null) - const [childFeedback, setChildFeedback] = useState({ - rating: null, - }) const { - config, - } = useChatContext() - + completionRes, + setCompletionRes, + childMessageId, + setChildMessageId, + childFeedback, + setChildFeedback, + isQuerying, + startQuerying, + stopQuerying, + } = useMoreLikeThisState({ controlClearMoreLikeThis, isLoading }) + const { currentTab, setCurrentTab, showResultTabs } = useWorkflowTabs(workflowProcessData) + const { config } = useChatContext() const setCurrentLogItem = useAppStore(s => s.setCurrentLogItem) const setShowPromptLogModal = useAppStore(s => s.setShowPromptLogModal) - const handleFeedback = async (childFeedback: FeedbackType) => { - await updateFeedback({ url: `/messages/${childMessageId}/feedbacks`, body: { rating: childFeedback.rating } }, isInstalledApp, installedAppId) - setChildFeedback(childFeedback) - } + const handleFeedback = useCallback(async (nextFeedback: FeedbackType) => { + if (!childMessageId) + return + await updateFeedback( + { url: `/messages/${childMessageId}/feedbacks`, body: { rating: nextFeedback.rating } }, + isInstalledApp, + installedAppId, + ) + setChildFeedback(nextFeedback) + }, [childMessageId, installedAppId, isInstalledApp, setChildFeedback]) - const [isQuerying, { setTrue: startQuerying, setFalse: stopQuerying }] = useBoolean(false) + const handleMoreLikeThis = useCallback(async () => { + if (isQuerying || !messageId) { + Toast.notify({ type: 'warning', message: t('appDebug.errorMessage.waitForResponse') }) + return + } + startQuerying() + try { + const res: any = await fetchMoreLikeThis(messageId as string, isInstalledApp, installedAppId) + setCompletionRes(res.answer) + setChildFeedback({ rating: null }) + setChildMessageId(res.id) + } + finally { + stopQuerying() + } + }, [ + installedAppId, + isInstalledApp, + isQuerying, + messageId, + setChildFeedback, + setChildMessageId, + setCompletionRes, + startQuerying, + stopQuerying, + t, + ]) - const childProps = { - isInWebApp: true, + const handleOpenLogModal = useCallback(async () => { + if (!messageId) + return + const data = await fetchTextGenerationMessage({ + appId: params.appId as string, + messageId, + }) + const logItem = formatLogItem(data) + setCurrentLogItem(logItem) + setShowPromptLogModal(true) + }, [messageId, params.appId, setCurrentLogItem, setShowPromptLogModal]) + + const copyContent = isWorkflow ? workflowProcessData?.resultText : content + const handleCopy = useCallback(() => { + if (typeof copyContent === 'string') + copy(copyContent) + else + copy(JSON.stringify(copyContent)) + Toast.notify({ type: 'success', message: t('common.actionMsg.copySuccessfully') }) + }, [copyContent, t]) + + const shouldIndentForChild = Boolean(isMobile && (childMessageId || isQuerying) && depth < MAX_DEPTH) + const shouldRenderChild = (childMessageId || isQuerying) && depth < MAX_DEPTH + const canCopy = (currentTab === 'RESULT' && workflowProcessData?.resultText) || !isWorkflow + const childProps: IGenerationItemProps = { + isWorkflow, + className, + isError: false, + onRetry, content: completionRes, messageId: childMessageId, - depth: depth + 1, - moreLikeThis: true, - onFeedback: handleFeedback, isLoading: isQuerying, + isResponding, + moreLikeThis: true, + depth: depth + 1, + onFeedback: handleFeedback, feedback: childFeedback, onSave, isShowTextToSpeech, @@ -133,80 +213,13 @@ const GenerationItem: FC = ({ isInstalledApp, installedAppId, controlClearMoreLikeThis, - isWorkflow, + isInWebApp: true, siteInfo, taskId, + inSidePanel, + hideProcessDetail, } - const handleMoreLikeThis = async () => { - if (isQuerying || !messageId) { - Toast.notify({ type: 'warning', message: t('appDebug.errorMessage.waitForResponse') }) - return - } - startQuerying() - const res: any = await fetchMoreLikeThis(messageId as string, isInstalledApp, installedAppId) - setCompletionRes(res.answer) - setChildFeedback({ - rating: null, - }) - setChildMessageId(res.id) - stopQuerying() - } - - useEffect(() => { - if (controlClearMoreLikeThis) { - setChildMessageId(null) - setCompletionRes('') - } - }, [controlClearMoreLikeThis]) - - // regeneration clear child - useEffect(() => { - if (isLoading) - setChildMessageId(null) - }, [isLoading]) - - const handleOpenLogModal = async () => { - const data = await fetchTextGenerationMessage({ - appId: params.appId as string, - messageId: messageId!, - }) - const logItem = Array.isArray(data.message) ? { - ...data, - log: [ - ...data.message, - ...(data.message[data.message.length - 1].role !== 'assistant' - ? [ - { - role: 'assistant', - text: data.answer, - files: data.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [], - }, - ] - : []), - ], - } : { - ...data, - log: [typeof data.message === 'string' ? { - text: data.message, - } : data.message], - } - setCurrentLogItem(logItem) - setShowPromptLogModal(true) - } - - const [currentTab, setCurrentTab] = useState('DETAIL') - const showResultTabs = !!workflowProcessData?.resultText || !!workflowProcessData?.files?.length - const switchTab = async (tab: string) => { - setCurrentTab(tab) - } - useEffect(() => { - if (workflowProcessData?.resultText || !!workflowProcessData?.files?.length) - switchTab('RESULT') - else - switchTab('DETAIL') - }, [workflowProcessData?.files?.length, workflowProcessData?.resultText]) - return ( <>
@@ -215,152 +228,45 @@ const GenerationItem: FC = ({ )} {!isLoading && ( <> - {/* result content */} -
- {workflowProcessData && ( - <> -
- {taskId && ( -
- - {t('share.generation.execution')} - · - {taskId} -
- )} - {siteInfo && workflowProcessData && ( - - )} - {showResultTabs && ( -
-
switchTab('RESULT')} - >{t('runLog.result')}
-
switchTab('DETAIL')} - >{t('runLog.detail')}
-
- )} -
- {!isError && ( - - )} - - )} - {!workflowProcessData && taskId && ( -
- - {t('share.generation.execution')} - · - {`${taskId}${depth > 1 ? `-${depth - 1}` : ''}`} -
- )} - {isError && ( -
{t('share.generation.batchFailed.outputPlaceholder')}
- )} - {!workflowProcessData && !isError && (typeof content === 'string') && ( -
- -
- )} -
- {/* meta data */} -
- {!isWorkflow && {content?.length} {t('common.unit.char')}} - {/* action buttons */} -
- {!isInWebApp && !isInstalledApp && !isResponding && ( -
- - - {/*
{t('common.operation.log')}
*/} -
-
- )} -
- {moreLikeThis && ( - - - - )} - {isShowTextToSpeech && ( - - )} - {((currentTab === 'RESULT' && workflowProcessData?.resultText) || !isWorkflow) && ( - { - const copyContent = isWorkflow ? workflowProcessData?.resultText : content - if (typeof copyContent === 'string') - copy(copyContent) - else - copy(JSON.stringify(copyContent)) - Toast.notify({ type: 'success', message: t('common.actionMsg.copySuccessfully') }) - }}> - - - )} - {isInWebApp && isError && ( - - - - )} - {isInWebApp && !isWorkflow && ( - { onSave?.(messageId as string) }}> - - - )} -
- {(supportFeedback || isInWebApp) && !isWorkflow && !isError && messageId && ( -
- {!feedback?.rating && ( - <> - onFeedback?.({ rating: 'like' })}> - - - onFeedback?.({ rating: 'dislike' })}> - - - - )} - {feedback?.rating === 'like' && ( - onFeedback?.({ rating: null })}> - - - )} - {feedback?.rating === 'dislike' && ( - onFeedback?.({ rating: null })}> - - - )} -
- )} -
-
+ + {/* more like this elements */} {!isTop && (
= ({ )}
- {((childMessageId || isQuerying) && depth < 3) && ( - + {shouldRenderChild && ( + )} ) diff --git a/web/app/components/app/text-generate/item/meta-section.tsx b/web/app/components/app/text-generate/item/meta-section.tsx new file mode 100644 index 0000000000..331c2101ec --- /dev/null +++ b/web/app/components/app/text-generate/item/meta-section.tsx @@ -0,0 +1,157 @@ +import type { FC } from 'react' +import type { TFunction } from 'i18next' +import { + RiBookmark3Line, + RiClipboardLine, + RiFileList3Line, + RiReplay15Line, + RiSparklingLine, + RiThumbDownLine, + RiThumbUpLine, +} from '@remixicon/react' +import type { FeedbackType } from '@/app/components/base/chat/chat/type' +import ActionButton, { ActionButtonState } from '@/app/components/base/action-button' +import NewAudioButton from '@/app/components/base/new-audio-button' +import cn from '@/utils/classnames' + +type FeedbackActionsProps = { + feedback?: FeedbackType + onFeedback?: (feedback: FeedbackType) => void +} + +const FeedbackActions: FC = ({ + feedback, + onFeedback, +}) => { + if (!feedback?.rating) { + return ( + <> + onFeedback?.({ rating: 'like' })}> + + + onFeedback?.({ rating: 'dislike' })}> + + + + ) + } + + if (feedback.rating === 'like') { + return ( + onFeedback?.({ rating: null })}> + + + ) + } + + return ( + onFeedback?.({ rating: null })}> + + + ) +} + +type MetaSectionProps = { + showCharCount: boolean + charCount?: number + t: TFunction + shouldIndentForChild: boolean + isInWebApp?: boolean + isInstalledApp: boolean + isResponding?: boolean + isError: boolean + messageId?: string | null + onOpenLogModal: () => void + moreLikeThis?: boolean + onMoreLikeThis: () => void + disableMoreLikeThis: boolean + isShowTextToSpeech?: boolean + textToSpeechVoice?: string + canCopy: boolean + onCopy: () => void + onRetry: () => void + isWorkflow?: boolean + onSave?: (messageId: string) => void + feedback?: FeedbackType + onFeedback?: (feedback: FeedbackType) => void + supportFeedback?: boolean +} + +const MetaSection: FC = ({ + showCharCount, + charCount, + t, + shouldIndentForChild, + isInWebApp, + isInstalledApp, + isResponding, + isError, + messageId, + onOpenLogModal, + moreLikeThis, + onMoreLikeThis, + disableMoreLikeThis, + isShowTextToSpeech, + textToSpeechVoice, + canCopy, + onCopy, + onRetry, + isWorkflow, + onSave, + feedback, + onFeedback, + supportFeedback, +}) => { + return ( +
+ {showCharCount && {charCount} {t('common.unit.char')}} +
+ {!isInWebApp && !isInstalledApp && !isResponding && ( +
+ + + +
+ )} +
+ {moreLikeThis && ( + + + + )} + {isShowTextToSpeech && messageId && ( + + )} + {canCopy && ( + + + + )} + {isInWebApp && isError && ( + + + + )} + {isInWebApp && !isWorkflow && ( + { onSave?.(messageId as string) }}> + + + )} +
+ {(supportFeedback || isInWebApp) && !isWorkflow && !isError && messageId && ( +
+ +
+ )} +
+
+ ) +} + +export default MetaSection