|
'use client' |
|
import type { FC } from 'react' |
|
import React, { useEffect, useRef, useState } from 'react' |
|
import { useTranslation } from 'react-i18next' |
|
import produce from 'immer' |
|
import { useBoolean, useGetState } from 'ahooks' |
|
import useConversation from '@/hooks/use-conversation' |
|
import Toast from '@/app/components/base/toast' |
|
import Sidebar from '@/app/components/sidebar' |
|
import ConfigSence from '@/app/components/config-scence' |
|
import Header from '@/app/components/header' |
|
import { fetchAppParams, fetchChatList, fetchConversations, sendChatMessage, updateFeedback } from '@/service' |
|
import type { ConversationItem, Feedbacktype, IChatItem, PromptConfig, AppInfo } from '@/types/app' |
|
import Chat from '@/app/components/chat' |
|
import { setLocaleOnClient } from '@/i18n/client' |
|
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' |
|
import Loading from '@/app/components/base/loading' |
|
import { replaceVarWithValues } from '@/utils/prompt' |
|
import AppUnavailable from '@/app/components/app-unavailable' |
|
import { APP_ID, API_KEY, APP_INFO, isShowPrompt, promptTemplate } from '@/config' |
|
import { userInputsFormToPromptVariables } from '@/utils/prompt' |
|
|
|
const Main: FC = () => { |
|
const { t } = useTranslation() |
|
const media = useBreakpoints() |
|
const isMobile = media === MediaType.mobile |
|
const hasSetAppConfig = APP_ID && API_KEY |
|
|
|
|
|
|
|
|
|
const [appUnavailable, setAppUnavailable] = useState<boolean>(false) |
|
const [isUnknwonReason, setIsUnknwonReason] = useState<boolean>(false) |
|
const [promptConfig, setPromptConfig] = useState<PromptConfig | null>(null) |
|
const [inited, setInited] = useState<boolean>(false) |
|
|
|
const [isShowSidebar, { setTrue: showSidebar, setFalse: hideSidebar }] = useBoolean(false) |
|
|
|
useEffect(() => { |
|
if (APP_INFO?.title) { |
|
document.title = `${APP_INFO.title} - Powered by Dify` |
|
} |
|
}, [APP_INFO?.title]) |
|
|
|
|
|
|
|
|
|
const { |
|
conversationList, |
|
setConversationList, |
|
currConversationId, |
|
setCurrConversationId, |
|
getConversationIdFromStorage, |
|
isNewConversation, |
|
currConversationInfo, |
|
currInputs, |
|
newConversationInputs, |
|
resetNewConversationInputs, |
|
setCurrInputs, |
|
setNewConversationInfo, |
|
setExistConversationInfo, |
|
} = useConversation() |
|
|
|
const [conversationIdChangeBecauseOfNew, setConversationIdChangeBecauseOfNew, getConversationIdChangeBecauseOfNew] = useGetState(false) |
|
const [isChatStarted, { setTrue: setChatStarted, setFalse: setChatNotStarted }] = useBoolean(false) |
|
const handleStartChat = (inputs: Record<string, any>) => { |
|
setCurrInputs(inputs) |
|
setChatStarted() |
|
|
|
setChatList(generateNewChatListWithOpenstatement('', inputs)) |
|
} |
|
const hasSetInputs = (() => { |
|
if (!isNewConversation) |
|
return true |
|
|
|
return isChatStarted |
|
})() |
|
|
|
const conversationName = currConversationInfo?.name || t('app.chat.newChatDefaultName') as string |
|
const conversationIntroduction = currConversationInfo?.introduction || '' |
|
|
|
const handleConversationSwitch = () => { |
|
if (!inited) |
|
return |
|
|
|
|
|
let notSyncToStateIntroduction = '' |
|
let notSyncToStateInputs: Record<string, any> | undefined | null = {} |
|
if (!isNewConversation) { |
|
const item = conversationList.find(item => item.id === currConversationId) |
|
notSyncToStateInputs = item?.inputs || {} |
|
setCurrInputs(notSyncToStateInputs as any) |
|
notSyncToStateIntroduction = item?.introduction || '' |
|
setExistConversationInfo({ |
|
name: item?.name || '', |
|
introduction: notSyncToStateIntroduction, |
|
}) |
|
} |
|
else { |
|
notSyncToStateInputs = newConversationInputs |
|
setCurrInputs(notSyncToStateInputs) |
|
} |
|
|
|
|
|
if (!isNewConversation && !conversationIdChangeBecauseOfNew && !isResponsing) { |
|
fetchChatList(currConversationId).then((res: any) => { |
|
const { data } = res |
|
const newChatList: IChatItem[] = generateNewChatListWithOpenstatement(notSyncToStateIntroduction, notSyncToStateInputs) |
|
|
|
data.forEach((item: any) => { |
|
newChatList.push({ |
|
id: `question-${item.id}`, |
|
content: item.query, |
|
isAnswer: false, |
|
}) |
|
newChatList.push({ |
|
id: item.id, |
|
content: item.answer, |
|
feedback: item.feedback, |
|
isAnswer: true, |
|
}) |
|
}) |
|
setChatList(newChatList) |
|
}) |
|
} |
|
|
|
if (isNewConversation && isChatStarted) |
|
setChatList(generateNewChatListWithOpenstatement()) |
|
|
|
setControlFocus(Date.now()) |
|
} |
|
useEffect(handleConversationSwitch, [currConversationId, inited]) |
|
|
|
const handleConversationIdChange = (id: string) => { |
|
if (id === '-1') { |
|
createNewChat() |
|
setConversationIdChangeBecauseOfNew(true) |
|
} |
|
else { |
|
setConversationIdChangeBecauseOfNew(false) |
|
} |
|
|
|
setCurrConversationId(id, APP_ID) |
|
hideSidebar() |
|
} |
|
|
|
|
|
|
|
|
|
const [chatList, setChatList, getChatList] = useGetState<IChatItem[]>([]) |
|
const chatListDomRef = useRef<HTMLDivElement>(null) |
|
useEffect(() => { |
|
|
|
if (chatListDomRef.current) |
|
chatListDomRef.current.scrollTop = chatListDomRef.current.scrollHeight |
|
}, [chatList, currConversationId]) |
|
|
|
const canEditInpus = !chatList.some(item => item.isAnswer === false) && isNewConversation |
|
const createNewChat = () => { |
|
|
|
if (conversationList.some(item => item.id === '-1')) |
|
return |
|
|
|
setConversationList(produce(conversationList, (draft) => { |
|
draft.unshift({ |
|
id: '-1', |
|
name: t('app.chat.newChatDefaultName'), |
|
inputs: newConversationInputs, |
|
introduction: conversationIntroduction, |
|
}) |
|
})) |
|
} |
|
|
|
|
|
const generateNewChatListWithOpenstatement = (introduction?: string, inputs?: Record<string, any> | null) => { |
|
let caculatedIntroduction = introduction || conversationIntroduction || '' |
|
const caculatedPromptVariables = inputs || currInputs || null |
|
if (caculatedIntroduction && caculatedPromptVariables) |
|
caculatedIntroduction = replaceVarWithValues(caculatedIntroduction, promptConfig?.prompt_variables || [], caculatedPromptVariables) |
|
|
|
const openstatement = { |
|
id: `${Date.now()}`, |
|
content: caculatedIntroduction, |
|
isAnswer: true, |
|
feedbackDisabled: true, |
|
isOpeningStatement: isShowPrompt, |
|
} |
|
if (caculatedIntroduction) |
|
return [openstatement] |
|
|
|
return [] |
|
} |
|
|
|
|
|
useEffect(() => { |
|
if (!hasSetAppConfig) { |
|
setAppUnavailable(true) |
|
return |
|
} |
|
(async () => { |
|
try { |
|
const [conversationData, appParams] = await Promise.all([fetchConversations(), fetchAppParams()]) |
|
|
|
|
|
const { data: conversations } = conversationData as { data: ConversationItem[] } |
|
const _conversationId = getConversationIdFromStorage(APP_ID) |
|
const isNotNewConversation = conversations.some(item => item.id === _conversationId) |
|
|
|
|
|
const { user_input_form, opening_statement: introduction }: any = appParams |
|
setLocaleOnClient(APP_INFO.default_language, true) |
|
setNewConversationInfo({ |
|
name: t('app.chat.newChatDefaultName'), |
|
introduction, |
|
}) |
|
const prompt_variables = userInputsFormToPromptVariables(user_input_form) |
|
setPromptConfig({ |
|
prompt_template: promptTemplate, |
|
prompt_variables, |
|
} as PromptConfig) |
|
|
|
setConversationList(conversations as ConversationItem[]) |
|
|
|
if (isNotNewConversation) |
|
setCurrConversationId(_conversationId, APP_ID, false) |
|
|
|
setInited(true) |
|
} |
|
catch (e: any) { |
|
if (e.status === 404) { |
|
setAppUnavailable(true) |
|
} |
|
else { |
|
setIsUnknwonReason(true) |
|
setAppUnavailable(true) |
|
} |
|
} |
|
})() |
|
}, []) |
|
|
|
const [isResponsing, { setTrue: setResponsingTrue, setFalse: setResponsingFalse }] = useBoolean(false) |
|
const { notify } = Toast |
|
const logError = (message: string) => { |
|
notify({ type: 'error', message }) |
|
} |
|
|
|
const checkCanSend = () => { |
|
if (!currInputs || !promptConfig?.prompt_variables) |
|
return true |
|
|
|
const inputLens = Object.values(currInputs).length |
|
const promptVariablesLens = promptConfig.prompt_variables.length |
|
|
|
const emytyInput = inputLens < promptVariablesLens || Object.values(currInputs).find(v => !v) |
|
if (emytyInput) { |
|
logError(t('app.errorMessage.valueOfVarRequired')) |
|
return false |
|
} |
|
return true |
|
} |
|
|
|
const [controlFocus, setControlFocus] = useState(0) |
|
const handleSend = async (message: string) => { |
|
if (isResponsing) { |
|
notify({ type: 'info', message: t('app.errorMessage.waitForResponse') }) |
|
return |
|
} |
|
const data = { |
|
inputs: currInputs, |
|
query: message, |
|
conversation_id: isNewConversation ? null : currConversationId, |
|
} |
|
|
|
|
|
const questionId = `question-${Date.now()}` |
|
const questionItem = { |
|
id: questionId, |
|
content: message, |
|
isAnswer: false, |
|
} |
|
|
|
const placeholderAnswerId = `answer-placeholder-${Date.now()}` |
|
const placeholderAnswerItem = { |
|
id: placeholderAnswerId, |
|
content: '', |
|
isAnswer: true, |
|
} |
|
|
|
const newList = [...getChatList(), questionItem, placeholderAnswerItem] |
|
setChatList(newList) |
|
|
|
|
|
const responseItem = { |
|
id: `${Date.now()}`, |
|
content: '', |
|
isAnswer: true, |
|
} |
|
|
|
let tempNewConversationId = '' |
|
setResponsingTrue() |
|
sendChatMessage(data, { |
|
onData: (message: string, isFirstMessage: boolean, { conversationId: newConversationId, messageId }: any) => { |
|
responseItem.content = responseItem.content + message |
|
responseItem.id = messageId |
|
if (isFirstMessage && newConversationId) |
|
tempNewConversationId = newConversationId |
|
|
|
|
|
const newListWithAnswer = produce( |
|
getChatList().filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId), |
|
(draft) => { |
|
if (!draft.find(item => item.id === questionId)) |
|
draft.push({ ...questionItem }) |
|
|
|
draft.push({ ...responseItem }) |
|
}) |
|
setChatList(newListWithAnswer) |
|
}, |
|
async onCompleted() { |
|
setResponsingFalse() |
|
if (!tempNewConversationId) { |
|
return |
|
} |
|
if (getConversationIdChangeBecauseOfNew()) { |
|
const { data: conversations }: any = await fetchConversations() |
|
setConversationList(conversations as ConversationItem[]) |
|
} |
|
setConversationIdChangeBecauseOfNew(false) |
|
resetNewConversationInputs() |
|
setChatNotStarted() |
|
setCurrConversationId(tempNewConversationId, APP_ID, true) |
|
}, |
|
onError() { |
|
setResponsingFalse() |
|
|
|
setChatList(produce(getChatList(), (draft) => { |
|
draft.splice(draft.findIndex(item => item.id === placeholderAnswerId), 1) |
|
})) |
|
}, |
|
}) |
|
} |
|
|
|
const handleFeedback = async (messageId: string, feedback: Feedbacktype) => { |
|
await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating } }) |
|
const newChatList = chatList.map((item) => { |
|
if (item.id === messageId) { |
|
return { |
|
...item, |
|
feedback, |
|
} |
|
} |
|
return item |
|
}) |
|
setChatList(newChatList) |
|
notify({ type: 'success', message: t('common.api.success') }) |
|
} |
|
|
|
const renderSidebar = () => { |
|
if (!APP_ID || !APP_INFO || !promptConfig) |
|
return null |
|
return ( |
|
<Sidebar |
|
list={conversationList} |
|
onCurrentIdChange={handleConversationIdChange} |
|
currentId={currConversationId} |
|
copyRight={APP_INFO.copyright || APP_INFO.title} |
|
/> |
|
) |
|
} |
|
|
|
if (appUnavailable) |
|
return <AppUnavailable isUnknwonReason={isUnknwonReason} errMessage={!hasSetAppConfig ? 'Please set APP_ID and API_KEY in config/index.tsx' : ''} /> |
|
|
|
if (!APP_ID || !APP_INFO || !promptConfig) |
|
return <Loading type='app' /> |
|
|
|
return ( |
|
<div className='bg-gray-100'> |
|
<Header |
|
title={APP_INFO.title} |
|
isMobile={isMobile} |
|
onShowSideBar={showSidebar} |
|
onCreateNewChat={() => handleConversationIdChange('-1')} |
|
/> |
|
<div className="flex rounded-t-2xl bg-white overflow-hidden"> |
|
{/* sidebar */} |
|
{!isMobile && renderSidebar()} |
|
{isMobile && isShowSidebar && ( |
|
<div className='fixed inset-0 z-50' |
|
style={{ backgroundColor: 'rgba(35, 56, 118, 0.2)' }} |
|
onClick={hideSidebar} |
|
> |
|
<div className='inline-block' onClick={e => e.stopPropagation()}> |
|
{renderSidebar()} |
|
</div> |
|
</div> |
|
)} |
|
{/* main */} |
|
<div className='flex-grow flex flex-col h-[calc(100vh_-_3rem)] overflow-y-auto'> |
|
<ConfigSence |
|
conversationName={conversationName} |
|
hasSetInputs={hasSetInputs} |
|
isPublicVersion={isShowPrompt} |
|
siteInfo={APP_INFO} |
|
promptConfig={promptConfig} |
|
onStartChat={handleStartChat} |
|
canEidtInpus={canEditInpus} |
|
savedInputs={currInputs as Record<string, any>} |
|
onInputsChange={setCurrInputs} |
|
></ConfigSence> |
|
|
|
{ |
|
hasSetInputs && ( |
|
<div className='relative grow h-[200px] pc:w-[794px] max-w-full mobile:w-full pb-[66px] mx-auto mb-3.5 overflow-hidden'> |
|
<div className='h-full overflow-y-auto' ref={chatListDomRef}> |
|
<Chat |
|
chatList={chatList} |
|
onSend={handleSend} |
|
onFeedback={handleFeedback} |
|
isResponsing={isResponsing} |
|
checkCanSend={checkCanSend} |
|
controlFocus={controlFocus} |
|
/> |
|
</div> |
|
</div>) |
|
} |
|
</div> |
|
</div> |
|
</div> |
|
) |
|
} |
|
|
|
export default React.memo(Main) |
|
|