index.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590
  1. /* eslint-disable @typescript-eslint/no-use-before-define */
  2. 'use client'
  3. import type { FC } from 'react'
  4. import React, { useEffect, useRef, useState } from 'react'
  5. import cn from 'classnames'
  6. import { useTranslation } from 'react-i18next'
  7. import { useContext } from 'use-context-selector'
  8. import produce from 'immer'
  9. import { useBoolean, useGetState } from 'ahooks'
  10. import { checkOrSetAccessToken } from '../utils'
  11. import AppUnavailable from '../../base/app-unavailable'
  12. import useConversation from './hooks/use-conversation'
  13. import s from './style.module.css'
  14. import { ToastContext } from '@/app/components/base/toast'
  15. import ConfigScene from '@/app/components/share/chatbot/config-scence'
  16. import Header from '@/app/components/share/header'
  17. import { fetchAppInfo, fetchAppParams, fetchChatList, fetchConversations, fetchSuggestedQuestions, sendChatMessage, stopChatMessageResponding, updateFeedback } from '@/service/share'
  18. import type { ConversationItem, SiteInfo } from '@/models/share'
  19. import type { PromptConfig, SuggestedQuestionsAfterAnswerConfig } from '@/models/debug'
  20. import type { Feedbacktype, IChatItem } from '@/app/components/app/chat/type'
  21. import Chat from '@/app/components/app/chat'
  22. import { changeLanguage } from '@/i18n/i18next-config'
  23. import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
  24. import Loading from '@/app/components/base/loading'
  25. import { replaceStringWithValues } from '@/app/components/app/configuration/prompt-value-panel'
  26. import { userInputsFormToPromptVariables } from '@/utils/model-config'
  27. import type { InstalledApp } from '@/models/explore'
  28. import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
  29. export type IMainProps = {
  30. isInstalledApp?: boolean
  31. installedAppInfo?: InstalledApp
  32. }
  33. const Main: FC<IMainProps> = ({
  34. isInstalledApp = false,
  35. installedAppInfo,
  36. }) => {
  37. const { t } = useTranslation()
  38. const media = useBreakpoints()
  39. const isMobile = media === MediaType.mobile
  40. /*
  41. * app info
  42. */
  43. const [appUnavailable, setAppUnavailable] = useState<boolean>(false)
  44. const [isUnknwonReason, setIsUnknwonReason] = useState<boolean>(false)
  45. const [appId, setAppId] = useState<string>('')
  46. const [isPublicVersion, setIsPublicVersion] = useState<boolean>(true)
  47. const [siteInfo, setSiteInfo] = useState<SiteInfo | null>()
  48. const [promptConfig, setPromptConfig] = useState<PromptConfig | null>(null)
  49. const [inited, setInited] = useState<boolean>(false)
  50. const [plan, setPlan] = useState<string>('basic') // basic/plus/pro
  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 [allConversationList, setAllConversationList] = useState<ConversationItem[]>([])
  64. const [isClearConversationList, { setTrue: clearConversationListTrue, setFalse: clearConversationListFalse }] = useBoolean(false)
  65. const [isClearPinnedConversationList, { setTrue: clearPinnedConversationListTrue, setFalse: clearPinnedConversationListFalse }] = useBoolean(false)
  66. const {
  67. conversationList,
  68. setConversationList,
  69. pinnedConversationList,
  70. setPinnedConversationList,
  71. currConversationId,
  72. setCurrConversationId,
  73. getConversationIdFromStorage,
  74. isNewConversation,
  75. currConversationInfo,
  76. currInputs,
  77. newConversationInputs,
  78. // existConversationInputs,
  79. resetNewConversationInputs,
  80. setCurrInputs,
  81. setNewConversationInfo,
  82. setExistConversationInfo,
  83. } = useConversation()
  84. const [hasMore, setHasMore] = useState<boolean>(true)
  85. const [hasPinnedMore, setHasPinnedMore] = useState<boolean>(true)
  86. const onMoreLoaded = ({ data: conversations, has_more }: any) => {
  87. setHasMore(has_more)
  88. if (isClearConversationList) {
  89. setConversationList(conversations)
  90. clearConversationListFalse()
  91. }
  92. else {
  93. setConversationList([...conversationList, ...conversations])
  94. }
  95. }
  96. const onPinnedMoreLoaded = ({ data: conversations, has_more }: any) => {
  97. setHasPinnedMore(has_more)
  98. if (isClearPinnedConversationList) {
  99. setPinnedConversationList(conversations)
  100. clearPinnedConversationListFalse()
  101. }
  102. else {
  103. setPinnedConversationList([...pinnedConversationList, ...conversations])
  104. }
  105. }
  106. const [controlUpdateConversationList, setControlUpdateConversationList] = useState(0)
  107. const noticeUpdateList = () => {
  108. setHasMore(true)
  109. clearConversationListTrue()
  110. setHasPinnedMore(true)
  111. clearPinnedConversationListTrue()
  112. setControlUpdateConversationList(Date.now())
  113. }
  114. const [suggestedQuestionsAfterAnswerConfig, setSuggestedQuestionsAfterAnswerConfig] = useState<SuggestedQuestionsAfterAnswerConfig | null>(null)
  115. const [speechToTextConfig, setSpeechToTextConfig] = useState<SuggestedQuestionsAfterAnswerConfig | null>(null)
  116. const [conversationIdChangeBecauseOfNew, setConversationIdChangeBecauseOfNew, getConversationIdChangeBecauseOfNew] = useGetState(false)
  117. const [isChatStarted, { setTrue: setChatStarted, setFalse: setChatNotStarted }] = useBoolean(false)
  118. const handleStartChat = (inputs: Record<string, any>) => {
  119. createNewChat()
  120. setConversationIdChangeBecauseOfNew(true)
  121. setCurrInputs(inputs)
  122. setChatStarted()
  123. // parse variables in introduction
  124. setChatList(generateNewChatListWithOpenstatement('', inputs))
  125. }
  126. const hasSetInputs = (() => {
  127. if (!isNewConversation)
  128. return true
  129. return isChatStarted
  130. })()
  131. // const conversationName = currConversationInfo?.name || t('share.chat.newChatDefaultName') as string
  132. const conversationIntroduction = currConversationInfo?.introduction || ''
  133. const handleConversationSwitch = () => {
  134. if (!inited)
  135. return
  136. if (!appId) {
  137. // wait for appId
  138. setTimeout(handleConversationSwitch, 100)
  139. return
  140. }
  141. // update inputs of current conversation
  142. let notSyncToStateIntroduction = ''
  143. let notSyncToStateInputs: Record<string, any> | undefined | null = {}
  144. if (!isNewConversation) {
  145. const item = allConversationList.find(item => item.id === currConversationId)
  146. notSyncToStateInputs = item?.inputs || {}
  147. setCurrInputs(notSyncToStateInputs)
  148. notSyncToStateIntroduction = item?.introduction || ''
  149. setExistConversationInfo({
  150. name: item?.name || '',
  151. introduction: notSyncToStateIntroduction,
  152. })
  153. }
  154. else {
  155. notSyncToStateInputs = newConversationInputs
  156. setCurrInputs(notSyncToStateInputs)
  157. }
  158. // update chat list of current conversation
  159. if (!isNewConversation && !conversationIdChangeBecauseOfNew && !isResponsing) {
  160. fetchChatList(currConversationId, isInstalledApp, installedAppInfo?.id).then((res: any) => {
  161. const { data } = res
  162. const newChatList: IChatItem[] = generateNewChatListWithOpenstatement(notSyncToStateIntroduction, notSyncToStateInputs)
  163. data.forEach((item: any) => {
  164. newChatList.push({
  165. id: `question-${item.id}`,
  166. content: item.query,
  167. isAnswer: false,
  168. })
  169. newChatList.push({
  170. id: item.id,
  171. content: item.answer,
  172. feedback: item.feedback,
  173. isAnswer: true,
  174. })
  175. })
  176. setChatList(newChatList)
  177. })
  178. }
  179. if (isNewConversation && isChatStarted)
  180. setChatList(generateNewChatListWithOpenstatement())
  181. setControlFocus(Date.now())
  182. }
  183. useEffect(handleConversationSwitch, [currConversationId, inited])
  184. /*
  185. * chat info. chat is under conversation.
  186. */
  187. const [chatList, setChatList, getChatList] = useGetState<IChatItem[]>([])
  188. const chatListDomRef = useRef<HTMLDivElement>(null)
  189. useEffect(() => {
  190. // scroll to bottom
  191. if (chatListDomRef.current)
  192. chatListDomRef.current.scrollTop = chatListDomRef.current.scrollHeight
  193. }, [chatList, currConversationId])
  194. // user can not edit inputs if user had send message
  195. const canEditInputs = !chatList.some(item => item.isAnswer === false) && isNewConversation
  196. const createNewChat = async () => {
  197. // if new chat is already exist, do not create new chat
  198. abortController?.abort()
  199. setResponsingFalse()
  200. if (conversationList.some(item => item.id === '-1'))
  201. return
  202. setConversationList(produce(conversationList, (draft) => {
  203. draft.unshift({
  204. id: '-1',
  205. name: t('share.chat.newChatDefaultName'),
  206. inputs: newConversationInputs,
  207. introduction: conversationIntroduction,
  208. })
  209. }))
  210. }
  211. // sometime introduction is not applied to state
  212. const generateNewChatListWithOpenstatement = (introduction?: string, inputs?: Record<string, any> | null) => {
  213. let caculatedIntroduction = introduction || conversationIntroduction || ''
  214. const caculatedPromptVariables = inputs || currInputs || null
  215. if (caculatedIntroduction && caculatedPromptVariables)
  216. caculatedIntroduction = replaceStringWithValues(caculatedIntroduction, promptConfig?.prompt_variables || [], caculatedPromptVariables)
  217. const openstatement = {
  218. id: `${Date.now()}`,
  219. content: caculatedIntroduction,
  220. isAnswer: true,
  221. feedbackDisabled: true,
  222. isOpeningStatement: isPublicVersion,
  223. }
  224. if (caculatedIntroduction)
  225. return [openstatement]
  226. return []
  227. }
  228. const fetchAllConversations = () => {
  229. return fetchConversations(isInstalledApp, installedAppInfo?.id, undefined, undefined, 100)
  230. }
  231. const fetchInitData = async () => {
  232. if (!isInstalledApp)
  233. await checkOrSetAccessToken()
  234. return Promise.all([isInstalledApp
  235. ? {
  236. app_id: installedAppInfo?.id,
  237. site: {
  238. title: installedAppInfo?.app.name,
  239. prompt_public: false,
  240. copyright: '',
  241. },
  242. plan: 'basic',
  243. }
  244. : fetchAppInfo(), fetchAllConversations(), fetchAppParams(isInstalledApp, installedAppInfo?.id)])
  245. }
  246. // init
  247. useEffect(() => {
  248. (async () => {
  249. try {
  250. const [appData, conversationData, appParams]: any = await fetchInitData()
  251. const { app_id: appId, site: siteInfo, plan }: any = appData
  252. setAppId(appId)
  253. setPlan(plan)
  254. const tempIsPublicVersion = siteInfo.prompt_public
  255. setIsPublicVersion(tempIsPublicVersion)
  256. const prompt_template = ''
  257. // handle current conversation id
  258. const { data: allConversations } = conversationData as { data: ConversationItem[]; has_more: boolean }
  259. const _conversationId = getConversationIdFromStorage(appId)
  260. const isNotNewConversation = allConversations.some(item => item.id === _conversationId)
  261. setAllConversationList(allConversations)
  262. // fetch new conversation info
  263. const { user_input_form, opening_statement: introduction, suggested_questions_after_answer, speech_to_text }: any = appParams
  264. const prompt_variables = userInputsFormToPromptVariables(user_input_form)
  265. if (siteInfo.default_language)
  266. changeLanguage(siteInfo.default_language)
  267. setNewConversationInfo({
  268. name: t('share.chat.newChatDefaultName'),
  269. introduction,
  270. })
  271. setSiteInfo(siteInfo as SiteInfo)
  272. setPromptConfig({
  273. prompt_template,
  274. prompt_variables,
  275. } as PromptConfig)
  276. setSuggestedQuestionsAfterAnswerConfig(suggested_questions_after_answer)
  277. setSpeechToTextConfig(speech_to_text)
  278. // setConversationList(conversations as ConversationItem[])
  279. if (isNotNewConversation)
  280. setCurrConversationId(_conversationId, appId, false)
  281. setInited(true)
  282. }
  283. catch (e: any) {
  284. if (e.status === 404) {
  285. setAppUnavailable(true)
  286. }
  287. else {
  288. setIsUnknwonReason(true)
  289. setAppUnavailable(true)
  290. }
  291. }
  292. })()
  293. }, [])
  294. const [isResponsing, { setTrue: setResponsingTrue, setFalse: setResponsingFalse }] = useBoolean(false)
  295. const [abortController, setAbortController] = useState<AbortController | null>(null)
  296. const { notify } = useContext(ToastContext)
  297. const logError = (message: string) => {
  298. notify({ type: 'error', message })
  299. }
  300. const checkCanSend = () => {
  301. if (currConversationId !== '-1')
  302. return true
  303. const prompt_variables = promptConfig?.prompt_variables
  304. const inputs = currInputs
  305. if (!inputs || !prompt_variables || prompt_variables?.length === 0)
  306. return true
  307. let hasEmptyInput = ''
  308. const requiredVars = prompt_variables?.filter(({ key, name, required }) => {
  309. const res = (!key || !key.trim()) || (!name || !name.trim()) || (required || required === undefined || required === null)
  310. return res
  311. }) || [] // compatible with old version
  312. requiredVars.forEach(({ key, name }) => {
  313. if (hasEmptyInput)
  314. return
  315. if (!inputs?.[key])
  316. hasEmptyInput = name
  317. })
  318. if (hasEmptyInput) {
  319. logError(t('appDebug.errorMessage.valueOfVarRequired', { key: hasEmptyInput }))
  320. return false
  321. }
  322. return !hasEmptyInput
  323. }
  324. const [controlFocus, setControlFocus] = useState(0)
  325. const [isShowSuggestion, setIsShowSuggestion] = useState(false)
  326. const doShowSuggestion = isShowSuggestion && !isResponsing
  327. const [suggestQuestions, setSuggestQuestions] = useState<string[]>([])
  328. const [messageTaskId, setMessageTaskId] = useState('')
  329. const [hasStopResponded, setHasStopResponded, getHasStopResponded] = useGetState(false)
  330. const [shouldReload, setShouldReload] = useState(false)
  331. const handleSend = async (message: string) => {
  332. if (isResponsing) {
  333. notify({ type: 'info', message: t('appDebug.errorMessage.waitForResponse') })
  334. return
  335. }
  336. const data = {
  337. inputs: currInputs,
  338. query: message,
  339. conversation_id: isNewConversation ? null : currConversationId,
  340. }
  341. // qustion
  342. const questionId = `question-${Date.now()}`
  343. const questionItem = {
  344. id: questionId,
  345. content: message,
  346. isAnswer: false,
  347. }
  348. const placeholderAnswerId = `answer-placeholder-${Date.now()}`
  349. const placeholderAnswerItem = {
  350. id: placeholderAnswerId,
  351. content: '',
  352. isAnswer: true,
  353. }
  354. const newList = [...getChatList(), questionItem, placeholderAnswerItem]
  355. setChatList(newList)
  356. // answer
  357. const responseItem = {
  358. id: `${Date.now()}`,
  359. content: '',
  360. isAnswer: true,
  361. }
  362. let tempNewConversationId = ''
  363. setHasStopResponded(false)
  364. setResponsingTrue()
  365. setIsShowSuggestion(false)
  366. sendChatMessage(data, {
  367. getAbortController: (abortController) => {
  368. setAbortController(abortController)
  369. },
  370. onData: (message: string, isFirstMessage: boolean, { conversationId: newConversationId, messageId, taskId }: any) => {
  371. responseItem.content = responseItem.content + message
  372. responseItem.id = messageId
  373. if (isFirstMessage && newConversationId)
  374. tempNewConversationId = newConversationId
  375. setMessageTaskId(taskId)
  376. // closesure new list is outdated.
  377. const newListWithAnswer = produce(
  378. getChatList().filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId),
  379. (draft) => {
  380. if (!draft.find(item => item.id === questionId))
  381. draft.push({ ...questionItem })
  382. draft.push({ ...responseItem })
  383. })
  384. setChatList(newListWithAnswer)
  385. },
  386. async onCompleted(hasError?: boolean) {
  387. setResponsingFalse()
  388. if (hasError)
  389. return
  390. if (getConversationIdChangeBecauseOfNew()) {
  391. const { data: allConversations }: any = await fetchAllConversations()
  392. setAllConversationList(allConversations)
  393. noticeUpdateList()
  394. }
  395. setConversationIdChangeBecauseOfNew(false)
  396. resetNewConversationInputs()
  397. setChatNotStarted()
  398. setCurrConversationId(tempNewConversationId, appId, true)
  399. if (suggestedQuestionsAfterAnswerConfig?.enabled && !getHasStopResponded()) {
  400. const { data }: any = await fetchSuggestedQuestions(responseItem.id, isInstalledApp, installedAppInfo?.id)
  401. setSuggestQuestions(data)
  402. setIsShowSuggestion(true)
  403. }
  404. },
  405. onError(errorMessage, errorCode) {
  406. if (['provider_not_initialize', 'completion_request_error'].includes(errorCode as string))
  407. setShouldReload(true)
  408. setResponsingFalse()
  409. // role back placeholder answer
  410. setChatList(produce(getChatList(), (draft) => {
  411. draft.splice(draft.findIndex(item => item.id === placeholderAnswerId), 1)
  412. }))
  413. },
  414. }, isInstalledApp, installedAppInfo?.id)
  415. }
  416. const handleFeedback = async (messageId: string, feedback: Feedbacktype) => {
  417. await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating } }, isInstalledApp, installedAppInfo?.id)
  418. const newChatList = chatList.map((item) => {
  419. if (item.id === messageId) {
  420. return {
  421. ...item,
  422. feedback,
  423. }
  424. }
  425. return item
  426. })
  427. setChatList(newChatList)
  428. notify({ type: 'success', message: t('common.api.success') })
  429. }
  430. const handleReload = () => {
  431. setCurrConversationId('-1', appId, false)
  432. setChatNotStarted()
  433. setShouldReload(false)
  434. createNewChat()
  435. }
  436. const difyIcon = (
  437. <div className={s.difyHeader}></div>
  438. )
  439. if (appUnavailable)
  440. return <AppUnavailable isUnknwonReason={isUnknwonReason} />
  441. if (!appId || !siteInfo || !promptConfig) {
  442. return <div className='flex h-screen w-full'>
  443. <Loading type='app' />
  444. </div>
  445. }
  446. return (
  447. <div>
  448. <Header
  449. title={siteInfo.title}
  450. icon=''
  451. customerIcon={difyIcon}
  452. icon_background={siteInfo.icon_background}
  453. isEmbedScene={true}
  454. isMobile={isMobile}
  455. />
  456. <div className={'flex bg-white overflow-hidden'}>
  457. <div className={cn(
  458. isInstalledApp ? s.installedApp : 'h-[calc(100vh_-_3rem)]',
  459. 'flex-grow flex flex-col overflow-y-auto',
  460. )
  461. }>
  462. <ConfigScene
  463. // conversationName={conversationName}
  464. hasSetInputs={hasSetInputs}
  465. isPublicVersion={isPublicVersion}
  466. siteInfo={siteInfo}
  467. promptConfig={promptConfig}
  468. onStartChat={handleStartChat}
  469. canEditInputs={canEditInputs}
  470. savedInputs={currInputs as Record<string, any>}
  471. onInputsChange={setCurrInputs}
  472. plan={plan}
  473. ></ConfigScene>
  474. {
  475. shouldReload && (
  476. <div className='flex items-center justify-between mb-5 px-4 py-2 bg-[#FEF0C7]'>
  477. <div className='flex items-center text-xs font-medium text-[#DC6803]'>
  478. <AlertTriangle className='mr-2 w-4 h-4' />
  479. {t('share.chat.temporarySystemIssue')}
  480. </div>
  481. <div
  482. className='flex items-center px-3 h-7 bg-white shadow-xs rounded-md text-xs font-medium text-gray-700 cursor-pointer'
  483. onClick={handleReload}
  484. >
  485. {t('share.chat.tryToSolve')}
  486. </div>
  487. </div>
  488. )
  489. }
  490. {
  491. hasSetInputs && (
  492. <div className={cn(doShowSuggestion ? 'pb-[140px]' : (isResponsing ? 'pb-[113px]' : 'pb-[76px]'), 'relative grow h-[200px] pc:w-[794px] max-w-full mobile:w-full mx-auto mb-3.5 overflow-hidden')}>
  493. <div className='h-full overflow-y-auto' ref={chatListDomRef}>
  494. <Chat
  495. chatList={chatList}
  496. onSend={handleSend}
  497. isHideFeedbackEdit
  498. onFeedback={handleFeedback}
  499. isResponsing={isResponsing}
  500. canStopResponsing={!!messageTaskId}
  501. abortResponsing={async () => {
  502. await stopChatMessageResponding(appId, messageTaskId, isInstalledApp, installedAppInfo?.id)
  503. setHasStopResponded(true)
  504. setResponsingFalse()
  505. }}
  506. checkCanSend={checkCanSend}
  507. controlFocus={controlFocus}
  508. isShowSuggestion={doShowSuggestion}
  509. suggestionList={suggestQuestions}
  510. displayScene='web'
  511. isShowSpeechToText={speechToTextConfig?.enabled}
  512. answerIconClassName={s.difyIcon}
  513. />
  514. </div>
  515. </div>)
  516. }
  517. {/* {isShowConfirm && (
  518. <Confirm
  519. title={t('share.chat.deleteConversation.title')}
  520. content={t('share.chat.deleteConversation.content')}
  521. isShow={isShowConfirm}
  522. onClose={hideConfirm}
  523. onConfirm={didDelete}
  524. onCancel={hideConfirm}
  525. />
  526. )} */}
  527. </div>
  528. </div>
  529. </div>
  530. )
  531. }
  532. export default React.memo(Main)