chore: replace chat in web app (#2373)

This commit is contained in:
zxhlyh
2024-02-04 16:10:46 +08:00
committed by GitHub
parent 3f0c515355
commit 51d359268e
49 changed files with 2100 additions and 92 deletions

View File

@@ -0,0 +1,141 @@
import { useCallback, useEffect, useMemo } from 'react'
import Chat from '../chat'
import type {
ChatConfig,
OnSend,
} from '../types'
import { useChat } from '../chat/hooks'
import { useChatWithHistoryContext } from './context'
import Header from './header'
import ConfigPanel from './config-panel'
import {
fetchSuggestedQuestions,
getUrl,
} from '@/service/share'
const ChatWrapper = () => {
const {
appParams,
appPrevChatList,
currentConversationId,
currentConversationItem,
inputsForms,
newConversationInputs,
handleNewConversationCompleted,
isMobile,
isInstalledApp,
appId,
appMeta,
handleFeedback,
currentChatInstanceRef,
} = useChatWithHistoryContext()
const appConfig = useMemo(() => {
const config = appParams || {}
return {
...config,
supportFeedback: true,
} as ChatConfig
}, [appParams])
const {
chatList,
handleSend,
handleStop,
isResponsing,
suggestedQuestions,
} = useChat(
appConfig,
undefined,
appPrevChatList,
)
useEffect(() => {
if (currentChatInstanceRef.current)
currentChatInstanceRef.current.handleStop = handleStop
}, [])
const doSend: OnSend = useCallback((message, files) => {
const data: any = {
query: message,
inputs: currentConversationId ? currentConversationItem?.inputs : newConversationInputs,
conversation_id: currentConversationId,
}
if (appConfig?.file_upload?.image.enabled && files?.length)
data.files = files
handleSend(
getUrl('chat-messages', isInstalledApp, appId || ''),
data,
{
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, isInstalledApp, appId),
onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
isPublicAPI: !isInstalledApp,
},
)
}, [
appConfig,
currentConversationId,
currentConversationItem,
handleSend,
newConversationInputs,
handleNewConversationCompleted,
isInstalledApp,
appId,
])
const chatNode = useMemo(() => {
if (inputsForms.length) {
return (
<>
<Header
isMobile={isMobile}
title={currentConversationItem?.name || ''}
/>
{
!currentConversationId && (
<div className={`mx-auto w-full max-w-[720px] ${isMobile && 'px-4'}`}>
<div className='mb-6' />
<ConfigPanel />
<div
className='my-6 h-[1px]'
style={{ background: 'linear-gradient(90deg, rgba(242, 244, 247, 0.00) 0%, #F2F4F7 49.17%, rgba(242, 244, 247, 0.00) 100%)' }}
/>
</div>
)
}
</>
)
}
return (
<Header
isMobile={isMobile}
title={currentConversationItem?.name || ''}
/>
)
}, [
currentConversationId,
inputsForms,
currentConversationItem,
isMobile,
])
return (
<Chat
config={appConfig}
chatList={chatList}
isResponsing={isResponsing}
chatContainerInnerClassName={`mx-auto pt-6 w-full max-w-[720px] ${isMobile && 'px-4'}`}
chatFooterClassName='pb-4'
chatFooterInnerClassName={`mx-auto w-full max-w-[720px] ${isMobile && 'px-4'}`}
onSend={doSend}
onStopResponding={handleStop}
chatNode={chatNode}
allToolIcons={appMeta?.tool_icons || {}}
onFeedback={handleFeedback}
suggestedQuestions={suggestedQuestions}
/>
)
}
export default ChatWrapper

View File

@@ -0,0 +1,82 @@
import { useTranslation } from 'react-i18next'
import { useChatWithHistoryContext } from '../context'
import { PortalSelect } from '@/app/components/base/select'
const Form = () => {
const { t } = useTranslation()
const {
inputsForms,
newConversationInputs,
handleNewConversationInputsChange,
isMobile,
} = useChatWithHistoryContext()
const handleFormChange = (variable: string, value: string) => {
handleNewConversationInputsChange({
...newConversationInputs,
[variable]: value,
})
}
const renderField = (form: any) => {
const {
label,
required,
max_length,
variable,
options,
} = form
if (form.type === 'text-input') {
return (
<input
className='grow h-9 rounded-lg bg-gray-100 px-2.5 outline-none appearance-none'
value={newConversationInputs[variable] || ''}
maxLength={max_length}
onChange={e => handleFormChange(variable, e.target.value)}
placeholder={`${label}${!required ? `(${t('appDebug.variableTable.optional')})` : ''}`}
/>
)
}
if (form.type === 'paragraph') {
return (
<textarea
value={newConversationInputs[variable]}
className='grow h-[104px] rounded-lg bg-gray-100 px-2.5 py-2 outline-none appearance-none resize-none'
onChange={e => handleFormChange(variable, e.target.value)}
placeholder={`${label}${!required ? `(${t('appDebug.variableTable.optional')})` : ''}`}
/>
)
}
return (
<PortalSelect
popupClassName='w-[200px]'
value={newConversationInputs[variable]}
items={options.map((option: string) => ({ value: option, name: option }))}
onSelect={item => handleFormChange(variable, item.value as string)}
placeholder={`${label}${!required ? `(${t('appDebug.variableTable.optional')})` : ''}`}
/>
)
}
if (!inputsForms.length)
return null
return (
<div className='mb-4 py-2'>
{
inputsForms.map(form => (
<div
key={form.variable}
className={`flex mb-3 last-of-type:mb-0 text-sm text-gray-900 ${isMobile && '!flex-wrap'}`}
>
<div className={`shrink-0 mr-2 py-2 w-[128px] ${isMobile && '!w-full'}`}>{form.label}</div>
{renderField(form)}
</div>
))
}
</div>
)
}
export default Form

View File

@@ -0,0 +1,158 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useChatWithHistoryContext } from '../context'
import Form from './form'
import Button from '@/app/components/base/button'
import { MessageDotsCircle } from '@/app/components/base/icons/src/vender/solid/communication'
import { Edit02 } from '@/app/components/base/icons/src/vender/line/general'
import { Star06 } from '@/app/components/base/icons/src/vender/solid/shapes'
import { FootLogo } from '@/app/components/share/chat/welcome/massive-component'
const ConfigPanel = () => {
const { t } = useTranslation()
const {
appData,
inputsForms,
handleStartChat,
showConfigPanelBeforeChat,
isMobile,
} = useChatWithHistoryContext()
const [collapsed, setCollapsed] = useState(true)
const customConfig = appData?.custom_config
const site = appData?.site
return (
<div className='flex flex-col max-h-[80%] w-full max-w-[720px]'>
<div
className={`
grow rounded-xl overflow-y-auto
${showConfigPanelBeforeChat && 'border-[0.5px] border-gray-100 shadow-lg'}
${!showConfigPanelBeforeChat && collapsed && 'border border-indigo-100'}
${!showConfigPanelBeforeChat && !collapsed && 'border-[0.5px] border-gray-100 shadow-lg'}
`}
>
<div
className={`
flex flex-wrap px-6 py-4 rounded-t-xl bg-indigo-25
${isMobile && '!px-4 !py-3'}
`}
>
{
showConfigPanelBeforeChat && (
<>
<div className='flex items-center text-2xl font-semibold text-gray-800'>
{appData?.site.icon} {appData?.site.title}
</div>
{
appData?.site.description && (
<div className='mt-2 w-full text-sm text-gray-500'>
{appData?.site.description}
</div>
)
}
</>
)
}
{
!showConfigPanelBeforeChat && collapsed && (
<>
<Star06 className='mr-1 mt-1 w-4 h-4 text-indigo-600' />
<div className='grow py-[3px] text-[13px] text-indigo-600 leading-[18px] font-medium'>
{t('share.chat.configStatusDes')}
</div>
<Button
className='shrink-0 px-2 py-0 h-6 bg-white text-xs font-medium text-primary-600 rounded-md'
onClick={() => setCollapsed(false)}
>
<Edit02 className='mr-1 w-3 h-3' />
{t('common.operation.edit')}
</Button>
</>
)
}
{
!showConfigPanelBeforeChat && !collapsed && (
<>
<Star06 className='mr-1 mt-1 w-4 h-4 text-indigo-600' />
<div className='grow py-[3px] text-[13px] text-indigo-600 leading-[18px] font-medium'>
{t('share.chat.privatePromptConfigTitle')}
</div>
</>
)
}
</div>
{
!collapsed && !showConfigPanelBeforeChat && (
<div className='p-6 rounded-b-xl'>
<Form />
<div className={`pl-[136px] flex items-center ${isMobile && '!pl-0'}`}>
<Button
type='primary'
className='mr-2 text-sm font-medium'
onClick={handleStartChat}
>
{t('common.operation.save')}
</Button>
<Button
className='text-sm font-medium'
onClick={() => setCollapsed(true)}
>
{t('common.operation.cancel')}
</Button>
</div>
</div>
)
}
{
showConfigPanelBeforeChat && (
<div className='p-6 rounded-b-xl'>
<Form />
<Button
className={`px-4 py-0 h-9 ${inputsForms.length && !isMobile && 'ml-[136px]'}`}
type='primary'
onClick={handleStartChat}
>
<MessageDotsCircle className='mr-2 w-4 h-4 text-white' />
{t('share.chat.startChat')}
</Button>
</div>
)
}
</div>
{
showConfigPanelBeforeChat && (site || customConfig) && (
<div className='mt-4 flex flex-wrap justify-between items-center py-2 text-xs text-gray-400'>
{site?.privacy_policy
? <div className={`flex items-center ${isMobile && 'w-full justify-end'}`}>{t('share.chat.privacyPolicyLeft')}
<a
className='text-gray-500'
href={site?.privacy_policy}
target='_blank' rel='noopener noreferrer'>{t('share.chat.privacyPolicyMiddle')}</a>
{t('share.chat.privacyPolicyRight')}
</div>
: <div>
</div>}
{
customConfig?.remove_webapp_brand
? null
: (
<div className={`flex items-center justify-end ${isMobile && 'w-full'}`}>
<a className='flex items-center pr-3 space-x-3' href="https://dify.ai/" target="_blank">
<span className='uppercase'>{t('share.chat.powerBy')}</span>
{
customConfig?.replace_webapp_logo
? <img src={customConfig?.replace_webapp_logo} alt='logo' className='block w-auto h-5' />
: <FootLogo />
}
</a>
</div>
)
}
</div>
)
}
</div>
)
}
export default ConfigPanel

View File

@@ -0,0 +1,74 @@
'use client'
import type { RefObject } from 'react'
import { createContext, useContext } from 'use-context-selector'
import type {
Callback,
ChatConfig,
ChatItem,
Feedback,
} from '../types'
import type {
AppConversationData,
AppData,
AppMeta,
ConversationItem,
} from '@/models/share'
export type ChatWithHistoryContextValue = {
appInfoLoading?: boolean
appMeta?: AppMeta
appData?: AppData
appParams?: ChatConfig
appChatListDataLoading?: boolean
currentConversationId: string
currentConversationItem?: ConversationItem
appPrevChatList: ChatItem[]
pinnedConversationList: AppConversationData['data']
conversationList: AppConversationData['data']
showConfigPanelBeforeChat: boolean
newConversationInputs: Record<string, any>
handleNewConversationInputsChange: (v: Record<string, any>) => void
inputsForms: any[]
handleNewConversation: () => void
handleStartChat: () => void
handleChangeConversation: (conversationId: string) => void
handlePinConversation: (conversationId: string) => void
handleUnpinConversation: (conversationId: string) => void
handleDeleteConversation: (conversationId: string, callback: Callback) => void
conversationRenaming: boolean
handleRenameConversation: (conversationId: string, newName: string, callback: Callback) => void
handleNewConversationCompleted: (newConversationId: string) => void
chatShouldReloadKey: string
isMobile: boolean
isInstalledApp: boolean
appId?: string
handleFeedback: (messageId: string, feedback: Feedback) => void
currentChatInstanceRef: RefObject<{ handleStop: () => void }>
}
export const ChatWithHistoryContext = createContext<ChatWithHistoryContextValue>({
currentConversationId: '',
appPrevChatList: [],
pinnedConversationList: [],
conversationList: [],
showConfigPanelBeforeChat: false,
newConversationInputs: {},
handleNewConversationInputsChange: () => {},
inputsForms: [],
handleNewConversation: () => {},
handleStartChat: () => {},
handleChangeConversation: () => {},
handlePinConversation: () => {},
handleUnpinConversation: () => {},
handleDeleteConversation: () => {},
conversationRenaming: false,
handleRenameConversation: () => {},
handleNewConversationCompleted: () => {},
chatShouldReloadKey: '',
isMobile: false,
isInstalledApp: false,
handleFeedback: () => {},
currentChatInstanceRef: { current: { handleStop: () => {} } },
})
export const useChatWithHistoryContext = () => useContext(ChatWithHistoryContext)

View File

@@ -0,0 +1,60 @@
import { useState } from 'react'
import { useChatWithHistoryContext } from './context'
import Sidebar from './sidebar'
import AppIcon from '@/app/components/base/app-icon'
import {
Edit05,
Menu01,
} from '@/app/components/base/icons/src/vender/line/general'
const HeaderInMobile = () => {
const {
appData,
handleNewConversation,
} = useChatWithHistoryContext()
const [showSidebar, setShowSidebar] = useState(false)
return (
<>
<div className='shrink-0 flex items-center px-3 h-[44px] border-b-[0.5px] border-b-gray-200'>
<div
className='shrink-0 flex items-center justify-center w-8 h-8 rounded-lg'
onClick={() => setShowSidebar(true)}
>
<Menu01 className='w-4 h-4 text-gray-700' />
</div>
<div className='grow flex justify-center items-center px-3'>
<AppIcon
className='mr-2'
size='tiny'
icon={appData?.site.icon}
background={appData?.site.icon_background}
/>
<div className='py-1 text-base font-semibold text-gray-800 truncate'>
{appData?.site.title}
</div>
</div>
<div
className='shrink-0 flex items-center justify-center w-8 h-8 rounded-lg'
onClick={handleNewConversation}
>
<Edit05 className='w-4 h-4 text-gray-700' />
</div>
</div>
{
showSidebar && (
<div className='fixed inset-0 z-50'
style={{ backgroundColor: 'rgba(35, 56, 118, 0.2)' }}
onClick={() => setShowSidebar(false)}
>
<div className='inline-block h-full bg-white' onClick={e => e.stopPropagation()}>
<Sidebar />
</div>
</div>
)
}
</>
)
}
export default HeaderInMobile

View File

@@ -0,0 +1,25 @@
import type { FC } from 'react'
import { memo } from 'react'
type HeaderProps = {
title: string
isMobile: boolean
}
const Header: FC<HeaderProps> = ({
title,
isMobile,
}) => {
return (
<div
className={`
sticky top-0 flex items-center px-8 h-16 bg-white/80 text-base font-medium
text-gray-900 border-b-[0.5px] border-b-gray-100 backdrop-blur-md z-10
${isMobile && '!h-12'}
`}
>
{title}
</div>
)
}
export default memo(Header)

View File

@@ -0,0 +1,385 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import { useLocalStorageState } from 'ahooks'
import produce from 'immer'
import type {
Callback,
ChatConfig,
ChatItem,
Feedback,
} from '../types'
import { CONVERSATION_ID_INFO } from '../constants'
import {
delConversation,
fetchAppInfo,
fetchAppMeta,
fetchAppParams,
fetchChatList,
fetchConversations,
generationConversationName,
pinConversation,
renameConversation,
unpinConversation,
updateFeedback,
} from '@/service/share'
import type { InstalledApp } from '@/models/explore'
import type {
AppData,
ConversationItem,
} from '@/models/share'
import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils'
import { useToastContext } from '@/app/components/base/toast'
export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
const isInstalledApp = useMemo(() => !!installedAppInfo, [installedAppInfo])
const { data: appInfo, isLoading: appInfoLoading } = useSWR(installedAppInfo ? null : 'appInfo', fetchAppInfo)
const appData = useMemo(() => {
if (isInstalledApp) {
const { id, app } = installedAppInfo!
return {
app_id: id,
site: { title: app.name, icon: app.icon, icon_background: app.icon_background, prompt_public: false, copyright: '' },
plan: 'basic',
} as AppData
}
return appInfo
}, [isInstalledApp, installedAppInfo, appInfo])
const appId = useMemo(() => appData?.app_id, [appData])
const [conversationIdInfo, setConversationIdInfo] = useLocalStorageState<Record<string, string>>(CONVERSATION_ID_INFO, {
defaultValue: {},
})
const currentConversationId = useMemo(() => conversationIdInfo?.[appId || ''] || '', [appId, conversationIdInfo])
const handleConversationIdInfoChange = useCallback((changeConversationId: string) => {
if (appId) {
setConversationIdInfo({
...conversationIdInfo,
[appId || '']: changeConversationId,
})
}
}, [appId, conversationIdInfo, setConversationIdInfo])
const [showConfigPanelBeforeChat, setShowConfigPanelBeforeChat] = useState(true)
const [newConversationId, setNewConversationId] = useState('')
const chatShouldReloadKey = useMemo(() => {
if (currentConversationId === newConversationId)
return ''
return currentConversationId
}, [currentConversationId, newConversationId])
const { data: appParams } = useSWR(['appParams', isInstalledApp, appId], () => fetchAppParams(isInstalledApp, appId))
const { data: appMeta } = useSWR(['appMeta', isInstalledApp, appId], () => fetchAppMeta(isInstalledApp, appId))
const { data: appPinnedConversationData, mutate: mutateAppPinnedConversationData } = useSWR(['appConversationData', isInstalledApp, appId, true], () => fetchConversations(isInstalledApp, appId, undefined, true, 100))
const { data: appConversationData, isLoading: appConversationDataLoading, mutate: mutateAppConversationData } = useSWR(['appConversationData', isInstalledApp, appId, false], () => fetchConversations(isInstalledApp, appId, undefined, false, 100))
const { data: appChatListData, isLoading: appChatListDataLoading } = useSWR(chatShouldReloadKey ? ['appChatList', chatShouldReloadKey, isInstalledApp, appId] : null, () => fetchChatList(chatShouldReloadKey, isInstalledApp, appId))
const appPrevChatList = useMemo(() => {
const data = appChatListData?.data || []
const chatList: ChatItem[] = []
if (currentConversationId && data.length) {
data.forEach((item: any) => {
chatList.push({
id: `question-${item.id}`,
content: item.query,
isAnswer: false,
message_files: item.message_files?.filter((file: any) => file.belongs_to === 'user') || [],
})
chatList.push({
id: item.id,
content: item.answer,
agent_thoughts: addFileInfos(item.agent_thoughts ? sortAgentSorts(item.agent_thoughts) : item.agent_thoughts, item.message_files),
feedback: item.feedback,
isAnswer: true,
citation: item.retriever_resources,
message_files: item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [],
})
})
}
return chatList
}, [appChatListData, currentConversationId])
const [showNewConversationItemInList, setShowNewConversationItemInList] = useState(false)
const pinnedConversationList = useMemo(() => {
return appPinnedConversationData?.data || []
}, [appPinnedConversationData])
const { t } = useTranslation()
const newConversationInputsRef = useRef<Record<string, any>>({})
const [newConversationInputs, setNewConversationInputs] = useState<Record<string, any>>({})
const handleNewConversationInputsChange = useCallback((newInputs: Record<string, any>) => {
newConversationInputsRef.current = newInputs
setNewConversationInputs(newInputs)
}, [])
const inputsForms = useMemo(() => {
return (appParams?.user_input_form || []).filter((item: any) => item.paragraph || item.select || item['text-input']).map((item: any) => {
if (item.paragraph) {
return {
...item.paragraph,
type: 'paragraph',
}
}
if (item.select) {
return {
...item.select,
type: 'select',
}
}
return {
...item['text-input'],
type: 'text-input',
}
})
}, [appParams])
useEffect(() => {
const conversationInputs: Record<string, any> = {}
inputsForms.forEach((item: any) => {
conversationInputs[item.variable] = item.default || ''
})
handleNewConversationInputsChange(conversationInputs)
}, [handleNewConversationInputsChange, inputsForms])
const { data: newConversation } = useSWR(newConversationId ? [isInstalledApp, appId, newConversationId] : null, () => generationConversationName(isInstalledApp, appId, newConversationId))
const [originConversationList, setOriginConversationList] = useState<ConversationItem[]>([])
useEffect(() => {
if (appConversationData?.data && !appConversationDataLoading)
setOriginConversationList(appConversationData?.data)
}, [appConversationData, appConversationDataLoading])
const conversationList = useMemo(() => {
const data = originConversationList.slice()
if (showNewConversationItemInList && data[0]?.id !== '') {
data.unshift({
id: '',
name: t('share.chat.newChatDefaultName'),
inputs: {},
introduction: '',
})
}
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
draft.unshift(newConversation)
}))
}
}, [newConversation])
const currentConversationItem = useMemo(() => {
let coversationItem = conversationList.find(item => item.id === currentConversationId)
if (!coversationItem && pinnedConversationList.length)
coversationItem = pinnedConversationList.find(item => item.id === currentConversationId)
return coversationItem
}, [conversationList, currentConversationId, pinnedConversationList])
const { notify } = useToastContext()
const checkInputsRequired = useCallback((silent?: boolean) => {
if (inputsForms.length) {
for (let i = 0; i < inputsForms.length; i += 1) {
const item = inputsForms[i]
if (item.required && !newConversationInputsRef.current[item.variable]) {
if (!silent) {
notify({
type: 'error',
message: t('appDebug.errorMessage.valueOfVarRequired', { key: item.variable }),
})
}
return
}
}
return true
}
return true
}, [inputsForms, notify, t])
const handleStartChat = useCallback(() => {
if (checkInputsRequired()) {
setShowConfigPanelBeforeChat(false)
setShowNewConversationItemInList(true)
}
}, [setShowConfigPanelBeforeChat, setShowNewConversationItemInList, checkInputsRequired])
const currentChatInstanceRef = useRef<{ handleStop: () => void }>({ handleStop: () => {} })
const handleChangeConversation = useCallback((conversationId: string) => {
currentChatInstanceRef.current.handleStop()
setNewConversationId('')
handleConversationIdInfoChange(conversationId)
if (conversationId === '' && !checkInputsRequired(true))
setShowConfigPanelBeforeChat(true)
else
setShowConfigPanelBeforeChat(false)
}, [handleConversationIdInfoChange, setShowConfigPanelBeforeChat, checkInputsRequired])
const handleNewConversation = useCallback(() => {
currentChatInstanceRef.current.handleStop()
setNewConversationId('')
if (showNewConversationItemInList) {
handleChangeConversation('')
}
else if (currentConversationId) {
handleConversationIdInfoChange('')
setShowConfigPanelBeforeChat(true)
setShowNewConversationItemInList(true)
handleNewConversationInputsChange({})
}
}, [handleChangeConversation, currentConversationId, handleConversationIdInfoChange, setShowConfigPanelBeforeChat, setShowNewConversationItemInList, showNewConversationItemInList, handleNewConversationInputsChange])
const handleUpdateConversationList = useCallback(() => {
mutateAppConversationData()
mutateAppPinnedConversationData()
}, [mutateAppConversationData, mutateAppPinnedConversationData])
const handlePinConversation = useCallback(async (conversationId: string) => {
await pinConversation(isInstalledApp, appId, conversationId)
notify({ type: 'success', message: t('common.api.success') })
handleUpdateConversationList()
}, [isInstalledApp, appId, notify, t, handleUpdateConversationList])
const handleUnpinConversation = useCallback(async (conversationId: string) => {
await unpinConversation(isInstalledApp, appId, conversationId)
notify({ type: 'success', message: t('common.api.success') })
handleUpdateConversationList()
}, [isInstalledApp, appId, notify, t, handleUpdateConversationList])
const [conversationDeleting, setConversationDeleting] = useState(false)
const handleDeleteConversation = useCallback(async (
conversationId: string,
{
onSuccess,
}: Callback,
) => {
if (conversationDeleting)
return
try {
setConversationDeleting(true)
await delConversation(isInstalledApp, appId, conversationId)
notify({ type: 'success', message: t('common.api.success') })
onSuccess()
}
finally {
setConversationDeleting(false)
}
if (conversationId === currentConversationId)
handleNewConversation()
handleUpdateConversationList()
}, [isInstalledApp, appId, notify, t, handleUpdateConversationList, handleNewConversation, currentConversationId, conversationDeleting])
const [conversationRenaming, setConversationRenaming] = useState(false)
const handleRenameConversation = useCallback(async (
conversationId: string,
newName: string,
{
onSuccess,
}: Callback,
) => {
if (conversationRenaming)
return
if (!newName.trim()) {
notify({
type: 'error',
message: t('common.chat.conversationNameCanNotEmpty'),
})
return
}
setConversationRenaming(true)
try {
await renameConversation(isInstalledApp, appId, conversationId, newName)
notify({
type: 'success',
message: t('common.actionMsg.modifiedSuccessfully'),
})
setOriginConversationList(produce((draft) => {
const index = originConversationList.findIndex(item => item.id === conversationId)
const item = draft[index]
draft[index] = {
...item,
name: newName,
}
}))
onSuccess()
}
finally {
setConversationRenaming(false)
}
}, [isInstalledApp, appId, notify, t, conversationRenaming, originConversationList])
const handleNewConversationCompleted = useCallback((newConversationId: string) => {
setNewConversationId(newConversationId)
handleConversationIdInfoChange(newConversationId)
setShowNewConversationItemInList(false)
mutateAppConversationData()
}, [mutateAppConversationData, handleConversationIdInfoChange])
const handleFeedback = useCallback(async (messageId: string, feedback: Feedback) => {
await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating } }, isInstalledApp, appId)
notify({ type: 'success', message: t('common.api.success') })
}, [isInstalledApp, appId, t, notify])
return {
appInfoLoading,
isInstalledApp,
appId,
currentConversationId,
currentConversationItem,
handleConversationIdInfoChange,
appData,
appParams: appParams || {} as ChatConfig,
appMeta,
appPinnedConversationData,
appConversationData,
appConversationDataLoading,
appChatListData,
appChatListDataLoading,
appPrevChatList,
pinnedConversationList,
conversationList,
showConfigPanelBeforeChat,
setShowConfigPanelBeforeChat,
setShowNewConversationItemInList,
newConversationInputs,
handleNewConversationInputsChange,
inputsForms,
handleNewConversation,
handleStartChat,
handleChangeConversation,
handlePinConversation,
handleUnpinConversation,
conversationDeleting,
handleDeleteConversation,
conversationRenaming,
handleRenameConversation,
handleNewConversationCompleted,
newConversationId,
chatShouldReloadKey,
handleFeedback,
currentChatInstanceRef,
}
}

View File

@@ -0,0 +1,195 @@
import type { FC } from 'react'
import {
useEffect,
useState,
} from 'react'
import { useAsyncEffect } from 'ahooks'
import {
ChatWithHistoryContext,
useChatWithHistoryContext,
} from './context'
import { useChatWithHistory } from './hooks'
import Sidebar from './sidebar'
import HeaderInMobile from './header-in-mobile'
import ConfigPanel from './config-panel'
import ChatWrapper from './chat-wrapper'
import type { InstalledApp } from '@/models/explore'
import Loading from '@/app/components/base/loading'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { checkOrSetAccessToken } from '@/app/components/share/utils'
type ChatWithHistoryProps = {
className?: string
}
const ChatWithHistory: FC<ChatWithHistoryProps> = ({
className,
}) => {
const {
appData,
appInfoLoading,
appPrevChatList,
showConfigPanelBeforeChat,
appChatListDataLoading,
chatShouldReloadKey,
isMobile,
} = useChatWithHistoryContext()
const chatReady = (!showConfigPanelBeforeChat || !!appPrevChatList.length)
const customConfig = appData?.custom_config
const site = appData?.site
useEffect(() => {
if (site) {
if (customConfig)
document.title = `${site.title}`
else
document.title = `${site.title} - Powered by Dify`
}
}, [site, customConfig])
if (appInfoLoading) {
return (
<Loading type='app' />
)
}
return (
<div className={`h-full flex bg-white ${className} ${isMobile && 'flex-col'}`}>
{
!isMobile && (
<Sidebar />
)
}
{
isMobile && (
<HeaderInMobile />
)
}
<div className={`grow overflow-hidden ${showConfigPanelBeforeChat && !appPrevChatList.length && 'flex items-center justify-center'}`}>
{
showConfigPanelBeforeChat && !appChatListDataLoading && !appPrevChatList.length && (
<div className={`flex w-full items-center justify-center h-full ${isMobile && 'px-4'}`}>
<ConfigPanel />
</div>
)
}
{
appChatListDataLoading && chatReady && (
<Loading type='app' />
)
}
{
chatReady && !appChatListDataLoading && (
<ChatWrapper key={chatShouldReloadKey} />
)
}
</div>
</div>
)
}
export type ChatWithHistoryWrapProps = {
installedAppInfo?: InstalledApp
className?: string
}
const ChatWithHistoryWrap: FC<ChatWithHistoryWrapProps> = ({
installedAppInfo,
className,
}) => {
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const {
appInfoLoading,
appData,
appParams,
appMeta,
appChatListDataLoading,
currentConversationId,
currentConversationItem,
appPrevChatList,
pinnedConversationList,
conversationList,
showConfigPanelBeforeChat,
newConversationInputs,
handleNewConversationInputsChange,
inputsForms,
handleNewConversation,
handleStartChat,
handleChangeConversation,
handlePinConversation,
handleUnpinConversation,
handleDeleteConversation,
conversationRenaming,
handleRenameConversation,
handleNewConversationCompleted,
chatShouldReloadKey,
isInstalledApp,
appId,
handleFeedback,
currentChatInstanceRef,
} = useChatWithHistory(installedAppInfo)
return (
<ChatWithHistoryContext.Provider value={{
appInfoLoading,
appData,
appParams,
appMeta,
appChatListDataLoading,
currentConversationId,
currentConversationItem,
appPrevChatList,
pinnedConversationList,
conversationList,
showConfigPanelBeforeChat,
newConversationInputs,
handleNewConversationInputsChange,
inputsForms,
handleNewConversation,
handleStartChat,
handleChangeConversation,
handlePinConversation,
handleUnpinConversation,
handleDeleteConversation,
conversationRenaming,
handleRenameConversation,
handleNewConversationCompleted,
chatShouldReloadKey,
isMobile,
isInstalledApp,
appId,
handleFeedback,
currentChatInstanceRef,
}}>
<ChatWithHistory className={className} />
</ChatWithHistoryContext.Provider>
)
}
const ChatWithHistoryWrapWithCheckToken: FC<ChatWithHistoryWrapProps> = ({
installedAppInfo,
className,
}) => {
const [inited, setInited] = useState(false)
useAsyncEffect(async () => {
if (!inited) {
if (!installedAppInfo)
await checkOrSetAccessToken()
setInited(true)
}
}, [])
if (!inited)
return null
return (
<ChatWithHistoryWrap
installedAppInfo={installedAppInfo}
className={className}
/>
)
}
export default ChatWithHistoryWrapWithCheckToken

View File

@@ -0,0 +1,141 @@
import {
useCallback,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { useChatWithHistoryContext } from '../context'
import List from './list'
import AppIcon from '@/app/components/base/app-icon'
import Button from '@/app/components/base/button'
import { Edit05 } from '@/app/components/base/icons/src/vender/line/general'
import type { ConversationItem } from '@/models/share'
import Confirm from '@/app/components/base/confirm'
import RenameModal from '@/app/components/share/chat/sidebar/rename-modal'
const Sidebar = () => {
const { t } = useTranslation()
const {
appData,
pinnedConversationList,
conversationList,
handleNewConversation,
currentConversationId,
handleChangeConversation,
handlePinConversation,
handleUnpinConversation,
conversationRenaming,
handleRenameConversation,
handleDeleteConversation,
isMobile,
} = useChatWithHistoryContext()
const [showConfirm, setShowConfirm] = useState<ConversationItem | null>(null)
const [showRename, setShowRename] = useState<ConversationItem | null>(null)
const handleOperate = useCallback((type: string, item: ConversationItem) => {
if (type === 'pin')
handlePinConversation(item.id)
if (type === 'unpin')
handleUnpinConversation(item.id)
if (type === 'delete')
setShowConfirm(item)
if (type === 'rename')
setShowRename(item)
}, [handlePinConversation, handleUnpinConversation])
const handleCancelConfirm = useCallback(() => {
setShowConfirm(null)
}, [])
const handleDelete = useCallback(() => {
if (showConfirm)
handleDeleteConversation(showConfirm.id, { onSuccess: handleCancelConfirm })
}, [showConfirm, handleDeleteConversation, handleCancelConfirm])
const handleCancelRename = useCallback(() => {
setShowRename(null)
}, [])
const handleRename = useCallback((newName: string) => {
if (showRename)
handleRenameConversation(showRename.id, newName, { onSuccess: handleCancelRename })
}, [showRename, handleRenameConversation, handleCancelRename])
return (
<div className='shrink-0 h-full flex flex-col w-[240px] border-r border-r-gray-100'>
{
!isMobile && (
<div className='shrink-0 flex p-4'>
<AppIcon
className='mr-3'
size='small'
icon={appData?.site.icon}
background={appData?.site.icon_background}
/>
<div className='py-1 text-base font-semibold text-gray-800'>
{appData?.site.title}
</div>
</div>
)
}
<div className='shrink-0 p-4'>
<Button
className='justify-start px-3 py-0 w-full h-9 text-sm font-medium text-primary-600'
onClick={handleNewConversation}
>
<Edit05 className='mr-2 w-4 h-4' />
{t('share.chat.newChat')}
</Button>
</div>
<div className='grow px-4 py-2 overflow-y-auto'>
{
!!pinnedConversationList.length && (
<div className='mb-4'>
<List
isPin
title={t('share.chat.pinnedTitle') || ''}
list={pinnedConversationList}
onChangeConversation={handleChangeConversation}
onOperate={handleOperate}
currentConversationId={currentConversationId}
/>
</div>
)
}
{
!!conversationList.length && (
<List
title={(pinnedConversationList.length && t('share.chat.unpinnedTitle')) || ''}
list={conversationList}
onChangeConversation={handleChangeConversation}
onOperate={handleOperate}
currentConversationId={currentConversationId}
/>
)
}
</div>
<div className='px-4 pb-4 text-xs text-gray-400'>
© {appData?.site.copyright || appData?.site.title} {(new Date()).getFullYear()}
</div>
{!!showConfirm && (
<Confirm
title={t('share.chat.deleteConversation.title')}
content={t('share.chat.deleteConversation.content') || ''}
isShow
onClose={handleCancelConfirm}
onCancel={handleCancelConfirm}
onConfirm={handleDelete}
/>
)}
{showRename && (
<RenameModal
isShow
onClose={handleCancelRename}
saveLoading={conversationRenaming}
name={showRename?.name || ''}
onSave={handleRename}
/>
)}
</div>
)
}
export default Sidebar

View File

@@ -0,0 +1,58 @@
import type { FC } from 'react'
import {
memo,
useRef,
} from 'react'
import { useHover } from 'ahooks'
import type { ConversationItem } from '@/models/share'
import { MessageDotsCircle } from '@/app/components/base/icons/src/vender/solid/communication'
import ItemOperation from '@/app/components/explore/item-operation'
type ItemProps = {
isPin?: boolean
item: ConversationItem
onOperate: (type: string, item: ConversationItem) => void
onChangeConversation: (conversationId: string) => void
currentConversationId: string
}
const Item: FC<ItemProps> = ({
isPin,
item,
onOperate,
onChangeConversation,
currentConversationId,
}) => {
const ref = useRef(null)
const isHovering = useHover(ref)
return (
<div
ref={ref}
key={item.id}
className={`
flex mb-0.5 last-of-type:mb-0 py-1.5 pl-3 pr-1.5 text-sm font-medium text-gray-700
rounded-lg cursor-pointer hover:bg-gray-50 group
${currentConversationId === item.id && 'text-primary-600 bg-primary-50'}
`}
onClick={() => onChangeConversation(item.id)}
>
<MessageDotsCircle className={`shrink-0 mt-1 mr-2 w-4 h-4 text-gray-400 ${currentConversationId === item.id && 'text-primary-600'}`} />
<div className='grow py-0.5 break-all' title={item.name}>{item.name}</div>
{item.id !== '' && (
<div className='shrink-0 h-6' onClick={e => e.stopPropagation()}>
<ItemOperation
isPinned={!!isPin}
isItemHovering={isHovering}
togglePin={() => onOperate(isPin ? 'unpin' : 'pin', item)}
isShowDelete
isShowRenameConversation
onRenameConversation={() => onOperate('rename', item)}
onDelete={() => onOperate('delete', item)}
/>
</div>
)}
</div>
)
}
export default memo(Item)

View File

@@ -0,0 +1,46 @@
import type { FC } from 'react'
import Item from './item'
import type { ConversationItem } from '@/models/share'
type ListProps = {
isPin?: boolean
title?: string
list: ConversationItem[]
onOperate: (type: string, item: ConversationItem) => void
onChangeConversation: (conversationId: string) => void
currentConversationId: string
}
const List: FC<ListProps> = ({
isPin,
title,
list,
onOperate,
onChangeConversation,
currentConversationId,
}) => {
return (
<div>
{
title && (
<div className='mb-0.5 px-3 h-[26px] text-xs font-medium text-gray-500'>
{title}
</div>
)
}
{
list.map(item => (
<Item
key={item.id}
isPin={isPin}
item={item}
onOperate={onOperate}
onChangeConversation={onChangeConversation}
currentConversationId={currentConversationId}
/>
))
}
</div>
)
}
export default List

View File

@@ -82,7 +82,7 @@ const Answer: FC<AnswerProps> = ({
)
}
{
hasAgentThoughts && !content && (
hasAgentThoughts && (
<AgentContent item={item} />
)
}

View File

@@ -1,5 +1,6 @@
import type { FC } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { ChatItem } from '../../types'
import { useCurrentAnswerIsResponsing } from '../hooks'
import { useChatContext } from '../context'
@@ -8,6 +9,11 @@ import { MessageFast } from '@/app/components/base/icons/src/vender/solid/commun
import AudioBtn from '@/app/components/base/audio-btn'
import AnnotationCtrlBtn from '@/app/components/app/configuration/toolbox/annotation/annotation-ctrl-btn'
import EditReplyModal from '@/app/components/app/annotation/edit-annotation-modal'
import {
ThumbsDown,
ThumbsUp,
} from '@/app/components/base/icons/src/vender/line/alertsAndFeedback'
import TooltipPlus from '@/app/components/base/tooltip-plus'
type OperationProps = {
item: ChatItem
@@ -19,11 +25,13 @@ const Operation: FC<OperationProps> = ({
question,
index,
}) => {
const { t } = useTranslation()
const {
config,
onAnnotationAdded,
onAnnotationEdited,
onAnnotationRemoved,
onFeedback,
} = useChatContext()
const [isShowReplyModal, setIsShowReplyModal] = useState(false)
const responsing = useCurrentAnswerIsResponsing(item.id)
@@ -32,8 +40,18 @@ const Operation: FC<OperationProps> = ({
isOpeningStatement,
content,
annotation,
feedback,
} = item
const hasAnnotation = !!annotation?.id
const [localFeedback, setLocalFeedback] = useState(feedback)
const handleFeedback = async (rating: 'like' | 'dislike' | null) => {
if (!config?.supportFeedback || !onFeedback)
return
await onFeedback?.(id, { rating })
setLocalFeedback({ rating })
}
return (
<div className='absolute top-[-14px] right-[-14px] flex justify-end gap-1'>
@@ -90,6 +108,53 @@ const Operation: FC<OperationProps> = ({
</div>
)
}
{
config?.supportFeedback && !localFeedback?.rating && onFeedback && !isOpeningStatement && (
<div className='hidden group-hover:flex ml-1 shrink-0 items-center px-0.5 bg-white border-[0.5px] border-gray-100 shadow-md text-gray-500 rounded-lg'>
<TooltipPlus popupContent={t('appDebug.operation.agree')}>
<div
className='flex items-center justify-center mr-0.5 w-6 h-6 rounded-md hover:bg-black/5 hover:text-gray-800 cursor-pointer'
onClick={() => handleFeedback('like')}
>
<ThumbsUp className='w-4 h-4' />
</div>
</TooltipPlus>
<TooltipPlus popupContent={t('appDebug.operation.disagree')}>
<div
className='flex items-center justify-center w-6 h-6 rounded-md hover:bg-black/5 hover:text-gray-800 cursor-pointer'
onClick={() => handleFeedback('dislike')}
>
<ThumbsDown className='w-4 h-4' />
</div>
</TooltipPlus>
</div>
)
}
{
config?.supportFeedback && localFeedback?.rating && onFeedback && !isOpeningStatement && (
<TooltipPlus popupContent={localFeedback.rating === 'like' ? t('appDebug.operation.cancelAgree') : t('appDebug.operation.cancelDisagree')}>
<div
className={`
flex items-center justify-center w-7 h-7 rounded-[10px] border-[2px] border-white cursor-pointer
${localFeedback.rating === 'like' && 'bg-blue-50 text-blue-600'}
${localFeedback.rating === 'dislike' && 'bg-red-100 text-red-600'}
`}
onClick={() => handleFeedback(null)}
>
{
localFeedback.rating === 'like' && (
<ThumbsUp className='w-4 h-4' />
)
}
{
localFeedback.rating === 'dislike' && (
<ThumbsDown className='w-4 h-4' />
)
}
</div>
</TooltipPlus>
)
}
</div>
)
}

View File

@@ -19,7 +19,7 @@ const SuggestedQuestions: FC<SuggestedQuestionsProps> = ({
return (
<div className='flex flex-wrap'>
{suggestedQuestions.map((question, index) => (
{suggestedQuestions.filter(q => !!q && q.trim()).map((question, index) => (
<div
key={index}
className='mt-1 mr-1 max-w-full last:mr-0 shrink-0 py-[5px] leading-[18px] items-center px-4 rounded-lg border border-gray-200 shadow-xs bg-white text-xs font-medium text-primary-600 cursor-pointer'

View File

@@ -15,6 +15,7 @@ export type ChatContextValue = Pick<ChatProps, 'config'
| 'onAnnotationEdited'
| 'onAnnotationAdded'
| 'onAnnotationRemoved'
| 'onFeedback'
>
const ChatContext = createContext<ChatContextValue>({
@@ -38,6 +39,7 @@ export const ChatContextProvider = ({
onAnnotationEdited,
onAnnotationAdded,
onAnnotationRemoved,
onFeedback,
}: ChatContextProviderProps) => {
return (
<ChatContext.Provider value={{
@@ -52,6 +54,7 @@ export const ChatContextProvider = ({
onAnnotationEdited,
onAnnotationAdded,
onAnnotationRemoved,
onFeedback,
}}>
{children}
</ChatContext.Provider>

View File

@@ -5,7 +5,7 @@ import {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { produce } from 'immer'
import { produce, setAutoFreeze } from 'immer'
import dayjs from 'dayjs'
import type {
ChatConfig,
@@ -23,8 +23,10 @@ import type { Annotation } from '@/models/log'
type GetAbortController = (abortController: AbortController) => void
type SendCallback = {
onGetConvesationMessages: (conversationId: string, getAbortController: GetAbortController) => Promise<any>
onGetConvesationMessages?: (conversationId: string, getAbortController: GetAbortController) => Promise<any>
onGetSuggestedQuestions?: (responseItemId: string, getAbortController: GetAbortController) => Promise<any>
onConversationComplete?: (conversationId: string) => void
isPublicAPI?: boolean
}
export const useCheckPromptVariables = () => {
@@ -67,7 +69,7 @@ export const useCheckPromptVariables = () => {
}
export const useChat = (
config: ChatConfig,
config?: ChatConfig,
promptVariablesConfig?: {
inputs: Inputs
promptVariables: PromptVariable[]
@@ -90,10 +92,17 @@ export const useChat = (
const suggestedQuestionsAbortControllerRef = useRef<AbortController | null>(null)
const checkPromptVariables = useCheckPromptVariables()
useEffect(() => {
setAutoFreeze(false)
return () => {
setAutoFreeze(true)
}
}, [])
const handleUpdateChatList = useCallback((newChatList: ChatItem[]) => {
setChatList(newChatList)
chatListRef.current = newChatList
}, [])
}, [setChatList])
const handleResponsing = useCallback((isResponsing: boolean) => {
setIsResponsing(isResponsing)
isResponsingRef.current = isResponsing
@@ -103,22 +112,19 @@ export const useChat = (
return replaceStringWithValues(str, promptVariablesConfig?.promptVariables || [], promptVariablesConfig?.inputs || {})
}, [promptVariablesConfig?.inputs, promptVariablesConfig?.promptVariables])
useEffect(() => {
if (config.opening_statement && !chatList.length) {
handleUpdateChatList([{
id: `${Date.now()}`,
content: getIntroduction(config.opening_statement),
isAnswer: true,
isOpeningStatement: true,
suggestedQuestions: config.suggested_questions,
}])
if (config?.opening_statement && chatListRef.current.filter(item => item.isOpeningStatement).length === 0) {
handleUpdateChatList([
{
id: `${Date.now()}`,
content: getIntroduction(config.opening_statement),
isAnswer: true,
isOpeningStatement: true,
suggestedQuestions: config.suggested_questions,
},
...chatListRef.current,
])
}
}, [
config.opening_statement,
config.suggested_questions,
getIntroduction,
chatList,
handleUpdateChatList,
])
}, [])
const handleStop = useCallback(() => {
hasStopResponded.current = true
@@ -136,7 +142,7 @@ export const useChat = (
const handleRestart = useCallback(() => {
handleStop()
connversationId.current = ''
const newChatList = config.opening_statement
const newChatList = config?.opening_statement
? [{
id: `${Date.now()}`,
content: config.opening_statement,
@@ -181,6 +187,8 @@ export const useChat = (
{
onGetConvesationMessages,
onGetSuggestedQuestions,
onConversationComplete,
isPublicAPI,
}: SendCallback,
) => {
setSuggestQuestions([])
@@ -248,6 +256,7 @@ export const useChat = (
body: bodyParams,
},
{
isPublicAPI,
getAbortController: (abortController) => {
abortControllerRef.current = abortController
},
@@ -286,7 +295,10 @@ export const useChat = (
if (hasError)
return
if (connversationId.current && !hasStopResponded.current) {
if (onConversationComplete)
onConversationComplete(connversationId.current)
if (connversationId.current && !hasStopResponded.current && onGetConvesationMessages) {
const { data }: any = await onGetConvesationMessages(
connversationId.current,
newAbortController => conversationMessagesAbortControllerRef.current = newAbortController,
@@ -315,7 +327,7 @@ export const useChat = (
})
handleUpdateChatList(newChatList)
}
if (config.suggested_questions_after_answer?.enabled && !hasStopResponded.current && onGetSuggestedQuestions) {
if (config?.suggested_questions_after_answer?.enabled && !hasStopResponded.current && onGetSuggestedQuestions) {
const { data }: any = await onGetSuggestedQuestions(
responseItem.id,
newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController,
@@ -409,7 +421,7 @@ export const useChat = (
return true
}, [
checkPromptVariables,
config.suggested_questions_after_answer,
config?.suggested_questions_after_answer,
updateCurrentQA,
t,
notify,
@@ -419,7 +431,7 @@ export const useChat = (
])
const handleAnnotationEdited = useCallback((query: string, answer: string, index: number) => {
setChatList(chatListRef.current.map((item, i) => {
handleUpdateChatList(chatListRef.current.map((item, i) => {
if (i === index - 1) {
return {
...item,
@@ -438,9 +450,9 @@ export const useChat = (
}
return item
}))
}, [])
}, [handleUpdateChatList])
const handleAnnotationAdded = useCallback((annotationId: string, authorName: string, query: string, answer: string, index: number) => {
setChatList(chatListRef.current.map((item, i) => {
handleUpdateChatList(chatListRef.current.map((item, i) => {
if (i === index - 1) {
return {
...item,
@@ -468,9 +480,9 @@ export const useChat = (
}
return item
}))
}, [])
}, [handleUpdateChatList])
const handleAnnotationRemoved = useCallback((index: number) => {
setChatList(chatListRef.current.map((item, i) => {
handleUpdateChatList(chatListRef.current.map((item, i) => {
if (i === index) {
return {
...item,
@@ -483,7 +495,7 @@ export const useChat = (
}
return item
}))
}, [])
}, [handleUpdateChatList])
return {
chatList,

View File

@@ -12,6 +12,7 @@ import { useThrottleEffect } from 'ahooks'
import type {
ChatConfig,
ChatItem,
Feedback,
OnSend,
} from '../types'
import Question from './question'
@@ -32,7 +33,9 @@ export type ChatProps = {
noChatInput?: boolean
onSend?: OnSend
chatContainerclassName?: string
chatContainerInnerClassName?: string
chatFooterClassName?: string
chatFooterInnerClassName?: string
suggestedQuestions?: string[]
showPromptLog?: boolean
questionIcon?: ReactNode
@@ -41,6 +44,8 @@ export type ChatProps = {
onAnnotationEdited?: (question: string, answer: string, index: number) => void
onAnnotationAdded?: (annotationId: string, authorName: string, question: string, answer: string, index: number) => void
onAnnotationRemoved?: (index: number) => void
chatNode?: ReactNode
onFeedback?: (messageId: string, feedback: Feedback) => void
}
const Chat: FC<ChatProps> = ({
config,
@@ -51,7 +56,9 @@ const Chat: FC<ChatProps> = ({
onStopResponding,
noChatInput,
chatContainerclassName,
chatContainerInnerClassName,
chatFooterClassName,
chatFooterInnerClassName,
suggestedQuestions,
showPromptLog,
questionIcon,
@@ -60,10 +67,14 @@ const Chat: FC<ChatProps> = ({
onAnnotationAdded,
onAnnotationEdited,
onAnnotationRemoved,
chatNode,
onFeedback,
}) => {
const { t } = useTranslation()
const chatContainerRef = useRef<HTMLDivElement>(null)
const chatContainerInnerRef = useRef<HTMLDivElement>(null)
const chatFooterRef = useRef<HTMLDivElement>(null)
const chatFooterInnerRef = useRef<HTMLDivElement>(null)
const handleScrolltoBottom = () => {
if (chatContainerRef.current)
@@ -75,6 +86,9 @@ const Chat: FC<ChatProps> = ({
if (chatContainerRef.current && chatFooterRef.current)
chatFooterRef.current.style.width = `${chatContainerRef.current.clientWidth}px`
if (chatContainerInnerRef.current && chatFooterInnerRef.current)
chatFooterInnerRef.current.style.width = `${chatContainerInnerRef.current.clientWidth}px`
}, [chatList], { wait: 500 })
useEffect(() => {
@@ -111,32 +125,39 @@ const Chat: FC<ChatProps> = ({
onAnnotationAdded={onAnnotationAdded}
onAnnotationEdited={onAnnotationEdited}
onAnnotationRemoved={onAnnotationRemoved}
onFeedback={onFeedback}
>
<div className='relative h-full'>
<div
ref={chatContainerRef}
className={`relative h-full overflow-y-auto ${chatContainerclassName}`}
>
{
chatList.map((item, index) => {
if (item.isAnswer) {
{chatNode}
<div
ref={chatContainerInnerRef}
className={`${chatContainerInnerClassName}`}
>
{
chatList.map((item, index) => {
if (item.isAnswer) {
return (
<Answer
key={item.id}
item={item}
question={chatList[index - 1]?.content}
index={index}
/>
)
}
return (
<Answer
<Question
key={item.id}
item={item}
question={chatList[index - 1]?.content}
index={index}
/>
)
}
return (
<Question
key={item.id}
item={item}
/>
)
})
}
})
}
</div>
</div>
<div
className={`absolute bottom-0 ${(hasTryToAsk || !noChatInput || !noStopResponding) && chatFooterClassName}`}
@@ -145,33 +166,38 @@ const Chat: FC<ChatProps> = ({
background: 'linear-gradient(0deg, #F9FAFB 40%, rgba(255, 255, 255, 0.00) 100%)',
}}
>
{
!noStopResponding && isResponsing && (
<div className='flex justify-center mb-2'>
<Button className='py-0 px-3 h-7 bg-white shadow-xs' onClick={onStopResponding}>
<StopCircle className='mr-[5px] w-3.5 h-3.5 text-gray-500' />
<span className='text-xs text-gray-500 font-normal'>{t('appDebug.operation.stopResponding')}</span>
</Button>
</div>
)
}
{
hasTryToAsk && (
<TryToAsk
suggestedQuestions={suggestedQuestions}
onSend={onSend}
/>
)
}
{
!noChatInput && (
<ChatInput
visionConfig={config?.file_upload?.image}
speechToTextConfig={config?.speech_to_text}
onSend={onSend}
/>
)
}
<div
ref={chatFooterInnerRef}
className={`${chatFooterInnerClassName}`}
>
{
!noStopResponding && isResponsing && (
<div className='flex justify-center mb-2'>
<Button className='py-0 px-3 h-7 bg-white shadow-xs' onClick={onStopResponding}>
<StopCircle className='mr-[5px] w-3.5 h-3.5 text-gray-500' />
<span className='text-xs text-gray-500 font-normal'>{t('appDebug.operation.stopResponding')}</span>
</Button>
</div>
)
}
{
hasTryToAsk && (
<TryToAsk
suggestedQuestions={suggestedQuestions}
onSend={onSend}
/>
)
}
{
!noChatInput && (
<ChatInput
visionConfig={config?.file_upload?.image}
speechToTextConfig={config?.speech_to_text}
onSend={onSend}
/>
)
}
</div>
</div>
</div>
</ChatContextProvider>

View File

@@ -34,7 +34,7 @@ const TryToAsk: FC<TryToAskProps> = ({
}}
/>
</div>
<div className='flex flex-wrap'>
<div className='flex flex-wrap justify-center'>
{
suggestedQuestions.map((suggestQuestion, index) => (
<Button

View File

@@ -0,0 +1 @@
export const CONVERSATION_ID_INFO = 'conversationIdInfo'

View File

@@ -44,8 +44,17 @@ export type EnableType = {
export type ChatConfig = Omit<ModelConfig, 'model'> & {
supportAnnotation?: boolean
appId?: string
supportFeedback?: boolean
}
export type ChatItem = IChatItem
export type OnSend = (message: string, files?: VisionFile[]) => void
export type Callback = {
onSuccess: () => void
}
export type Feedback = {
rating: 'like' | 'dislike' | null
}