index.tsx 19 KB

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