index.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591
  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. citation: item.retriever_resources,
  175. })
  176. })
  177. setChatList(newChatList)
  178. })
  179. }
  180. if (isNewConversation && isChatStarted)
  181. setChatList(generateNewChatListWithOpenstatement())
  182. setControlFocus(Date.now())
  183. }
  184. useEffect(handleConversationSwitch, [currConversationId, inited])
  185. /*
  186. * chat info. chat is under conversation.
  187. */
  188. const [chatList, setChatList, getChatList] = useGetState<IChatItem[]>([])
  189. const chatListDomRef = useRef<HTMLDivElement>(null)
  190. useEffect(() => {
  191. // scroll to bottom
  192. if (chatListDomRef.current)
  193. chatListDomRef.current.scrollTop = chatListDomRef.current.scrollHeight
  194. }, [chatList, currConversationId])
  195. // user can not edit inputs if user had send message
  196. const canEditInputs = !chatList.some(item => item.isAnswer === false) && isNewConversation
  197. const createNewChat = async () => {
  198. // if new chat is already exist, do not create new chat
  199. abortController?.abort()
  200. setResponsingFalse()
  201. if (conversationList.some(item => item.id === '-1'))
  202. return
  203. setConversationList(produce(conversationList, (draft) => {
  204. draft.unshift({
  205. id: '-1',
  206. name: t('share.chat.newChatDefaultName'),
  207. inputs: newConversationInputs,
  208. introduction: conversationIntroduction,
  209. })
  210. }))
  211. }
  212. // sometime introduction is not applied to state
  213. const generateNewChatListWithOpenstatement = (introduction?: string, inputs?: Record<string, any> | null) => {
  214. let caculatedIntroduction = introduction || conversationIntroduction || ''
  215. const caculatedPromptVariables = inputs || currInputs || null
  216. if (caculatedIntroduction && caculatedPromptVariables)
  217. caculatedIntroduction = replaceStringWithValues(caculatedIntroduction, promptConfig?.prompt_variables || [], caculatedPromptVariables)
  218. const openstatement = {
  219. id: `${Date.now()}`,
  220. content: caculatedIntroduction,
  221. isAnswer: true,
  222. feedbackDisabled: true,
  223. isOpeningStatement: isPublicVersion,
  224. }
  225. if (caculatedIntroduction)
  226. return [openstatement]
  227. return []
  228. }
  229. const fetchAllConversations = () => {
  230. return fetchConversations(isInstalledApp, installedAppInfo?.id, undefined, undefined, 100)
  231. }
  232. const fetchInitData = async () => {
  233. if (!isInstalledApp)
  234. await checkOrSetAccessToken()
  235. return Promise.all([isInstalledApp
  236. ? {
  237. app_id: installedAppInfo?.id,
  238. site: {
  239. title: installedAppInfo?.app.name,
  240. prompt_public: false,
  241. copyright: '',
  242. },
  243. plan: 'basic',
  244. }
  245. : fetchAppInfo(), fetchAllConversations(), fetchAppParams(isInstalledApp, installedAppInfo?.id)])
  246. }
  247. // init
  248. useEffect(() => {
  249. (async () => {
  250. try {
  251. const [appData, conversationData, appParams]: any = await fetchInitData()
  252. const { app_id: appId, site: siteInfo, plan }: any = appData
  253. setAppId(appId)
  254. setPlan(plan)
  255. const tempIsPublicVersion = siteInfo.prompt_public
  256. setIsPublicVersion(tempIsPublicVersion)
  257. const prompt_template = ''
  258. // handle current conversation id
  259. const { data: allConversations } = conversationData as { data: ConversationItem[]; has_more: boolean }
  260. const _conversationId = getConversationIdFromStorage(appId)
  261. const isNotNewConversation = allConversations.some(item => item.id === _conversationId)
  262. setAllConversationList(allConversations)
  263. // fetch new conversation info
  264. const { user_input_form, opening_statement: introduction, suggested_questions_after_answer, speech_to_text, retriever_resource }: any = appParams
  265. const prompt_variables = userInputsFormToPromptVariables(user_input_form)
  266. if (siteInfo.default_language)
  267. changeLanguage(siteInfo.default_language)
  268. setNewConversationInfo({
  269. name: t('share.chat.newChatDefaultName'),
  270. introduction,
  271. })
  272. setSiteInfo(siteInfo as SiteInfo)
  273. setPromptConfig({
  274. prompt_template,
  275. prompt_variables,
  276. } as PromptConfig)
  277. setSuggestedQuestionsAfterAnswerConfig(suggested_questions_after_answer)
  278. setSpeechToTextConfig(speech_to_text)
  279. // setConversationList(conversations as ConversationItem[])
  280. if (isNotNewConversation)
  281. setCurrConversationId(_conversationId, appId, false)
  282. setInited(true)
  283. }
  284. catch (e: any) {
  285. if (e.status === 404) {
  286. setAppUnavailable(true)
  287. }
  288. else {
  289. setIsUnknwonReason(true)
  290. setAppUnavailable(true)
  291. }
  292. }
  293. })()
  294. }, [])
  295. const [isResponsing, { setTrue: setResponsingTrue, setFalse: setResponsingFalse }] = useBoolean(false)
  296. const [abortController, setAbortController] = useState<AbortController | null>(null)
  297. const { notify } = useContext(ToastContext)
  298. const logError = (message: string) => {
  299. notify({ type: 'error', message })
  300. }
  301. const checkCanSend = () => {
  302. if (currConversationId !== '-1')
  303. return true
  304. const prompt_variables = promptConfig?.prompt_variables
  305. const inputs = currInputs
  306. if (!inputs || !prompt_variables || prompt_variables?.length === 0)
  307. return true
  308. let hasEmptyInput = ''
  309. const requiredVars = prompt_variables?.filter(({ key, name, required }) => {
  310. const res = (!key || !key.trim()) || (!name || !name.trim()) || (required || required === undefined || required === null)
  311. return res
  312. }) || [] // compatible with old version
  313. requiredVars.forEach(({ key, name }) => {
  314. if (hasEmptyInput)
  315. return
  316. if (!inputs?.[key])
  317. hasEmptyInput = name
  318. })
  319. if (hasEmptyInput) {
  320. logError(t('appDebug.errorMessage.valueOfVarRequired', { key: hasEmptyInput }))
  321. return false
  322. }
  323. return !hasEmptyInput
  324. }
  325. const [controlFocus, setControlFocus] = useState(0)
  326. const [isShowSuggestion, setIsShowSuggestion] = useState(false)
  327. const doShowSuggestion = isShowSuggestion && !isResponsing
  328. const [suggestQuestions, setSuggestQuestions] = useState<string[]>([])
  329. const [messageTaskId, setMessageTaskId] = useState('')
  330. const [hasStopResponded, setHasStopResponded, getHasStopResponded] = useGetState(false)
  331. const [shouldReload, setShouldReload] = useState(false)
  332. const handleSend = async (message: string) => {
  333. if (isResponsing) {
  334. notify({ type: 'info', message: t('appDebug.errorMessage.waitForResponse') })
  335. return
  336. }
  337. const data = {
  338. inputs: currInputs,
  339. query: message,
  340. conversation_id: isNewConversation ? null : currConversationId,
  341. }
  342. // qustion
  343. const questionId = `question-${Date.now()}`
  344. const questionItem = {
  345. id: questionId,
  346. content: message,
  347. isAnswer: false,
  348. }
  349. const placeholderAnswerId = `answer-placeholder-${Date.now()}`
  350. const placeholderAnswerItem = {
  351. id: placeholderAnswerId,
  352. content: '',
  353. isAnswer: true,
  354. }
  355. const newList = [...getChatList(), questionItem, placeholderAnswerItem]
  356. setChatList(newList)
  357. // answer
  358. const responseItem: IChatItem = {
  359. id: `${Date.now()}`,
  360. content: '',
  361. isAnswer: true,
  362. }
  363. let tempNewConversationId = ''
  364. setHasStopResponded(false)
  365. setResponsingTrue()
  366. setIsShowSuggestion(false)
  367. sendChatMessage(data, {
  368. getAbortController: (abortController) => {
  369. setAbortController(abortController)
  370. },
  371. onData: (message: string, isFirstMessage: boolean, { conversationId: newConversationId, messageId, taskId }: any) => {
  372. responseItem.content = responseItem.content + message
  373. responseItem.id = messageId
  374. if (isFirstMessage && newConversationId)
  375. tempNewConversationId = newConversationId
  376. setMessageTaskId(taskId)
  377. // closesure new list is outdated.
  378. const newListWithAnswer = produce(
  379. getChatList().filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId),
  380. (draft) => {
  381. if (!draft.find(item => item.id === questionId))
  382. draft.push({ ...questionItem })
  383. draft.push({ ...responseItem })
  384. })
  385. setChatList(newListWithAnswer)
  386. },
  387. async onCompleted(hasError?: boolean) {
  388. setResponsingFalse()
  389. if (hasError)
  390. return
  391. if (getConversationIdChangeBecauseOfNew()) {
  392. const { data: allConversations }: any = await fetchAllConversations()
  393. setAllConversationList(allConversations)
  394. noticeUpdateList()
  395. }
  396. setConversationIdChangeBecauseOfNew(false)
  397. resetNewConversationInputs()
  398. setChatNotStarted()
  399. setCurrConversationId(tempNewConversationId, appId, true)
  400. if (suggestedQuestionsAfterAnswerConfig?.enabled && !getHasStopResponded()) {
  401. const { data }: any = await fetchSuggestedQuestions(responseItem.id, isInstalledApp, installedAppInfo?.id)
  402. setSuggestQuestions(data)
  403. setIsShowSuggestion(true)
  404. }
  405. },
  406. onError(errorMessage, errorCode) {
  407. if (['provider_not_initialize', 'completion_request_error'].includes(errorCode as string))
  408. setShouldReload(true)
  409. setResponsingFalse()
  410. // role back placeholder answer
  411. setChatList(produce(getChatList(), (draft) => {
  412. draft.splice(draft.findIndex(item => item.id === placeholderAnswerId), 1)
  413. }))
  414. },
  415. }, isInstalledApp, installedAppInfo?.id)
  416. }
  417. const handleFeedback = async (messageId: string, feedback: Feedbacktype) => {
  418. await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating } }, isInstalledApp, installedAppInfo?.id)
  419. const newChatList = chatList.map((item) => {
  420. if (item.id === messageId) {
  421. return {
  422. ...item,
  423. feedback,
  424. }
  425. }
  426. return item
  427. })
  428. setChatList(newChatList)
  429. notify({ type: 'success', message: t('common.api.success') })
  430. }
  431. const handleReload = () => {
  432. setCurrConversationId('-1', appId, false)
  433. setChatNotStarted()
  434. setShouldReload(false)
  435. createNewChat()
  436. }
  437. const difyIcon = (
  438. <div className={s.difyHeader}></div>
  439. )
  440. if (appUnavailable)
  441. return <AppUnavailable isUnknwonReason={isUnknwonReason} />
  442. if (!appId || !siteInfo || !promptConfig) {
  443. return <div className='flex h-screen w-full'>
  444. <Loading type='app' />
  445. </div>
  446. }
  447. return (
  448. <div>
  449. <Header
  450. title={siteInfo.title}
  451. icon=''
  452. customerIcon={difyIcon}
  453. icon_background={siteInfo.icon_background}
  454. isEmbedScene={true}
  455. isMobile={isMobile}
  456. />
  457. <div className={'flex bg-white overflow-hidden'}>
  458. <div className={cn(
  459. isInstalledApp ? s.installedApp : 'h-[calc(100vh_-_3rem)]',
  460. 'flex-grow flex flex-col overflow-y-auto',
  461. )
  462. }>
  463. <ConfigScene
  464. // conversationName={conversationName}
  465. hasSetInputs={hasSetInputs}
  466. isPublicVersion={isPublicVersion}
  467. siteInfo={siteInfo}
  468. promptConfig={promptConfig}
  469. onStartChat={handleStartChat}
  470. canEditInputs={canEditInputs}
  471. savedInputs={currInputs as Record<string, any>}
  472. onInputsChange={setCurrInputs}
  473. plan={plan}
  474. ></ConfigScene>
  475. {
  476. shouldReload && (
  477. <div className='flex items-center justify-between mb-5 px-4 py-2 bg-[#FEF0C7]'>
  478. <div className='flex items-center text-xs font-medium text-[#DC6803]'>
  479. <AlertTriangle className='mr-2 w-4 h-4' />
  480. {t('share.chat.temporarySystemIssue')}
  481. </div>
  482. <div
  483. className='flex items-center px-3 h-7 bg-white shadow-xs rounded-md text-xs font-medium text-gray-700 cursor-pointer'
  484. onClick={handleReload}
  485. >
  486. {t('share.chat.tryToSolve')}
  487. </div>
  488. </div>
  489. )
  490. }
  491. {
  492. hasSetInputs && (
  493. <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')}>
  494. <div className='h-full overflow-y-auto' ref={chatListDomRef}>
  495. <Chat
  496. chatList={chatList}
  497. onSend={handleSend}
  498. isHideFeedbackEdit
  499. onFeedback={handleFeedback}
  500. isResponsing={isResponsing}
  501. canStopResponsing={!!messageTaskId}
  502. abortResponsing={async () => {
  503. await stopChatMessageResponding(appId, messageTaskId, isInstalledApp, installedAppInfo?.id)
  504. setHasStopResponded(true)
  505. setResponsingFalse()
  506. }}
  507. checkCanSend={checkCanSend}
  508. controlFocus={controlFocus}
  509. isShowSuggestion={doShowSuggestion}
  510. suggestionList={suggestQuestions}
  511. displayScene='web'
  512. isShowSpeechToText={speechToTextConfig?.enabled}
  513. answerIconClassName={s.difyIcon}
  514. />
  515. </div>
  516. </div>)
  517. }
  518. {/* {isShowConfirm && (
  519. <Confirm
  520. title={t('share.chat.deleteConversation.title')}
  521. content={t('share.chat.deleteConversation.content')}
  522. isShow={isShowConfirm}
  523. onClose={hideConfirm}
  524. onConfirm={didDelete}
  525. onCancel={hideConfirm}
  526. />
  527. )} */}
  528. </div>
  529. </div>
  530. </div>
  531. )
  532. }
  533. export default React.memo(Main)