Files
dify/web/app/components/base/chat/chat/answer/operation.tsx

375 lines
15 KiB
TypeScript

import type { FC } from 'react'
import type {
ChatItem,
Feedback,
} from '../../types'
import copy from 'copy-to-clipboard'
import {
memo,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import EditReplyModal from '@/app/components/app/annotation/edit-annotation-modal'
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
import Log from '@/app/components/base/chat/chat/log'
import AnnotationCtrlButton from '@/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button'
import Modal from '@/app/components/base/modal/modal'
import NewAudioButton from '@/app/components/base/new-audio-button'
import Textarea from '@/app/components/base/textarea'
import Toast from '@/app/components/base/toast'
import Tooltip from '@/app/components/base/tooltip'
import { cn } from '@/utils/classnames'
import { useChatContext } from '../context'
type OperationProps = {
item: ChatItem
question: string
index: number
showPromptLog?: boolean
maxSize: number
contentWidth: number
hasWorkflowProcess: boolean
noChatInput?: boolean
}
const Operation: FC<OperationProps> = ({
item,
question,
index,
showPromptLog,
maxSize,
contentWidth,
hasWorkflowProcess,
noChatInput,
}) => {
const { t } = useTranslation()
const {
config,
onAnnotationAdded,
onAnnotationEdited,
onAnnotationRemoved,
onFeedback,
onRegenerate,
} = useChatContext()
const [isShowReplyModal, setIsShowReplyModal] = useState(false)
const [isShowFeedbackModal, setIsShowFeedbackModal] = useState(false)
const [feedbackContent, setFeedbackContent] = useState('')
const {
id,
isOpeningStatement,
content: messageContent,
annotation,
feedback,
adminFeedback,
agent_thoughts,
humanInputFormDataList,
} = item
const [userLocalFeedback, setUserLocalFeedback] = useState(feedback)
const [adminLocalFeedback, setAdminLocalFeedback] = useState(adminFeedback)
const [feedbackTarget, setFeedbackTarget] = useState<'user' | 'admin'>('user')
// Separate feedback types for display
const userFeedback = feedback
const content = useMemo(() => {
if (agent_thoughts?.length)
return agent_thoughts.reduce((acc, cur) => acc + cur.thought, '')
return messageContent
}, [agent_thoughts, messageContent])
const displayUserFeedback = userLocalFeedback ?? userFeedback
const hasUserFeedback = !!displayUserFeedback?.rating
const hasAdminFeedback = !!adminLocalFeedback?.rating
const shouldShowUserFeedbackBar = !isOpeningStatement && config?.supportFeedback && !!onFeedback && !config?.supportAnnotation
const shouldShowAdminFeedbackBar = !isOpeningStatement && config?.supportFeedback && !!onFeedback && !!config?.supportAnnotation
const userFeedbackLabel = t('table.header.userRate', { ns: 'appLog' }) || 'User feedback'
const adminFeedbackLabel = t('table.header.adminRate', { ns: 'appLog' }) || 'Admin feedback'
const feedbackTooltipClassName = 'max-w-[260px]'
const buildFeedbackTooltip = (feedbackData?: Feedback | null, label = userFeedbackLabel) => {
if (!feedbackData?.rating)
return label
const ratingLabel = feedbackData.rating === 'like'
? (t('detail.operation.like', { ns: 'appLog' }) || 'like')
: (t('detail.operation.dislike', { ns: 'appLog' }) || 'dislike')
const feedbackText = feedbackData.content?.trim()
if (feedbackText)
return `${label}: ${ratingLabel} - ${feedbackText}`
return `${label}: ${ratingLabel}`
}
const handleFeedback = async (rating: 'like' | 'dislike' | null, content?: string, target: 'user' | 'admin' = 'user') => {
if (!config?.supportFeedback || !onFeedback)
return
await onFeedback?.(id, { rating, content })
const nextFeedback = rating === null ? { rating: null } : { rating, content }
if (target === 'admin')
setAdminLocalFeedback(nextFeedback)
else
setUserLocalFeedback(nextFeedback)
}
const handleLikeClick = (target: 'user' | 'admin') => {
handleFeedback('like', undefined, target)
}
const handleDislikeClick = (target: 'user' | 'admin') => {
setFeedbackTarget(target)
setIsShowFeedbackModal(true)
}
const handleFeedbackSubmit = async () => {
await handleFeedback('dislike', feedbackContent, feedbackTarget)
setFeedbackContent('')
setIsShowFeedbackModal(false)
}
const handleFeedbackCancel = () => {
setFeedbackContent('')
setIsShowFeedbackModal(false)
}
const operationWidth = useMemo(() => {
let width = 0
if (!isOpeningStatement)
width += 26
if (!isOpeningStatement && showPromptLog)
width += 28 + 8
if (!isOpeningStatement && config?.text_to_speech?.enabled)
width += 26
if (!isOpeningStatement && config?.supportAnnotation && config?.annotation_reply?.enabled)
width += 26
if (shouldShowUserFeedbackBar)
width += hasUserFeedback ? 28 + 8 : 60 + 8
if (shouldShowAdminFeedbackBar)
width += (hasAdminFeedback ? 28 : 60) + 8 + (hasUserFeedback ? 28 : 0)
return width
}, [config?.annotation_reply?.enabled, config?.supportAnnotation, config?.text_to_speech?.enabled, hasAdminFeedback, hasUserFeedback, isOpeningStatement, shouldShowAdminFeedbackBar, shouldShowUserFeedbackBar, showPromptLog])
const positionRight = useMemo(() => operationWidth < maxSize, [operationWidth, maxSize])
return (
<>
<div
className={cn(
'absolute flex justify-end gap-1',
hasWorkflowProcess && '-bottom-4 right-2',
!positionRight && '-bottom-4 right-2',
!hasWorkflowProcess && positionRight && '!top-[9px]',
)}
style={(!hasWorkflowProcess && positionRight) ? { left: contentWidth + 8 } : {}}
data-testid="operation-bar"
>
{shouldShowUserFeedbackBar && !humanInputFormDataList?.length && (
<div className={cn(
'ml-1 items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm',
hasUserFeedback ? 'flex' : 'hidden group-hover:flex',
)}
>
{hasUserFeedback
? (
<Tooltip
popupContent={buildFeedbackTooltip(displayUserFeedback, userFeedbackLabel)}
popupClassName={feedbackTooltipClassName}
>
<ActionButton
state={displayUserFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Destructive}
onClick={() => handleFeedback(null, undefined, 'user')}
>
{displayUserFeedback?.rating === 'like'
? <div className="i-ri-thumb-up-line h-4 w-4" />
: <div className="i-ri-thumb-down-line h-4 w-4" />}
</ActionButton>
</Tooltip>
)
: (
<>
<ActionButton
state={displayUserFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Default}
onClick={() => handleLikeClick('user')}
>
<div className="i-ri-thumb-up-line h-4 w-4" />
</ActionButton>
<ActionButton
state={displayUserFeedback?.rating === 'dislike' ? ActionButtonState.Destructive : ActionButtonState.Default}
onClick={() => handleDislikeClick('user')}
>
<div className="i-ri-thumb-down-line h-4 w-4" />
</ActionButton>
</>
)}
</div>
)}
{shouldShowAdminFeedbackBar && !humanInputFormDataList?.length && (
<div className={cn(
'ml-1 items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm',
(hasAdminFeedback || hasUserFeedback) ? 'flex' : 'hidden group-hover:flex',
)}
>
{/* User Feedback Display */}
{displayUserFeedback?.rating && (
<Tooltip
popupContent={buildFeedbackTooltip(displayUserFeedback, userFeedbackLabel)}
popupClassName={feedbackTooltipClassName}
>
{displayUserFeedback.rating === 'like'
? (
<ActionButton state={ActionButtonState.Active}>
<div className="i-ri-thumb-up-line h-4 w-4" />
</ActionButton>
)
: (
<ActionButton state={ActionButtonState.Destructive}>
<div className="i-ri-thumb-down-line h-4 w-4" />
</ActionButton>
)}
</Tooltip>
)}
{/* Admin Feedback Controls */}
{displayUserFeedback?.rating && <div className="mx-1 h-3 w-[0.5px] bg-components-actionbar-border" />}
{hasAdminFeedback
? (
<Tooltip
popupContent={buildFeedbackTooltip(adminLocalFeedback, adminFeedbackLabel)}
popupClassName={feedbackTooltipClassName}
>
<ActionButton
state={adminLocalFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Destructive}
onClick={() => handleFeedback(null, undefined, 'admin')}
>
{adminLocalFeedback?.rating === 'like'
? <div className="i-ri-thumb-up-line h-4 w-4" />
: <div className="i-ri-thumb-down-line h-4 w-4" />}
</ActionButton>
</Tooltip>
)
: (
<>
<Tooltip
popupContent={buildFeedbackTooltip(adminLocalFeedback, adminFeedbackLabel)}
popupClassName={feedbackTooltipClassName}
>
<ActionButton
state={adminLocalFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Default}
onClick={() => handleLikeClick('admin')}
>
<div className="i-ri-thumb-up-line h-4 w-4" />
</ActionButton>
</Tooltip>
<Tooltip
popupContent={buildFeedbackTooltip(adminLocalFeedback, adminFeedbackLabel)}
popupClassName={feedbackTooltipClassName}
>
<ActionButton
state={adminLocalFeedback?.rating === 'dislike' ? ActionButtonState.Destructive : ActionButtonState.Default}
onClick={() => handleDislikeClick('admin')}
>
<div className="i-ri-thumb-down-line h-4 w-4" />
</ActionButton>
</Tooltip>
</>
)}
</div>
)}
{showPromptLog && !isOpeningStatement && (
<div className="hidden group-hover:block">
<Log logItem={item} />
</div>
)}
{!isOpeningStatement && (
<div className="ml-1 hidden items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm group-hover:flex" data-testid="operation-actions">
{(config?.text_to_speech?.enabled && !humanInputFormDataList?.length) && (
<NewAudioButton
id={id}
value={content}
voice={config?.text_to_speech?.voice}
/>
)}
{!humanInputFormDataList?.length && (
<ActionButton
onClick={() => {
copy(content)
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
}}
data-testid="copy-btn"
>
<div className="i-ri-clipboard-line h-4 w-4" />
</ActionButton>
)}
{!noChatInput && (
<ActionButton onClick={() => onRegenerate?.(item)} data-testid="regenerate-btn">
<div className="i-ri-reset-left-line h-4 w-4" />
</ActionButton>
)}
{config?.supportAnnotation && config.annotation_reply?.enabled && !humanInputFormDataList?.length && (
<AnnotationCtrlButton
appId={config?.appId || ''}
messageId={id}
cached={!!annotation?.id}
query={question}
answer={content}
onAdded={(id, authorName) => onAnnotationAdded?.(id, authorName, question, content, index)}
onEdit={() => setIsShowReplyModal(true)}
/>
)}
</div>
)}
</div>
<EditReplyModal
isShow={isShowReplyModal}
onHide={() => setIsShowReplyModal(false)}
query={question}
answer={content}
onEdited={(editedQuery, editedAnswer) => onAnnotationEdited?.(editedQuery, editedAnswer, index)}
onAdded={(annotationId, authorName, editedQuery, editedAnswer) => onAnnotationAdded?.(annotationId, authorName, editedQuery, editedAnswer, index)}
appId={config?.appId || ''}
messageId={id}
annotationId={annotation?.id || ''}
createdAt={annotation?.created_at}
onRemove={() => onAnnotationRemoved?.(index)}
/>
{isShowFeedbackModal && (
<Modal
title={t('feedback.title', { ns: 'common' }) || 'Provide Feedback'}
subTitle={t('feedback.subtitle', { ns: 'common' }) || 'Please tell us what went wrong with this response'}
onClose={handleFeedbackCancel}
onConfirm={handleFeedbackSubmit}
onCancel={handleFeedbackCancel}
confirmButtonText={t('operation.submit', { ns: 'common' }) || 'Submit'}
cancelButtonText={t('operation.cancel', { ns: 'common' }) || 'Cancel'}
>
<div className="space-y-3">
<div>
<label className="mb-2 block text-text-secondary system-sm-semibold">
{t('feedback.content', { ns: 'common' }) || 'Feedback Content'}
</label>
<Textarea
value={feedbackContent}
onChange={e => setFeedbackContent(e.target.value)}
placeholder={t('feedback.placeholder', { ns: 'common' }) || 'Please describe what went wrong or how we can improve...'}
rows={4}
className="w-full"
/>
</div>
</div>
</Modal>
)}
</>
)
}
export default memo(Operation)