use base ui toast

This commit is contained in:
yyh
2026-03-25 20:38:44 +08:00
parent a7178b4d5c
commit 20dea1faa2
274 changed files with 3597 additions and 8129 deletions

View File

@ -4,7 +4,7 @@ import type { InstalledApp } from '@/models/explore'
import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook, waitFor } from '@testing-library/react'
import { ToastProvider } from '@/app/components/base/toast'
import { ToastHost } from '@/app/components/base/ui/toast'
import {
AppSourceType,
delConversation,
@ -95,7 +95,8 @@ const createQueryClient = () => new QueryClient({
const createWrapper = (queryClient: QueryClient) => {
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
<ToastProvider>{children}</ToastProvider>
<ToastHost />
{children}
</QueryClientProvider>
)
}

View File

@ -1,47 +1,21 @@
import type { ExtraContent } from '../chat/type'
import type {
Callback,
ChatConfig,
ChatItem,
Feedback,
} from '../types'
import type { Callback, ChatConfig, ChatItem, Feedback } from '../types'
import type { InstalledApp } from '@/models/explore'
import type {
AppData,
ConversationItem,
} from '@/models/share'
import type { AppData, ConversationItem } from '@/models/share'
import type { HumanInputFilledFormData, HumanInputFormData } from '@/types/workflow'
import { useLocalStorageState } from 'ahooks'
import { noop } from 'es-toolkit/function'
import { produce } from 'immer'
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
import { useToastContext } from '@/app/components/base/toast/context'
import { toast } from '@/app/components/base/ui/toast'
import { InputVarType } from '@/app/components/workflow/types'
import { useWebAppStore } from '@/context/web-app-context'
import { useAppFavicon } from '@/hooks/use-app-favicon'
import { changeLanguage } from '@/i18n-config/client'
import {
AppSourceType,
delConversation,
pinConversation,
renameConversation,
unpinConversation,
updateFeedback,
} from '@/service/share'
import {
useInvalidateShareConversations,
useShareChatList,
useShareConversationName,
useShareConversations,
} from '@/service/use-share'
import { AppSourceType, delConversation, pinConversation, renameConversation, unpinConversation, updateFeedback } from '@/service/share'
import { useInvalidateShareConversations, useShareChatList, useShareConversationName, useShareConversations } from '@/service/use-share'
import { TransferMethod } from '@/types/app'
import { addFileInfos, sortAgentSorts } from '../../../tools/utils'
import { CONVERSATION_ID_INFO } from '../constants'
@ -94,14 +68,12 @@ function getFormattedChatList(messages: any[]) {
})
return newChatList
}
export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
const isInstalledApp = useMemo(() => !!installedAppInfo, [installedAppInfo])
const appSourceType = isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp
const appInfo = useWebAppStore(s => s.appInfo)
const appParams = useWebAppStore(s => s.appParams)
const appMeta = useWebAppStore(s => s.appMeta)
useAppFavicon({
enable: !installedAppInfo,
icon_type: appInfo?.site.icon_type,
@ -109,7 +81,6 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
icon_background: appInfo?.site.icon_background,
icon_url: appInfo?.site.icon_url,
})
const appData = useMemo(() => {
if (isInstalledApp) {
const { id, app } = installedAppInfo!
@ -130,18 +101,15 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
custom_config: null,
} as AppData
}
return appInfo
}, [isInstalledApp, installedAppInfo, appInfo])
const appId = useMemo(() => appData?.app_id, [appData])
const [userId, setUserId] = useState<string>()
useEffect(() => {
getProcessedSystemVariablesFromUrlParams().then(({ user_id }) => {
setUserId(user_id)
})
}, [])
useEffect(() => {
const setLocaleFromProps = async () => {
if (appData?.site.default_language)
@ -149,7 +117,6 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
}
setLocaleFromProps()
}, [appData])
const [sidebarCollapseState, setSidebarCollapseState] = useState<boolean>(() => {
if (typeof window !== 'undefined') {
try {
@ -193,15 +160,12 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
})
}
}, [appId, conversationIdInfo, setConversationIdInfo, userId])
const [newConversationId, setNewConversationId] = useState('')
const chatShouldReloadKey = useMemo(() => {
if (currentConversationId === newConversationId)
return ''
return currentConversationId
}, [currentConversationId, newConversationId])
const { data: appPinnedConversationData } = useShareConversations({
appSourceType,
appId,
@ -212,10 +176,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
refetchOnWindowFocus: false,
refetchOnReconnect: false,
})
const {
data: appConversationData,
isLoading: appConversationDataLoading,
} = useShareConversations({
const { data: appConversationData, isLoading: appConversationDataLoading } = useShareConversations({
appSourceType,
appId,
pinned: false,
@ -225,10 +186,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
refetchOnWindowFocus: false,
refetchOnReconnect: false,
})
const {
data: appChatListData,
isLoading: appChatListDataLoading,
} = useShareChatList({
const { data: appChatListData, isLoading: appChatListDataLoading } = useShareChatList({
conversationId: chatShouldReloadKey,
appSourceType,
appId,
@ -238,18 +196,12 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
refetchOnReconnect: false,
})
const invalidateShareConversations = useInvalidateShareConversations()
const [clearChatList, setClearChatList] = useState(false)
const [isResponding, setIsResponding] = useState(false)
const appPrevChatTree = useMemo(
() => (currentConversationId && appChatListData?.data.length)
? buildChatItemTree(getFormattedChatList(appChatListData.data))
: [],
[appChatListData, currentConversationId],
)
const appPrevChatTree = useMemo(() => (currentConversationId && appChatListData?.data.length)
? buildChatItemTree(getFormattedChatList(appChatListData.data))
: [], [appChatListData, currentConversationId])
const [showNewConversationItemInList, setShowNewConversationItemInList] = useState(false)
const pinnedConversationList = useMemo(() => {
return appPinnedConversationData?.data || []
}, [appPinnedConversationData])
@ -268,7 +220,6 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
let value = initInputs[item.paragraph.variable]
if (value && item.paragraph.max_length && value.length > item.paragraph.max_length)
value = value.slice(0, item.paragraph.max_length)
return {
...item.paragraph,
default: value || item.default || item.paragraph.default,
@ -283,7 +234,6 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
type: 'number',
}
}
if (item.checkbox) {
const preset = initInputs[item.checkbox.variable] === true
return {
@ -292,7 +242,6 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
type: 'checkbox',
}
}
if (item.select) {
const isInputInOptions = item.select.options.includes(initInputs[item.select.variable])
return {
@ -301,32 +250,27 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
type: 'select',
}
}
if (item['file-list']) {
return {
...item['file-list'],
type: 'file-list',
}
}
if (item.file) {
return {
...item.file,
type: 'file',
}
}
if (item.json_object) {
return {
...item.json_object,
type: 'json_object',
}
}
let value = initInputs[item['text-input'].variable]
if (value && item['text-input'].max_length && value.length > item['text-input'].max_length)
value = value.slice(0, item['text-input'].max_length)
return {
...item['text-input'],
default: value || item.default || item['text-input'].default,
@ -334,11 +278,9 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
}
})
}, [initInputs, appParams])
const allInputsHidden = useMemo(() => {
return inputsForms.length > 0 && inputsForms.every(item => item.hide === true)
}, [inputsForms])
useEffect(() => {
// init inputs from url params
(async () => {
@ -348,16 +290,13 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
setInitUserVariables(userVariables)
})()
}, [])
useEffect(() => {
const conversationInputs: Record<string, any> = {}
inputsForms.forEach((item: any) => {
conversationInputs[item.variable] = item.default || null
})
handleNewConversationInputsChange(conversationInputs)
}, [handleNewConversationInputsChange, inputsForms])
const { data: newConversation } = useShareConversationName({
conversationId: newConversationId,
appSourceType,
@ -373,7 +312,6 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
}, [appConversationData, appConversationDataLoading])
const conversationList = useMemo(() => {
const data = originConversationList.slice()
if (showNewConversationItemInList && data[0]?.id !== '') {
data.unshift({
id: '',
@ -384,12 +322,10 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
}
return data
}, [originConversationList, showNewConversationItemInList, t])
useEffect(() => {
if (newConversation) {
setOriginConversationList(produce((draft) => {
const index = draft.findIndex(item => item.id === newConversation.id)
if (index > -1)
draft[index] = newConversation
else
@ -397,16 +333,12 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
}))
}
}, [newConversation])
const currentConversationItem = useMemo(() => {
let conversationItem = conversationList.find(item => item.id === currentConversationId)
if (!conversationItem && pinnedConversationList.length)
conversationItem = pinnedConversationList.find(item => item.id === currentConversationId)
return conversationItem
}, [conversationList, currentConversationId, pinnedConversationList])
const currentConversationLatestInputs = useMemo(() => {
if (!currentConversationId || !appChatListData?.data.length)
return newConversationInputsRef.current || {}
@ -417,12 +349,9 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
if (currentConversationItem)
setCurrentConversationInputs(currentConversationLatestInputs || {})
}, [currentConversationItem, currentConversationLatestInputs])
const { notify } = useToastContext()
const checkInputsRequired = useCallback((silent?: boolean) => {
if (allInputsHidden)
return true
let hasEmptyInput = ''
let fileIsUploading = false
const requiredVars = inputsForms.filter(({ required, type }) => required && type !== InputVarType.checkbox)
@ -430,13 +359,10 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
requiredVars.forEach(({ variable, label, type }) => {
if (hasEmptyInput)
return
if (fileIsUploading)
return
if (!newConversationInputsRef.current[variable] && !silent)
hasEmptyInput = label as string
if ((type === InputVarType.singleFile || type === InputVarType.multiFiles) && newConversationInputsRef.current[variable] && !silent) {
const files = newConversationInputsRef.current[variable]
if (Array.isArray(files))
@ -446,26 +372,25 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
}
})
}
if (hasEmptyInput) {
notify({ type: 'error', message: t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: hasEmptyInput }) })
toast.error(t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: hasEmptyInput }))
return false
}
if (fileIsUploading) {
notify({ type: 'info', message: t('errorMessage.waitForFileUpload', { ns: 'appDebug' }) })
toast.info(t('errorMessage.waitForFileUpload', { ns: 'appDebug' }))
return
}
return true
}, [inputsForms, notify, t, allInputsHidden])
}, [inputsForms, t, allInputsHidden])
const handleStartChat = useCallback((callback: any) => {
if (checkInputsRequired()) {
setShowNewConversationItemInList(true)
callback?.()
}
}, [setShowNewConversationItemInList, checkInputsRequired])
const currentChatInstanceRef = useRef<{ handleStop: () => void }>({ handleStop: noop })
const currentChatInstanceRef = useRef<{
handleStop: () => void
}>({ handleStop: noop })
const handleChangeConversation = useCallback((conversationId: string) => {
currentChatInstanceRef.current.handleStop()
setNewConversationId('')
@ -487,76 +412,48 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
const handleUpdateConversationList = useCallback(() => {
invalidateShareConversations()
}, [invalidateShareConversations])
const handlePinConversation = useCallback(async (conversationId: string) => {
await pinConversation(appSourceType, appId, conversationId)
notify({ type: 'success', message: t('api.success', { ns: 'common' }) })
toast.success(t('api.success', { ns: 'common' }))
handleUpdateConversationList()
}, [appSourceType, appId, notify, t, handleUpdateConversationList])
}, [appSourceType, appId, t, handleUpdateConversationList])
const handleUnpinConversation = useCallback(async (conversationId: string) => {
await unpinConversation(appSourceType, appId, conversationId)
notify({ type: 'success', message: t('api.success', { ns: 'common' }) })
toast.success(t('api.success', { ns: 'common' }))
handleUpdateConversationList()
}, [appSourceType, appId, notify, t, handleUpdateConversationList])
}, [appSourceType, appId, t, handleUpdateConversationList])
const [conversationDeleting, setConversationDeleting] = useState(false)
const handleDeleteConversation = useCallback(async (
conversationId: string,
{
onSuccess,
}: Callback,
) => {
const handleDeleteConversation = useCallback(async (conversationId: string, { onSuccess }: Callback) => {
if (conversationDeleting)
return
try {
setConversationDeleting(true)
await delConversation(appSourceType, appId, conversationId)
notify({ type: 'success', message: t('api.success', { ns: 'common' }) })
toast.success(t('api.success', { ns: 'common' }))
onSuccess()
}
finally {
setConversationDeleting(false)
}
if (conversationId === currentConversationId)
handleNewConversation()
handleUpdateConversationList()
}, [isInstalledApp, appId, notify, t, handleUpdateConversationList, handleNewConversation, currentConversationId, conversationDeleting])
}, [isInstalledApp, appId, t, handleUpdateConversationList, handleNewConversation, currentConversationId, conversationDeleting])
const [conversationRenaming, setConversationRenaming] = useState(false)
const handleRenameConversation = useCallback(async (
conversationId: string,
newName: string,
{
onSuccess,
}: Callback,
) => {
const handleRenameConversation = useCallback(async (conversationId: string, newName: string, { onSuccess }: Callback) => {
if (conversationRenaming)
return
if (!newName.trim()) {
notify({
type: 'error',
message: t('chat.conversationNameCanNotEmpty', { ns: 'common' }),
})
toast.error(t('chat.conversationNameCanNotEmpty', { ns: 'common' }))
return
}
setConversationRenaming(true)
try {
await renameConversation(appSourceType, appId, conversationId, newName)
notify({
type: 'success',
message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }),
})
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
setOriginConversationList(produce((draft) => {
const index = originConversationList.findIndex(item => item.id === conversationId)
const item = draft[index]
draft[index] = {
...item,
name: newName,
@ -567,20 +464,17 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
finally {
setConversationRenaming(false)
}
}, [isInstalledApp, appId, notify, t, conversationRenaming, originConversationList])
}, [isInstalledApp, appId, t, conversationRenaming, originConversationList])
const handleNewConversationCompleted = useCallback((newConversationId: string) => {
setNewConversationId(newConversationId)
handleConversationIdInfoChange(newConversationId)
setShowNewConversationItemInList(false)
invalidateShareConversations()
}, [handleConversationIdInfoChange, invalidateShareConversations])
const handleFeedback = useCallback(async (messageId: string, feedback: Feedback) => {
await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating, content: feedback.content } }, appSourceType, appId)
notify({ type: 'success', message: t('api.success', { ns: 'common' }) })
}, [appSourceType, appId, t, notify])
toast.success(t('api.success', { ns: 'common' }))
}, [appSourceType, appId, t])
return {
isInstalledApp,
appId,

View File

@ -5,8 +5,8 @@ import { TransferMethod } from '@/types/app'
import { useCheckInputsForms } from '../check-input-forms-hooks'
const mockNotify = vi.fn()
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({ notify: mockNotify }),
vi.mock('@/app/components/base/ui/toast', () => ({
}))
describe('useCheckInputsForms', () => {

View File

@ -20,8 +20,8 @@ vi.mock('@/app/components/base/audio-btn/audio.player.manager', () => ({
},
}))
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({ notify: vi.fn() }),
vi.mock('@/app/components/base/ui/toast', () => ({
}))
vi.mock('@/hooks/use-timestamp', () => ({

View File

@ -5,7 +5,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import copy from 'copy-to-clipboard'
import * as React from 'react'
import Toast from '../../../toast'
import { toast } from '@/app/components/base/ui/toast'
import { ThemeBuilder } from '../../embedded-chatbot/theme/theme-context'
import { ChatContextProvider } from '../context-provider'
import Question from '../question'
@ -179,7 +179,7 @@ describe('Question component', () => {
it('should call copy-to-clipboard and show a toast when copy action is clicked', async () => {
const user = userEvent.setup()
const toastSpy = vi.spyOn(Toast, 'notify')
const toastSpy = vi.spyOn(toast, 'success').mockReturnValue('toast-success')
renderWithProvider(makeItem())

View File

@ -29,7 +29,7 @@ const {
vi.mock('copy-to-clipboard', () => ({ default: vi.fn() }))
vi.mock('@/app/components/base/toast', () => ({
vi.mock('@/app/components/base/ui/toast', () => ({
default: { notify: vi.fn() },
}))

View File

@ -1,14 +1,7 @@
import type { FC } from 'react'
import type {
ChatItem,
Feedback,
} from '../../types'
import type { ChatItem, Feedback } from '../../types'
import copy from 'copy-to-clipboard'
import {
memo,
useMemo,
useState,
} from 'react'
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'
@ -17,8 +10,8 @@ import AnnotationCtrlButton from '@/app/components/base/features/new-feature-pan
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 { toast } from '@/app/components/base/ui/toast'
import { cn } from '@/utils/classnames'
import { useChatContext } from '../context'
@ -32,14 +25,11 @@ type OperationProps = {
hasWorkflowProcess: boolean
noChatInput?: boolean
}
const stringifyCopyValue = (value: unknown) => {
if (typeof value === 'string')
return value
if (value === null || typeof value === 'undefined')
return ''
try {
return JSON.stringify(value, null, 2)
}
@ -47,196 +37,132 @@ const stringifyCopyValue = (value: unknown) => {
return String(value)
}
}
const buildCopyContentFromLLMGenerationItems = (llmGenerationItems?: ChatItem['llmGenerationItems']) => {
if (!llmGenerationItems?.length)
return ''
const hasStructuredItems = llmGenerationItems.some(item => item.type !== 'text')
if (!hasStructuredItems)
return ''
return llmGenerationItems
.map((item) => {
if (item.type === 'text')
return item.text || ''
if (item.type === 'thought')
return item.thoughtOutput ? `[THOUGHT]\n${item.thoughtOutput}` : ''
if (item.type === 'tool') {
const sections = [
`[TOOL] ${item.toolName || ''}`.trim(),
]
if (item.toolArguments)
sections.push(`INPUT:\n${stringifyCopyValue(item.toolArguments)}`)
if (typeof item.toolOutput !== 'undefined')
sections.push(`OUTPUT:\n${stringifyCopyValue(item.toolOutput)}`)
if (item.toolError)
sections.push(`ERROR:\n${item.toolError}`)
return sections.join('\n')
}
if (item.type === 'model') {
const sections = [
`[MODEL] ${item.modelName || ''}`.trim(),
]
if (typeof item.modelOutput !== 'undefined')
sections.push(`OUTPUT:\n${stringifyCopyValue(item.modelOutput)}`)
return sections.join('\n')
}
return ''
})
.filter(Boolean)
.join('\n\n')
}
const buildCopyContentFromAgentThoughts = (agentThoughts?: ChatItem['agent_thoughts']) => {
if (!agentThoughts?.length)
return ''
return agentThoughts
.map((thought) => {
const sections = [
`[AGENT] ${thought.tool || ''}`.trim(),
]
if (thought.thought)
sections.push(`THOUGHT:\n${thought.thought}`)
if (thought.tool_input)
sections.push(`INPUT:\n${thought.tool_input}`)
if (thought.observation)
sections.push(`OUTPUT:\n${thought.observation}`)
return sections.join('\n')
})
.join('\n\n')
}
const Operation: FC<OperationProps> = ({
item,
question,
index,
showPromptLog,
maxSize,
contentWidth,
hasWorkflowProcess,
noChatInput,
}) => {
const Operation: FC<OperationProps> = ({ item, question, index, showPromptLog, maxSize, contentWidth, hasWorkflowProcess, noChatInput }) => {
const { t } = useTranslation()
const {
config,
onAnnotationAdded,
onAnnotationEdited,
onAnnotationRemoved,
onFeedback,
onRegenerate,
} = useChatContext()
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 { 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 copyContent = useMemo(() => {
const llmGenerationCopyContent = buildCopyContentFromLLMGenerationItems(item.llmGenerationItems)
if (llmGenerationCopyContent)
return llmGenerationCopyContent
const agentThoughtCopyContent = buildCopyContentFromAgentThoughts(agent_thoughts)
if (agentThoughtCopyContent)
return agentThoughtCopyContent
return messageContent
}, [item.llmGenerationItems, 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)
@ -251,40 +177,18 @@ const Operation: FC<OperationProps> = ({
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"
>
<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',
)}
>
<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')}
>
<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" />}
@ -293,16 +197,10 @@ const Operation: FC<OperationProps> = ({
)
: (
<>
<ActionButton
state={displayUserFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Default}
onClick={() => handleLikeClick('user')}
>
<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')}
>
<ActionButton state={displayUserFeedback?.rating === 'dislike' ? ActionButtonState.Destructive : ActionButtonState.Default} onClick={() => handleDislikeClick('user')}>
<div className="i-ri-thumb-down-line h-4 w-4" />
</ActionButton>
</>
@ -310,17 +208,10 @@ const Operation: FC<OperationProps> = ({
</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',
)}
>
<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}
>
<Tooltip popupContent={buildFeedbackTooltip(displayUserFeedback, userFeedbackLabel)} popupClassName={feedbackTooltipClassName}>
{displayUserFeedback.rating === 'like'
? (
<ActionButton state={ActionButtonState.Active}>
@ -339,14 +230,8 @@ const Operation: FC<OperationProps> = ({
{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')}
>
<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" />}
@ -355,25 +240,13 @@ const Operation: FC<OperationProps> = ({
)
: (
<>
<Tooltip
popupContent={buildFeedbackTooltip(adminLocalFeedback, adminFeedbackLabel)}
popupClassName={feedbackTooltipClassName}
>
<ActionButton
state={adminLocalFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Default}
onClick={() => handleLikeClick('admin')}
>
<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')}
>
<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>
@ -388,18 +261,12 @@ const Operation: FC<OperationProps> = ({
)}
{!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}
/>
)}
{(config?.text_to_speech?.enabled && !humanInputFormDataList?.length) && (<NewAudioButton id={id} value={content} voice={config?.text_to_speech?.voice} />)}
{!humanInputFormDataList?.length && (
<ActionButton
onClick={() => {
copy(copyContent)
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
toast.success(t('actionMsg.copySuccessfully', { ns: 'common' }))
}}
data-testid="copy-btn"
>
@ -411,55 +278,19 @@ const Operation: FC<OperationProps> = ({
<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)}
/>
)}
{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)}
/>
<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'}
>
<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"
/>
<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>
@ -467,5 +298,4 @@ const Operation: FC<OperationProps> = ({
</>
)
}
export default memo(Operation)

View File

@ -175,8 +175,8 @@ vi.mock('@/app/components/base/features/hooks', () => ({
// ---------------------------------------------------------------------------
// Toast context
// ---------------------------------------------------------------------------
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({ notify: mockNotify, close: vi.fn() }),
vi.mock('@/app/components/base/ui/toast', () => ({
}))
// ---------------------------------------------------------------------------

View File

@ -1,28 +1,18 @@
import type { Theme } from '../../embedded-chatbot/theme/theme-context'
import type {
EnableType,
OnSend,
} from '../../types'
import type { EnableType, OnSend } from '../../types'
import type { InputForm } from '../type'
import type { FileUpload } from '@/app/components/base/features/types'
import { noop } from 'es-toolkit/function'
import { decode } from 'html-entities'
import Recorder from 'js-audio-recorder'
import {
useCallback,
useRef,
useState,
} from 'react'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Textarea from 'react-textarea-autosize'
import FeatureBar from '@/app/components/base/features/new-feature-panel/feature-bar'
import { FileListInChatInput } from '@/app/components/base/file-uploader'
import { useFile } from '@/app/components/base/file-uploader/hooks'
import {
FileContextProvider,
useFileStore,
} from '@/app/components/base/file-uploader/store'
import { useToastContext } from '@/app/components/base/toast/context'
import { FileContextProvider, useFileStore } from '@/app/components/base/file-uploader/store'
import { toast } from '@/app/components/base/ui/toast'
import VoiceInput from '@/app/components/base/voice-input'
import { TransferMethod } from '@/types/app'
import { cn } from '@/utils/classnames'
@ -53,71 +43,34 @@ type ChatInputAreaProps = {
*/
sendOnEnter?: boolean
}
const ChatInputArea = ({
readonly,
botName,
showFeatureBar,
showFileUpload,
featureBarDisabled,
onFeatureBarClick,
visionConfig,
speechToTextConfig = { enabled: true },
onSend,
inputs = {},
inputsForm = [],
theme,
isResponding,
disabled,
sendOnEnter = true,
}: ChatInputAreaProps) => {
const ChatInputArea = ({ readonly, botName, showFeatureBar, showFileUpload, featureBarDisabled, onFeatureBarClick, visionConfig, speechToTextConfig = { enabled: true }, onSend, inputs = {}, inputsForm = [], theme, isResponding, disabled, sendOnEnter = true }: ChatInputAreaProps) => {
const { t } = useTranslation()
const { notify } = useToastContext()
const {
wrapperRef,
textareaRef,
textValueRef,
holdSpaceRef,
handleTextareaResize,
isMultipleLine,
} = useTextAreaHeight()
const { wrapperRef, textareaRef, textValueRef, holdSpaceRef, handleTextareaResize, isMultipleLine } = useTextAreaHeight()
const [query, setQuery] = useState('')
const [showVoiceInput, setShowVoiceInput] = useState(false)
const filesStore = useFileStore()
const {
handleDragFileEnter,
handleDragFileLeave,
handleDragFileOver,
handleDropFile,
handleClipboardPasteFile,
isDragActive,
} = useFile(visionConfig!, false)
const { handleDragFileEnter, handleDragFileLeave, handleDragFileOver, handleDropFile, handleClipboardPasteFile, isDragActive } = useFile(visionConfig!, false)
const { checkInputsForm } = useCheckInputsForms()
const historyRef = useRef([''])
const [currentIndex, setCurrentIndex] = useState(-1)
const isComposingRef = useRef(false)
const handleQueryChange = useCallback(
(value: string) => {
setQuery(value)
setTimeout(handleTextareaResize, 0)
},
[handleTextareaResize],
)
const handleQueryChange = useCallback((value: string) => {
setQuery(value)
setTimeout(handleTextareaResize, 0)
}, [handleTextareaResize])
const handleSend = () => {
if (isResponding) {
notify({ type: 'info', message: t('errorMessage.waitForResponse', { ns: 'appDebug' }) })
toast.info(t('errorMessage.waitForResponse', { ns: 'appDebug' }))
return
}
if (onSend) {
const { files, setFiles } = filesStore.getState()
if (files.some(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId)) {
notify({ type: 'info', message: t('errorMessage.waitForFileUpload', { ns: 'appDebug' }) })
toast.info(t('errorMessage.waitForFileUpload', { ns: 'appDebug' }))
return
}
if (!query || !query.trim()) {
notify({ type: 'info', message: t('errorMessage.queryRequired', { ns: 'appAnnotation' }) })
toast.info(t('errorMessage.queryRequired', { ns: 'appAnnotation' }))
return
}
if (checkInputsForm(inputs, inputsForm)) {
@ -145,7 +98,6 @@ const ChatInputArea = ({
const isSendCombo = sendOnEnter
? (e.key === 'Enter' && !e.shiftKey)
: (e.key === 'Enter' && e.shiftKey)
if (isSendCombo && !e.nativeEvent.isComposing) {
// if isComposing, exit
if (isComposingRef.current)
@ -176,101 +128,36 @@ const ChatInputArea = ({
}
}
}
const handleShowVoiceInput = useCallback(() => {
(Recorder as any).getPermission().then(() => {
setShowVoiceInput(true)
}, () => {
notify({ type: 'error', message: t('voiceInput.notAllow', { ns: 'common' }) })
toast.error(t('voiceInput.notAllow', { ns: 'common' }))
})
}, [t, notify])
const operation = (
<Operation
ref={holdSpaceRef}
readonly={readonly}
fileConfig={visionConfig}
speechToTextConfig={speechToTextConfig}
onShowVoiceInput={handleShowVoiceInput}
onSend={handleSend}
theme={theme}
/>
)
}, [t])
const operation = (<Operation ref={holdSpaceRef} readonly={readonly} fileConfig={visionConfig} speechToTextConfig={speechToTextConfig} onShowVoiceInput={handleShowVoiceInput} onSend={handleSend} theme={theme} />)
return (
<>
<div
className={cn(
'relative z-10 overflow-hidden rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur pb-[9px] shadow-md',
isDragActive && 'border border-dashed border-components-option-card-option-selected-border',
disabled && 'pointer-events-none border-components-panel-border opacity-50 shadow-none',
)}
>
<div className={cn('relative z-10 overflow-hidden rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur pb-[9px] shadow-md', isDragActive && 'border border-dashed border-components-option-card-option-selected-border', disabled && 'pointer-events-none border-components-panel-border opacity-50 shadow-none')}>
<div className="relative max-h-[158px] overflow-y-auto overflow-x-hidden px-[9px] pt-[9px]">
<FileListInChatInput fileConfig={visionConfig!} />
<div
ref={wrapperRef}
className="flex items-center justify-between"
>
<div ref={wrapperRef} className="flex items-center justify-between">
<div className="relative flex w-full grow items-center">
<div
ref={textValueRef}
className="pointer-events-none invisible absolute h-auto w-auto whitespace-pre p-1 leading-6 body-lg-regular"
>
<div ref={textValueRef} className="pointer-events-none invisible absolute h-auto w-auto whitespace-pre p-1 leading-6 body-lg-regular">
{query}
</div>
<Textarea
ref={ref => textareaRef.current = ref as any}
className={cn(
'w-full resize-none bg-transparent p-1 leading-6 text-text-primary outline-none body-lg-regular',
)}
placeholder={decode(t(readonly ? 'chat.inputDisabledPlaceholder' : 'chat.inputPlaceholder', { ns: 'common', botName }) || '')}
autoFocus
minRows={1}
value={query}
onChange={e => handleQueryChange(e.target.value)}
onKeyDown={handleKeyDown}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onPaste={handleClipboardPasteFile}
onDragEnter={handleDragFileEnter}
onDragLeave={handleDragFileLeave}
onDragOver={handleDragFileOver}
onDrop={handleDropFile}
readOnly={readonly}
/>
<Textarea ref={ref => textareaRef.current = ref as any} className={cn('w-full resize-none bg-transparent p-1 leading-6 text-text-primary outline-none body-lg-regular')} placeholder={decode(t(readonly ? 'chat.inputDisabledPlaceholder' : 'chat.inputPlaceholder', { ns: 'common', botName }) || '')} autoFocus minRows={1} value={query} onChange={e => handleQueryChange(e.target.value)} onKeyDown={handleKeyDown} onCompositionStart={handleCompositionStart} onCompositionEnd={handleCompositionEnd} onPaste={handleClipboardPasteFile} onDragEnter={handleDragFileEnter} onDragLeave={handleDragFileLeave} onDragOver={handleDragFileOver} onDrop={handleDropFile} readOnly={readonly} />
</div>
{
!isMultipleLine && operation
}
{!isMultipleLine && operation}
</div>
{
showVoiceInput && (
<VoiceInput
onCancel={() => setShowVoiceInput(false)}
onConverted={text => handleQueryChange(text)}
/>
)
}
{showVoiceInput && (<VoiceInput onCancel={() => setShowVoiceInput(false)} onConverted={text => handleQueryChange(text)} />)}
</div>
{
isMultipleLine && (
<div className="px-[9px]">{operation}</div>
)
}
{isMultipleLine && (<div className="px-[9px]">{operation}</div>)}
</div>
{showFeatureBar && (
<FeatureBar
showFileUpload={showFileUpload}
disabled={featureBarDisabled}
onFeatureBarClick={readonly ? noop : onFeatureBarClick}
hideEditEntrance={readonly}
/>
)}
{showFeatureBar && (<FeatureBar showFileUpload={showFileUpload} disabled={featureBarDisabled} onFeatureBarClick={readonly ? noop : onFeatureBarClick} hideEditEntrance={readonly} />)}
</>
)
}
const ChatInputAreaWrapper = (props: ChatInputAreaProps) => {
return (
<FileContextProvider>
@ -278,5 +165,4 @@ const ChatInputAreaWrapper = (props: ChatInputAreaProps) => {
</FileContextProvider>
)
}
export default ChatInputAreaWrapper

View File

@ -1,30 +1,24 @@
import type { InputForm } from './type'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useToastContext } from '@/app/components/base/toast/context'
import { toast } from '@/app/components/base/ui/toast'
import { InputVarType } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
export const useCheckInputsForms = () => {
const { t } = useTranslation()
const { notify } = useToastContext()
const checkInputsForm = useCallback((inputs: Record<string, any>, inputsForm: InputForm[]) => {
let hasEmptyInput = ''
let fileIsUploading = false
const requiredVars = inputsForm.filter(({ required, type }) => required && type !== InputVarType.checkbox) // boolean can be not checked
if (requiredVars?.length) {
requiredVars.forEach(({ variable, label, type }) => {
if (hasEmptyInput)
return
if (fileIsUploading)
return
if (!inputs[variable])
hasEmptyInput = label as string
if ((type === InputVarType.singleFile || type === InputVarType.multiFiles) && inputs[variable]) {
const files = inputs[variable]
if (Array.isArray(files))
@ -34,20 +28,16 @@ export const useCheckInputsForms = () => {
}
})
}
if (hasEmptyInput) {
notify({ type: 'error', message: t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: hasEmptyInput }) })
toast.error(t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: hasEmptyInput }))
return false
}
if (fileIsUploading) {
notify({ type: 'info', message: t('errorMessage.waitForFileUpload', { ns: 'appDebug' }) })
toast.info(t('errorMessage.waitForFileUpload', { ns: 'appDebug' }))
return
}
return true
}, [notify, t])
}, [t])
return {
checkInputsForm,
}

View File

@ -24,10 +24,8 @@ vi.mock('@/hooks/use-timestamp', () => ({
}),
}))
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({
notify: vi.fn(),
}),
vi.mock('@/app/components/base/ui/toast', () => ({
}))
vi.mock('@/app/components/base/audio-btn/audio.player.manager', () => ({

View File

@ -29,7 +29,7 @@ import {
getProcessedFiles,
getProcessedFilesFromResponse,
} from '@/app/components/base/file-uploader/utils'
import { useToastContext } from '@/app/components/base/toast/context'
import { toast } from '@/app/components/base/ui/toast'
import { NodeRunningStatus, WorkflowRunningStatus } from '@/app/components/workflow/types'
import useTimestamp from '@/hooks/use-timestamp'
import { useParams, usePathname } from '@/next/navigation'
@ -65,7 +65,6 @@ export const useChat = (
) => {
const { t } = useTranslation()
const { formatTime } = useTimestamp()
const { notify } = useToastContext()
const conversationIdRef = useRef('')
const hasStopRespondedRef = useRef(false)
const [isResponding, setIsResponding] = useState(false)
@ -637,7 +636,7 @@ export const useChat = (
setSuggestedQuestions([])
if (isRespondingRef.current) {
notify({ type: 'info', message: t('errorMessage.waitForResponse', { ns: 'appDebug' }) })
toast.info(t('errorMessage.waitForResponse', { ns: 'appDebug' }))
return false
}
@ -1269,7 +1268,6 @@ export const useChat = (
config?.suggested_questions_after_answer,
updateCurrentQAOnTree,
updateChatTreeNode,
notify,
handleResponding,
formatTime,
createAudioPlayerManager,

View File

@ -1,25 +1,16 @@
import type {
FC,
ReactNode,
} from 'react'
import type { FC, ReactNode } from 'react'
import type { Theme } from '../embedded-chatbot/theme/theme-context'
import type { ChatItem } from '../types'
import copy from 'copy-to-clipboard'
import {
memo,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Textarea from 'react-textarea-autosize'
import { FileList } from '@/app/components/base/file-uploader'
import { Markdown } from '@/app/components/base/markdown'
import { toast } from '@/app/components/base/ui/toast'
import { cn } from '@/utils/classnames'
import ActionButton from '../../action-button'
import Button from '../../button'
import Toast from '../../toast'
import { CssTransform } from '../embedded-chatbot/theme/utils'
import ContentSwitch from './content-switch'
import { useChatContext } from './context'
@ -32,38 +23,20 @@ type QuestionProps = {
switchSibling?: (siblingMessageId: string) => void
hideAvatar?: boolean
}
const Question: FC<QuestionProps> = ({
item,
questionIcon,
theme,
enableEdit = true,
switchSibling,
hideAvatar,
}) => {
const Question: FC<QuestionProps> = ({ item, questionIcon, theme, enableEdit = true, switchSibling, hideAvatar }) => {
const { t } = useTranslation()
const {
content,
message_files,
} = item
const {
onRegenerate,
} = useChatContext()
const { content, message_files } = item
const { onRegenerate } = useChatContext()
const [isEditing, setIsEditing] = useState(false)
const [editedContent, setEditedContent] = useState(content)
const [contentWidth, setContentWidth] = useState(0)
const contentRef = useRef<HTMLDivElement>(null)
const isComposingRef = useRef(false)
const compositionEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const handleEdit = useCallback(() => {
setIsEditing(true)
setEditedContent(content)
}, [content])
const handleResend = useCallback(() => {
if (compositionEndTimerRef.current) {
clearTimeout(compositionEndTimerRef.current)
@ -73,7 +46,6 @@ const Question: FC<QuestionProps> = ({
setIsEditing(false)
onRegenerate?.(item, { message: editedContent, files: message_files })
}, [editedContent, message_files, item, onRegenerate])
const handleCancelEditing = useCallback(() => {
if (compositionEndTimerRef.current) {
clearTimeout(compositionEndTimerRef.current)
@ -83,36 +55,28 @@ const Question: FC<QuestionProps> = ({
setIsEditing(false)
setEditedContent(content)
}, [content])
const handleEditInputKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key !== 'Enter' || e.shiftKey)
return
if (e.nativeEvent.isComposing)
return
if (isComposingRef.current) {
e.preventDefault()
return
}
e.preventDefault()
handleResend()
}, [handleResend])
const clearCompositionEndTimer = useCallback(() => {
if (!compositionEndTimerRef.current)
return
clearTimeout(compositionEndTimerRef.current)
compositionEndTimerRef.current = null
}, [])
const handleCompositionStart = useCallback(() => {
clearCompositionEndTimer()
isComposingRef.current = true
}, [clearCompositionEndTimer])
const handleCompositionEnd = useCallback(() => {
clearCompositionEndTimer()
compositionEndTimerRef.current = setTimeout(() => {
@ -120,7 +84,6 @@ const Question: FC<QuestionProps> = ({
compositionEndTimerRef.current = null
}, 50)
}, [clearCompositionEndTimer])
const handleSwitchSibling = useCallback((direction: 'prev' | 'next') => {
if (direction === 'prev') {
if (item.prevSibling)
@ -131,13 +94,11 @@ const Question: FC<QuestionProps> = ({
switchSibling?.(item.nextSibling)
}
}, [switchSibling, item.prevSibling, item.nextSibling])
const getContentWidth = () => {
/* v8 ignore next 2 -- @preserve */
if (contentRef.current)
setContentWidth(contentRef.current?.clientWidth)
}
useEffect(() => {
/* v8 ignore next 2 -- @preserve */
if (!contentRef.current)
@ -150,27 +111,21 @@ const Question: FC<QuestionProps> = ({
resizeObserver.disconnect()
}
}, [])
useEffect(() => {
return () => {
clearCompositionEndTimer()
}
}, [clearCompositionEndTimer])
return (
<div className="mb-2 flex justify-end last:mb-0">
<div className={cn('group relative mr-4 flex max-w-full items-start overflow-x-hidden pl-14', isEditing && 'flex-1')}>
<div className={cn('mr-2 gap-1', isEditing ? 'hidden' : 'flex')}>
<div
data-testid="action-container"
className="absolute hidden 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"
style={{ right: contentWidth + 8 }}
>
<div data-testid="action-container" className="absolute hidden 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" style={{ right: contentWidth + 8 }}>
<ActionButton
data-testid="copy-btn"
onClick={() => {
copy(content)
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
toast.success(t('actionMsg.copySuccessfully', { ns: 'common' }))
}}
>
<div className="i-ri-clipboard-line h-4 w-4" />
@ -182,43 +137,14 @@ const Question: FC<QuestionProps> = ({
)}
</div>
</div>
<div
ref={contentRef}
data-testid="question-content"
className={cn(
'w-full px-4 py-3 text-sm',
!isEditing && 'rounded-2xl bg-background-gradient-bg-fill-chat-bubble-bg-3 text-text-primary',
isEditing && 'rounded-[24px] border-[3px] border-components-option-card-option-selected-border bg-components-panel-bg-blur shadow-lg',
)}
style={(!isEditing && theme?.chatBubbleColorStyle) ? CssTransform(theme.chatBubbleColorStyle) : {}}
>
{
!!message_files?.length && (
<FileList
className={cn(isEditing ? 'mb-3' : 'mb-2')}
files={message_files}
showDeleteAction={false}
showDownloadAction={true}
/>
)
}
<div ref={contentRef} data-testid="question-content" className={cn('w-full px-4 py-3 text-sm', !isEditing && 'rounded-2xl bg-background-gradient-bg-fill-chat-bubble-bg-3 text-text-primary', isEditing && 'rounded-[24px] border-[3px] border-components-option-card-option-selected-border bg-components-panel-bg-blur shadow-lg')} style={(!isEditing && theme?.chatBubbleColorStyle) ? CssTransform(theme.chatBubbleColorStyle) : {}}>
{!!message_files?.length && (<FileList className={cn(isEditing ? 'mb-3' : 'mb-2')} files={message_files} showDeleteAction={false} showDownloadAction={true} />)}
{!isEditing
? <Markdown content={content} />
: (
<div className="flex flex-col gap-4">
<div className="max-h-[158px] overflow-y-auto overflow-x-hidden pr-1">
<Textarea
className={cn(
'w-full resize-none bg-transparent p-0 leading-7 text-text-primary outline-none body-lg-regular',
)}
autoFocus
minRows={1}
value={editedContent}
onChange={e => setEditedContent(e.target.value)}
onKeyDown={handleEditInputKeyDown}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
/>
<Textarea className={cn('w-full resize-none bg-transparent p-0 leading-7 text-text-primary outline-none body-lg-regular')} autoFocus minRows={1} value={editedContent} onChange={e => setEditedContent(e.target.value)} onKeyDown={handleEditInputKeyDown} onCompositionStart={handleCompositionStart} onCompositionEnd={handleCompositionEnd} />
</div>
<div className="flex items-center justify-end gap-2">
<Button className="min-w-24" onClick={handleCancelEditing} data-testid="cancel-edit-btn">{t('operation.cancel', { ns: 'common' })}</Button>
@ -226,31 +152,20 @@ const Question: FC<QuestionProps> = ({
</div>
</div>
)}
{!isEditing && (
<ContentSwitch
count={item.siblingCount}
currentIndex={item.siblingIndex}
prevDisabled={!item.prevSibling}
nextDisabled={!item.nextSibling}
switchSibling={handleSwitchSibling}
/>
)}
{!isEditing && (<ContentSwitch count={item.siblingCount} currentIndex={item.siblingIndex} prevDisabled={!item.prevSibling} nextDisabled={!item.nextSibling} switchSibling={handleSwitchSibling} />)}
</div>
<div className="mt-1 h-[18px]" />
</div>
{!hideAvatar && (
<div className="h-10 w-10 shrink-0">
{
questionIcon || (
<div className="h-full w-full rounded-full border-[0.5px] border-black/5">
<div className="i-custom-public-avatar-user h-full w-full" />
</div>
)
}
{questionIcon || (
<div className="h-full w-full rounded-full border-[0.5px] border-black/5">
<div className="i-custom-public-avatar-user h-full w-full" />
</div>
)}
</div>
)}
</div>
)
}
export default memo(Question)

View File

@ -3,7 +3,7 @@ import type { ChatConfig } from '../../types'
import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook, waitFor } from '@testing-library/react'
import { ToastProvider } from '@/app/components/base/toast'
import { ToastHost } from '@/app/components/base/ui/toast'
import { InputVarType } from '@/app/components/workflow/types'
import {
AppSourceType,
@ -109,7 +109,8 @@ const createQueryClient = () => new QueryClient({
const createWrapper = (queryClient: QueryClient) => {
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
<ToastProvider>{children}</ToastProvider>
<ToastHost />
{children}
</QueryClientProvider>
)
}

View File

@ -1,38 +1,20 @@
import type { ChatConfig, ChatItem, Feedback } from '../types'
/* eslint-disable ts/no-explicit-any */
import type {
ChatConfig,
ChatItem,
Feedback,
} from '../types'
import type { InputValueTypes } from '@/app/components/share/text-generation/types'
import type { Locale } from '@/i18n-config'
import type {
AppData,
ConversationItem,
} from '@/models/share'
import type { AppData, ConversationItem } from '@/models/share'
import { useLocalStorageState } from 'ahooks'
import { noop } from 'es-toolkit/function'
import { produce } from 'immer'
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useToastContext } from '@/app/components/base/toast/context'
import { toast } from '@/app/components/base/ui/toast'
import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils'
import { InputVarType } from '@/app/components/workflow/types'
import { useWebAppStore } from '@/context/web-app-context'
import { changeLanguage } from '@/i18n-config/client'
import { AppSourceType, updateFeedback } from '@/service/share'
import {
useInvalidateShareConversations,
useShareChatList,
useShareConversationName,
useShareConversations,
} from '@/service/use-share'
import { useInvalidateShareConversations, useShareChatList, useShareConversationName, useShareConversations } from '@/service/use-share'
import { useGetTryAppInfo, useGetTryAppParams } from '@/service/use-try-app'
import { TransferMethod } from '@/types/app'
import { getProcessedFilesFromResponse } from '../../file-uploader/utils'
@ -64,7 +46,6 @@ function getFormattedChatList(messages: any[]) {
})
return newChatList
}
export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: string) => {
const isInstalledApp = false // just can be webapp and try app
const isTryApp = appSourceType === AppSourceType.tryApp
@ -75,17 +56,13 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri
const { data: tryAppParams } = useGetTryAppParams(isTryApp ? tryAppId! : '')
const webAppParams = useWebAppStore(s => s.appParams)
const appParams = isTryApp ? tryAppParams : webAppParams
const appId = useMemo(() => {
return isTryApp ? tryAppId : (appInfo as any)?.app_id
}, [appInfo, isTryApp, tryAppId])
const embeddedConversationId = useWebAppStore(s => s.embeddedConversationId)
const embeddedUserId = useWebAppStore(s => s.embeddedUserId)
const [userId, setUserId] = useState<string>()
const [conversationId, setConversationId] = useState<string>()
useEffect(() => {
if (isTryApp)
return
@ -94,15 +71,12 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri
setConversationId(conversation_id)
})
}, [])
useEffect(() => {
setUserId(embeddedUserId || undefined)
}, [embeddedUserId])
useEffect(() => {
setConversationId(embeddedConversationId || undefined)
}, [embeddedConversationId])
useEffect(() => {
if (isTryApp)
return
@ -110,11 +84,9 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri
// Check URL parameters for language override
const urlParams = new URLSearchParams(window.location.search)
const localeParam = urlParams.get('locale')
// Check for encoded system variables
const systemVariables = await getProcessedSystemVariablesFromUrlParams()
const localeFromSysVar = systemVariables.locale
if (localeParam) {
// If locale parameter exists in URL, use it instead of default
await changeLanguage(localeParam as Locale)
@ -128,10 +100,8 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri
await changeLanguage((appInfo as unknown as AppData).site?.default_language)
}
}
setLanguageFromParams()
}, [appInfo])
const [conversationIdInfo, setConversationIdInfo] = useLocalStorageState<Record<string, Record<string, string>>>(CONVERSATION_ID_INFO, {
defaultValue: {},
})
@ -158,51 +128,36 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri
})
}
}, [appId, conversationIdInfo, setConversationIdInfo, userId])
const [newConversationId, setNewConversationId] = useState('')
const chatShouldReloadKey = useMemo(() => {
if (currentConversationId === newConversationId)
return ''
return currentConversationId
}, [currentConversationId, newConversationId])
const { data: appPinnedConversationData } = useShareConversations({
appSourceType,
appId,
pinned: true,
limit: 100,
})
const {
data: appConversationData,
isLoading: appConversationDataLoading,
} = useShareConversations({
const { data: appConversationData, isLoading: appConversationDataLoading } = useShareConversations({
appSourceType,
appId,
pinned: false,
limit: 100,
})
const {
data: appChatListData,
isLoading: appChatListDataLoading,
} = useShareChatList({
const { data: appChatListData, isLoading: appChatListDataLoading } = useShareChatList({
conversationId: chatShouldReloadKey,
appSourceType,
appId,
})
const invalidateShareConversations = useInvalidateShareConversations()
const [clearChatList, setClearChatList] = useState(false)
const [isResponding, setIsResponding] = useState(false)
const appPrevChatList = useMemo(
() => (currentConversationId && appChatListData?.data.length)
? buildChatItemTree(getFormattedChatList(appChatListData.data))
: [],
[appChatListData, currentConversationId],
)
const appPrevChatList = useMemo(() => (currentConversationId && appChatListData?.data.length)
? buildChatItemTree(getFormattedChatList(appChatListData.data))
: [], [appChatListData, currentConversationId])
const [showNewConversationItemInList, setShowNewConversationItemInList] = useState(false)
const pinnedConversationList = useMemo(() => {
return appPinnedConversationData?.data || []
}, [appPinnedConversationData])
@ -222,7 +177,6 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri
let value = initInputs[item.paragraph.variable]
if (value && item.paragraph.max_length && value.length > item.paragraph.max_length)
value = value.slice(0, item.paragraph.max_length)
return {
...item.paragraph,
default: value || item.default || item.paragraph.default,
@ -237,7 +191,6 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri
type: 'number',
}
}
if (item.checkbox) {
const preset = initInputs[item.checkbox.variable] === true
return {
@ -246,7 +199,6 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri
type: 'checkbox',
}
}
if (item.select) {
const isInputInOptions = item.select.options.includes(initInputs[item.select.variable])
return {
@ -255,32 +207,27 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri
type: 'select',
}
}
if (item['file-list']) {
return {
...item['file-list'],
type: 'file-list',
}
}
if (item.file) {
return {
...item.file,
type: 'file',
}
}
if (item.json_object) {
return {
...item.json_object,
type: 'json_object',
}
}
let value = initInputs[item['text-input'].variable]
if (value && item['text-input'].max_length && value.length > item['text-input'].max_length)
value = value.slice(0, item['text-input'].max_length)
return {
...item['text-input'],
default: value || item.default || item['text-input'].default,
@ -288,11 +235,9 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri
}
})
}, [initInputs, appParams])
const allInputsHidden = useMemo(() => {
return inputsForms.length > 0 && inputsForms.every(item => item.hide === true)
}, [inputsForms])
useEffect(() => {
// init inputs from url params
(async () => {
@ -306,13 +251,11 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri
}, [])
useEffect(() => {
const conversationInputs: Record<string, InputValueTypes> = {}
inputsForms.forEach((item) => {
conversationInputs[item.variable] = item.default || null
})
handleNewConversationInputsChange(conversationInputs)
}, [handleNewConversationInputsChange, inputsForms])
const { data: newConversation } = useShareConversationName({
conversationId: newConversationId,
appSourceType,
@ -324,12 +267,11 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri
const [originConversationList, setOriginConversationList] = useState<ConversationItem[]>([])
useEffect(() => {
if (appConversationData?.data && !appConversationDataLoading)
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
setOriginConversationList(appConversationData?.data)
}, [appConversationData, appConversationDataLoading])
const conversationList = useMemo(() => {
const data = originConversationList.slice()
if (showNewConversationItemInList && data[0]?.id !== '') {
data.unshift({
id: '',
@ -340,12 +282,10 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri
}
return data
}, [originConversationList, showNewConversationItemInList, t])
useEffect(() => {
if (newConversation) {
setOriginConversationList(produce((draft) => {
const index = draft.findIndex(item => item.id === newConversation.id)
if (index > -1)
draft[index] = newConversation
else
@ -353,16 +293,12 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri
}))
}
}, [newConversation])
const currentConversationItem = useMemo(() => {
let conversationItem = conversationList.find(item => item.id === currentConversationId)
if (!conversationItem && pinnedConversationList.length)
conversationItem = pinnedConversationList.find(item => item.id === currentConversationId)
return conversationItem
}, [conversationList, currentConversationId, pinnedConversationList])
const currentConversationLatestInputs = useMemo(() => {
if (!currentConversationId || !appChatListData?.data.length)
return newConversationInputsRef.current || {}
@ -371,15 +307,12 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri
const [currentConversationInputs, setCurrentConversationInputs] = useState<Record<string, any>>(currentConversationLatestInputs || {})
useEffect(() => {
if (currentConversationItem && !isTryApp)
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
setCurrentConversationInputs(currentConversationLatestInputs || {})
}, [currentConversationItem, currentConversationLatestInputs])
const { notify } = useToastContext()
const checkInputsRequired = useCallback((silent?: boolean) => {
if (allInputsHidden)
return true
let hasEmptyInput = ''
let fileIsUploading = false
const requiredVars = inputsForms.filter(({ required, type }) => required && type !== InputVarType.checkbox)
@ -387,13 +320,10 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri
requiredVars.forEach(({ variable, label, type }) => {
if (hasEmptyInput)
return
if (fileIsUploading)
return
if (!newConversationInputsRef.current[variable] && !silent)
hasEmptyInput = label as string
if ((type === InputVarType.singleFile || type === InputVarType.multiFiles) && newConversationInputsRef.current[variable] && !silent) {
const files = newConversationInputsRef.current[variable]
if (Array.isArray(files))
@ -403,26 +333,25 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri
}
})
}
if (hasEmptyInput) {
notify({ type: 'error', message: t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: hasEmptyInput }) })
toast.error(t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: hasEmptyInput }))
return false
}
if (fileIsUploading) {
notify({ type: 'info', message: t('errorMessage.waitForFileUpload', { ns: 'appDebug' }) })
toast.info(t('errorMessage.waitForFileUpload', { ns: 'appDebug' }))
return
}
return true
}, [inputsForms, notify, t, allInputsHidden])
}, [inputsForms, t, allInputsHidden])
const handleStartChat = useCallback((callback?: () => void) => {
if (checkInputsRequired()) {
setShowNewConversationItemInList(true)
callback?.()
}
}, [setShowNewConversationItemInList, checkInputsRequired])
const currentChatInstanceRef = useRef<{ handleStop: () => void }>({ handleStop: noop })
const currentChatInstanceRef = useRef<{
handleStop: () => void
}>({ handleStop: noop })
const handleChangeConversation = useCallback((conversationId: string) => {
currentChatInstanceRef.current.handleStop()
setNewConversationId('')
@ -435,26 +364,22 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri
setClearChatList(true)
return
}
currentChatInstanceRef.current.handleStop()
setShowNewConversationItemInList(true)
handleChangeConversation('')
handleNewConversationInputsChange(await getProcessedInputsFromUrlParams())
setClearChatList(true)
}, [isTryApp, setShowNewConversationItemInList, handleNewConversationInputsChange, setClearChatList])
const handleNewConversationCompleted = useCallback((newConversationId: string) => {
setNewConversationId(newConversationId)
handleConversationIdInfoChange(newConversationId)
setShowNewConversationItemInList(false)
invalidateShareConversations()
}, [handleConversationIdInfoChange, invalidateShareConversations])
const handleFeedback = useCallback(async (messageId: string, feedback: Feedback) => {
await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating, content: feedback.content } }, appSourceType, appId)
notify({ type: 'success', message: t('api.success', { ns: 'common' }) })
}, [appSourceType, appId, t, notify])
toast.success(t('api.success', { ns: 'common' }))
}, [appSourceType, appId, t])
return {
appSourceType,
isInstalledApp,

View File

@ -16,8 +16,8 @@ vi.mock('@/next/navigation', () => ({
useSearchParams: () => new URLSearchParams(),
}))
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({ notify: vi.fn() }),
vi.mock('@/app/components/base/ui/toast', () => ({
}))
// Mock CodeEditor to trigger onChange easily