index.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508
  1. 'use client'
  2. import type { FC } from 'react'
  3. import React, { useEffect, useState, useRef } from 'react'
  4. import cn from 'classnames'
  5. import { useTranslation } from 'react-i18next'
  6. import { useContext } from 'use-context-selector'
  7. import produce from 'immer'
  8. import { useBoolean, useGetState } from 'ahooks'
  9. import useConversation from './hooks/use-conversation'
  10. import { ToastContext } from '@/app/components/base/toast'
  11. import Sidebar from '@/app/components/share/chat/sidebar'
  12. import ConfigSence from '@/app/components/share/chat/config-scence'
  13. import Header from '@/app/components/share/header'
  14. import { fetchAppInfo, fetchAppParams, fetchChatList, fetchConversations, sendChatMessage, updateFeedback, fetchSuggestedQuestions } from '@/service/share'
  15. import type { ConversationItem, SiteInfo } from '@/models/share'
  16. import type { PromptConfig } from '@/models/debug'
  17. import type { Feedbacktype, IChatItem } from '@/app/components/app/chat'
  18. import Chat from '@/app/components/app/chat'
  19. import { changeLanguage } from '@/i18n/i18next-config'
  20. import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
  21. import Loading from '@/app/components/base/loading'
  22. import { replaceStringWithValues } from '@/app/components/app/configuration/prompt-value-panel'
  23. import AppUnavailable from '../../base/app-unavailable'
  24. import { userInputsFormToPromptVariables } from '@/utils/model-config'
  25. import { SuggestedQuestionsAfterAnswerConfig } from '@/models/debug'
  26. export type IMainProps = {
  27. params: {
  28. locale: string
  29. appId: string
  30. conversationId: string
  31. token: string
  32. }
  33. }
  34. const Main: FC<IMainProps> = () => {
  35. const { t } = useTranslation()
  36. const media = useBreakpoints()
  37. const isMobile = media === MediaType.mobile
  38. /*
  39. * app info
  40. */
  41. const [appUnavailable, setAppUnavailable] = useState<boolean>(false)
  42. const [isUnknwonReason, setIsUnknwonReason] = useState<boolean>(false)
  43. const [appId, setAppId] = useState<string>('')
  44. const [isPublicVersion, setIsPublicVersion] = useState<boolean>(true)
  45. const [siteInfo, setSiteInfo] = useState<SiteInfo | null>()
  46. const [promptConfig, setPromptConfig] = useState<PromptConfig | null>(null)
  47. const [inited, setInited] = useState<boolean>(false)
  48. const [plan, setPlan] = useState<string>('basic') // basic/plus/pro
  49. // in mobile, show sidebar by click button
  50. const [isShowSidebar, { setTrue: showSidebar, setFalse: hideSidebar }] = useBoolean(false)
  51. // Can Use metadata(https://beta.nextjs.org/docs/api-reference/metadata) to set title. But it only works in server side client.
  52. useEffect(() => {
  53. if (siteInfo?.title) {
  54. if (plan !== 'basic')
  55. document.title = `${siteInfo.title}`
  56. else
  57. document.title = `${siteInfo.title} - Powered by Dify`
  58. }
  59. }, [siteInfo?.title, plan])
  60. /*
  61. * conversation info
  62. */
  63. const {
  64. conversationList,
  65. setConversationList,
  66. currConversationId,
  67. setCurrConversationId,
  68. getConversationIdFromStorage,
  69. isNewConversation,
  70. currConversationInfo,
  71. currInputs,
  72. newConversationInputs,
  73. // existConversationInputs,
  74. resetNewConversationInputs,
  75. setCurrInputs,
  76. setNewConversationInfo,
  77. setExistConversationInfo
  78. } = useConversation()
  79. const [suggestedQuestionsAfterAnswerConfig, setSuggestedQuestionsAfterAnswerConfig] = useState<SuggestedQuestionsAfterAnswerConfig | null>(null)
  80. const [conversationIdChangeBecauseOfNew, setConversationIdChangeBecauseOfNew, getConversationIdChangeBecauseOfNew] = useGetState(false)
  81. const [isChatStarted, { setTrue: setChatStarted, setFalse: setChatNotStarted }] = useBoolean(false)
  82. const handleStartChat = (inputs: Record<string, any>) => {
  83. createNewChat()
  84. setConversationIdChangeBecauseOfNew(true)
  85. setCurrInputs(inputs)
  86. setChatStarted()
  87. // parse variables in introduction
  88. setChatList(generateNewChatListWithOpenstatement('', inputs))
  89. }
  90. const hasSetInputs = (() => {
  91. if (!isNewConversation) {
  92. return true
  93. }
  94. return isChatStarted
  95. })()
  96. const conversationName = currConversationInfo?.name || t('share.chat.newChatDefaultName') as string
  97. const conversationIntroduction = currConversationInfo?.introduction || ''
  98. const handleConversationSwitch = () => {
  99. if (!inited) return
  100. if (!appId) {
  101. // wait for appId
  102. setTimeout(handleConversationSwitch, 100)
  103. return
  104. }
  105. // update inputs of current conversation
  106. let notSyncToStateIntroduction = ''
  107. let notSyncToStateInputs: Record<string, any> | undefined | null = {}
  108. if (!isNewConversation) {
  109. const item = conversationList.find(item => item.id === currConversationId)
  110. notSyncToStateInputs = item?.inputs || {}
  111. setCurrInputs(notSyncToStateInputs)
  112. notSyncToStateIntroduction = item?.introduction || ''
  113. setExistConversationInfo({
  114. name: item?.name || '',
  115. introduction: notSyncToStateIntroduction,
  116. })
  117. } else {
  118. notSyncToStateInputs = newConversationInputs
  119. setCurrInputs(notSyncToStateInputs)
  120. }
  121. // update chat list of current conversation
  122. if (!isNewConversation && !conversationIdChangeBecauseOfNew && !isResponsing) {
  123. fetchChatList(currConversationId).then((res: any) => {
  124. const { data } = res
  125. const newChatList: IChatItem[] = generateNewChatListWithOpenstatement(notSyncToStateIntroduction, notSyncToStateInputs)
  126. data.forEach((item: any) => {
  127. newChatList.push({
  128. id: `question-${item.id}`,
  129. content: item.query,
  130. isAnswer: false,
  131. })
  132. newChatList.push({
  133. id: item.id,
  134. content: item.answer,
  135. feedback: item.feedback,
  136. isAnswer: true,
  137. })
  138. })
  139. setChatList(newChatList)
  140. })
  141. }
  142. if (isNewConversation && isChatStarted) {
  143. setChatList(generateNewChatListWithOpenstatement())
  144. }
  145. setControlFocus(Date.now())
  146. }
  147. useEffect(handleConversationSwitch, [currConversationId, inited])
  148. const handleConversationIdChange = (id: string) => {
  149. if (id === '-1') {
  150. createNewChat()
  151. setConversationIdChangeBecauseOfNew(true)
  152. } else {
  153. setConversationIdChangeBecauseOfNew(false)
  154. }
  155. // trigger handleConversationSwitch
  156. setCurrConversationId(id, appId)
  157. setIsShowSuggestion(false)
  158. hideSidebar()
  159. }
  160. /*
  161. * chat info. chat is under conversation.
  162. */
  163. const [chatList, setChatList, getChatList] = useGetState<IChatItem[]>([])
  164. const chatListDomRef = useRef<HTMLDivElement>(null)
  165. useEffect(() => {
  166. // scroll to bottom
  167. if (chatListDomRef.current) {
  168. chatListDomRef.current.scrollTop = chatListDomRef.current.scrollHeight
  169. }
  170. }, [chatList, currConversationId])
  171. // user can not edit inputs if user had send message
  172. const canEditInpus = !chatList.some(item => item.isAnswer === false) && isNewConversation
  173. const createNewChat = () => {
  174. // if new chat is already exist, do not create new chat
  175. abortController?.abort()
  176. setResponsingFalse()
  177. if (conversationList.some(item => item.id === '-1')) {
  178. return
  179. }
  180. setConversationList(produce(conversationList, draft => {
  181. draft.unshift({
  182. id: '-1',
  183. name: t('share.chat.newChatDefaultName'),
  184. inputs: newConversationInputs,
  185. introduction: conversationIntroduction
  186. })
  187. }))
  188. }
  189. // sometime introduction is not applied to state
  190. const generateNewChatListWithOpenstatement = (introduction?: string, inputs?: Record<string, any> | null) => {
  191. let caculatedIntroduction = introduction || conversationIntroduction || ''
  192. const caculatedPromptVariables = inputs || currInputs || null
  193. if (caculatedIntroduction && caculatedPromptVariables) {
  194. caculatedIntroduction = replaceStringWithValues(caculatedIntroduction, promptConfig?.prompt_variables || [], caculatedPromptVariables)
  195. }
  196. // console.log(isPublicVersion)
  197. const openstatement = {
  198. id: `${Date.now()}`,
  199. content: caculatedIntroduction,
  200. isAnswer: true,
  201. feedbackDisabled: true,
  202. isOpeningStatement: isPublicVersion
  203. }
  204. if (caculatedIntroduction) {
  205. return [openstatement]
  206. }
  207. return []
  208. }
  209. // init
  210. useEffect(() => {
  211. (async () => {
  212. try {
  213. const [appData, conversationData, appParams] = await Promise.all([fetchAppInfo(), fetchConversations(), fetchAppParams()])
  214. const { app_id: appId, site: siteInfo, model_config, plan }: any = appData
  215. setAppId(appId)
  216. setPlan(plan)
  217. const tempIsPublicVersion = siteInfo.prompt_public
  218. setIsPublicVersion(tempIsPublicVersion)
  219. const prompt_template = tempIsPublicVersion ? model_config.pre_prompt : ''
  220. // handle current conversation id
  221. const { data: conversations } = conversationData as { data: ConversationItem[] }
  222. const _conversationId = getConversationIdFromStorage(appId)
  223. const isNotNewConversation = conversations.some(item => item.id === _conversationId)
  224. // fetch new conversation info
  225. const { user_input_form, opening_statement: introduction, suggested_questions_after_answer }: any = appParams
  226. const prompt_variables = userInputsFormToPromptVariables(user_input_form)
  227. changeLanguage(siteInfo.default_language)
  228. setNewConversationInfo({
  229. name: t('share.chat.newChatDefaultName'),
  230. introduction,
  231. })
  232. setSiteInfo(siteInfo as SiteInfo)
  233. setPromptConfig({
  234. prompt_template,
  235. prompt_variables: prompt_variables,
  236. } as PromptConfig)
  237. setSuggestedQuestionsAfterAnswerConfig(suggested_questions_after_answer)
  238. setConversationList(conversations as ConversationItem[])
  239. if (isNotNewConversation) {
  240. setCurrConversationId(_conversationId, appId, false)
  241. }
  242. setInited(true)
  243. } catch (e: any) {
  244. if (e.status === 404) {
  245. setAppUnavailable(true)
  246. } else {
  247. setIsUnknwonReason(true)
  248. setAppUnavailable(true)
  249. }
  250. }
  251. })()
  252. }, [])
  253. const [isResponsing, { setTrue: setResponsingTrue, setFalse: setResponsingFalse }] = useBoolean(false)
  254. const [abortController, setAbortController] = useState<AbortController | null>(null)
  255. const { notify } = useContext(ToastContext)
  256. const logError = (message: string) => {
  257. notify({ type: 'error', message })
  258. }
  259. const checkCanSend = () => {
  260. const prompt_variables = promptConfig?.prompt_variables
  261. const inputs = currInputs
  262. if (!inputs || !prompt_variables || prompt_variables?.length === 0) {
  263. return true
  264. }
  265. let hasEmptyInput = false
  266. const requiredVars = prompt_variables?.filter(({ key, name, required }) => {
  267. const res = (!key || !key.trim()) || (!name || !name.trim()) || (required || required === undefined || required === null)
  268. return res
  269. }) || [] // compatible with old version
  270. requiredVars.forEach(({ key }) => {
  271. if (hasEmptyInput) {
  272. return
  273. }
  274. if (!inputs?.[key]) {
  275. hasEmptyInput = true
  276. }
  277. })
  278. if (hasEmptyInput) {
  279. logError(t('appDebug.errorMessage.valueOfVarRequired'))
  280. return false
  281. }
  282. return !hasEmptyInput
  283. }
  284. const [controlFocus, setControlFocus] = useState(0)
  285. const [isShowSuggestion, setIsShowSuggestion] = useState(false)
  286. const doShowSuggestion = isShowSuggestion && !isResponsing
  287. const [suggestQuestions, setSuggestQuestions] = useState<string[]>([])
  288. const handleSend = async (message: string) => {
  289. if (isResponsing) {
  290. notify({ type: 'info', message: t('appDebug.errorMessage.waitForResponse') })
  291. return
  292. }
  293. const data = {
  294. inputs: currInputs,
  295. query: message,
  296. conversation_id: isNewConversation ? null : currConversationId,
  297. }
  298. // qustion
  299. const questionId = `question-${Date.now()}`
  300. const questionItem = {
  301. id: questionId,
  302. content: message,
  303. isAnswer: false,
  304. }
  305. const placeholderAnswerId = `answer-placeholder-${Date.now()}`
  306. const placeholderAnswerItem = {
  307. id: placeholderAnswerId,
  308. content: '',
  309. isAnswer: true,
  310. }
  311. const newList = [...getChatList(), questionItem, placeholderAnswerItem]
  312. setChatList(newList)
  313. // answer
  314. const responseItem = {
  315. id: `${Date.now()}`,
  316. content: '',
  317. isAnswer: true,
  318. }
  319. let tempNewConversationId = ''
  320. setResponsingTrue()
  321. setIsShowSuggestion(false)
  322. sendChatMessage(data, {
  323. getAbortController: (abortController) => {
  324. setAbortController(abortController)
  325. },
  326. onData: (message: string, isFirstMessage: boolean, { conversationId: newConversationId, messageId }: any) => {
  327. responseItem.content = responseItem.content + message
  328. responseItem.id = messageId
  329. if (isFirstMessage && newConversationId) {
  330. tempNewConversationId = newConversationId
  331. }
  332. // closesure new list is outdated.
  333. const newListWithAnswer = produce(
  334. getChatList().filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId),
  335. (draft) => {
  336. if (!draft.find(item => item.id === questionId))
  337. draft.push({ ...questionItem })
  338. draft.push({ ...responseItem })
  339. })
  340. setChatList(newListWithAnswer)
  341. },
  342. async onCompleted(hasError?: boolean) {
  343. setResponsingFalse()
  344. if (hasError) {
  345. return
  346. }
  347. let currChatList = conversationList
  348. if (getConversationIdChangeBecauseOfNew()) {
  349. const { data: conversations }: any = await fetchConversations()
  350. setConversationList(conversations as ConversationItem[])
  351. currChatList = conversations
  352. }
  353. setConversationIdChangeBecauseOfNew(false)
  354. resetNewConversationInputs()
  355. setChatNotStarted()
  356. setCurrConversationId(tempNewConversationId, appId, true)
  357. if (suggestedQuestionsAfterAnswerConfig?.enabled) {
  358. const { data }: any = await fetchSuggestedQuestions(responseItem.id)
  359. setSuggestQuestions(data)
  360. setIsShowSuggestion(true)
  361. }
  362. },
  363. onError() {
  364. setResponsingFalse()
  365. // role back placeholder answer
  366. setChatList(produce(getChatList(), draft => {
  367. draft.splice(draft.findIndex(item => item.id === placeholderAnswerId), 1)
  368. }))
  369. },
  370. })
  371. }
  372. const handleFeedback = async (messageId: string, feedback: Feedbacktype) => {
  373. await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating } })
  374. const newChatList = chatList.map((item) => {
  375. if (item.id === messageId) {
  376. return {
  377. ...item,
  378. feedback,
  379. }
  380. }
  381. return item
  382. })
  383. setChatList(newChatList)
  384. notify({ type: 'success', message: t('common.api.success') })
  385. }
  386. const renderSidebar = () => {
  387. if (!appId || !siteInfo || !promptConfig)
  388. return null
  389. return (
  390. <Sidebar
  391. list={conversationList}
  392. onCurrentIdChange={handleConversationIdChange}
  393. currentId={currConversationId}
  394. copyRight={siteInfo.copyright || siteInfo.title}
  395. />
  396. )
  397. }
  398. if (appUnavailable)
  399. return <AppUnavailable isUnknwonReason={isUnknwonReason} />
  400. if (!appId || !siteInfo || !promptConfig)
  401. return <Loading type='app' />
  402. return (
  403. <div className='bg-gray-100'>
  404. <Header
  405. title={siteInfo.title}
  406. icon={siteInfo.icon || ''}
  407. icon_background={siteInfo.icon_background || '#FFEAD5'}
  408. isMobile={isMobile}
  409. onShowSideBar={showSidebar}
  410. onCreateNewChat={() => handleConversationIdChange('-1')}
  411. />
  412. {/* {isNewConversation ? 'new' : 'exist'}
  413. {JSON.stringify(newConversationInputs ? newConversationInputs : {})}
  414. {JSON.stringify(existConversationInputs ? existConversationInputs : {})} */}
  415. <div className="flex rounded-t-2xl bg-white overflow-hidden">
  416. {/* sidebar */}
  417. {!isMobile && renderSidebar()}
  418. {isMobile && isShowSidebar && (
  419. <div className='fixed inset-0 z-50'
  420. style={{ backgroundColor: 'rgba(35, 56, 118, 0.2)' }}
  421. onClick={hideSidebar}
  422. >
  423. <div className='inline-block' onClick={e => e.stopPropagation()}>
  424. {renderSidebar()}
  425. </div>
  426. </div>
  427. )}
  428. {/* main */}
  429. <div className='flex-grow flex flex-col h-[calc(100vh_-_3rem)] overflow-y-auto'>
  430. <ConfigSence
  431. conversationName={conversationName}
  432. hasSetInputs={hasSetInputs}
  433. isPublicVersion={isPublicVersion}
  434. siteInfo={siteInfo}
  435. promptConfig={promptConfig}
  436. onStartChat={handleStartChat}
  437. canEidtInpus={canEditInpus}
  438. savedInputs={currInputs as Record<string, any>}
  439. onInputsChange={setCurrInputs}
  440. plan={plan}
  441. ></ConfigSence>
  442. {
  443. hasSetInputs && (
  444. <div className={cn(doShowSuggestion ? 'pb-[140px]' : 'pb-[66px]', 'relative grow h-[200px] pc:w-[794px] max-w-full mobile:w-full mx-auto mb-3.5 overflow-hidden')}>
  445. <div className='h-full overflow-y-auto' ref={chatListDomRef}>
  446. <Chat
  447. chatList={chatList}
  448. onSend={handleSend}
  449. isHideFeedbackEdit
  450. onFeedback={handleFeedback}
  451. isResponsing={isResponsing}
  452. abortResponsing={() => {
  453. abortController?.abort()
  454. setResponsingFalse()
  455. }}
  456. checkCanSend={checkCanSend}
  457. controlFocus={controlFocus}
  458. isShowSuggestion={doShowSuggestion}
  459. suggestionList={suggestQuestions}
  460. />
  461. </div>
  462. </div>)
  463. }
  464. </div>
  465. </div>
  466. </div>
  467. )
  468. }
  469. export default React.memo(Main)