Преглед на файлове

Chore: refactor embedded chatbot (#5125)

KVOJJJin преди 10 месеца
родител
ревизия
4289f17be2

+ 2 - 2
web/app/(shareLayout)/chatbot/[token]/page.tsx

@@ -3,7 +3,7 @@ import type { FC } from 'react'
 import React, { useEffect } from 'react'
 import cn from 'classnames'
 import type { IMainProps } from '@/app/components/share/chat'
-import Main from '@/app/components/share/chatbot'
+import EmbeddedChatbot from '@/app/components/base/chat/embedded-chatbot'
 import Loading from '@/app/components/base/loading'
 import { fetchSystemFeatures } from '@/service/share'
 import LogoSite from '@/app/components/base/logo/logo-site'
@@ -77,7 +77,7 @@ const Chatbot: FC<IMainProps> = () => {
                     </div>
                   </div>
                 )
-                : <Main />
+                : <EmbeddedChatbot />
               }
             </>
           )}

+ 2 - 2
web/app/components/app/chat/icon-component/index.tsx

@@ -37,7 +37,7 @@ export const TryToAskIcon = (
 )
 
 export const ReplayIcon = ({ className }: SVGProps<SVGElement>) => (
-  <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-    <path d="M1.33301 6.66667C1.33301 6.66667 2.66966 4.84548 3.75556 3.75883C4.84147 2.67218 6.34207 2 7.99967 2C11.3134 2 13.9997 4.68629 13.9997 8C13.9997 11.3137 11.3134 14 7.99967 14C5.26428 14 2.95642 12.1695 2.23419 9.66667M1.33301 6.66667V2.66667M1.33301 6.66667H5.33301" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
+  <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
+    <path d="M1.33301 6.66667C1.33301 6.66667 2.66966 4.84548 3.75556 3.75883C4.84147 2.67218 6.34207 2 7.99967 2C11.3134 2 13.9997 4.68629 13.9997 8C13.9997 11.3137 11.3134 14 7.99967 14C5.26428 14 2.95642 12.1695 2.23419 9.66667M1.33301 6.66667V2.66667M1.33301 6.66667H5.33301" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
   </svg>
 )

+ 3 - 3
web/app/components/base/app-unavailable.tsx

@@ -5,13 +5,13 @@ import { useTranslation } from 'react-i18next'
 
 type IAppUnavailableProps = {
   code?: number
-  isUnknwonReason?: boolean
+  isUnknownReason?: boolean
   unknownReason?: string
 }
 
 const AppUnavailable: FC<IAppUnavailableProps> = ({
   code = 404,
-  isUnknwonReason,
+  isUnknownReason,
   unknownReason,
 }) => {
   const { t } = useTranslation()
@@ -22,7 +22,7 @@ const AppUnavailable: FC<IAppUnavailableProps> = ({
         style={{
           borderRight: '1px solid rgba(0,0,0,.3)',
         }}>{code}</h1>
-      <div className='text-sm'>{unknownReason || (isUnknwonReason ? t('share.common.appUnkonwError') : t('share.common.appUnavailable'))}</div>
+      <div className='text-sm'>{unknownReason || (isUnknownReason ? t('share.common.appUnkonwError') : t('share.common.appUnavailable'))}</div>
     </div>
   )
 }

+ 9 - 9
web/app/components/base/chat/chat-with-history/index.tsx

@@ -181,12 +181,12 @@ const ChatWithHistoryWrapWithCheckToken: FC<ChatWithHistoryWrapProps> = ({
   installedAppInfo,
   className,
 }) => {
-  const [inited, setInited] = useState(false)
+  const [initialized, setInitialized] = useState(false)
   const [appUnavailable, setAppUnavailable] = useState<boolean>(false)
-  const [isUnknwonReason, setIsUnknwonReason] = useState<boolean>(false)
+  const [isUnknownReason, setIsUnknownReason] = useState<boolean>(false)
 
   useAsyncEffect(async () => {
-    if (!inited) {
+    if (!initialized) {
       if (!installedAppInfo) {
         try {
           await checkOrSetAccessToken()
@@ -196,21 +196,21 @@ const ChatWithHistoryWrapWithCheckToken: FC<ChatWithHistoryWrapProps> = ({
             setAppUnavailable(true)
           }
           else {
-            setIsUnknwonReason(true)
+            setIsUnknownReason(true)
             setAppUnavailable(true)
           }
         }
       }
-      setInited(true)
+      setInitialized(true)
     }
   }, [])
 
-  if (appUnavailable)
-    return <AppUnavailable isUnknwonReason={isUnknwonReason} />
-
-  if (!inited)
+  if (!initialized)
     return null
 
+  if (appUnavailable)
+    return <AppUnavailable isUnknownReason={isUnknownReason} />
+
   return (
     <ChatWithHistoryWrap
       installedAppInfo={installedAppInfo}

+ 135 - 0
web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx

@@ -0,0 +1,135 @@
+import { useCallback, useEffect, useMemo } from 'react'
+import cn from 'classnames'
+import Chat from '../chat'
+import type {
+  ChatConfig,
+  OnSend,
+} from '../types'
+import { useChat } from '../chat/hooks'
+import { useEmbeddedChatbotContext } from './context'
+import ConfigPanel from './config-panel'
+import { isDify } from './utils'
+import {
+  fetchSuggestedQuestions,
+  getUrl,
+  stopChatMessageResponding,
+} from '@/service/share'
+import LogoAvatar from '@/app/components/base/logo/logo-embeded-chat-avatar'
+
+const ChatWrapper = () => {
+  const {
+    appParams,
+    appPrevChatList,
+    currentConversationId,
+    currentConversationItem,
+    inputsForms,
+    newConversationInputs,
+    handleNewConversationCompleted,
+    isMobile,
+    isInstalledApp,
+    appId,
+    appMeta,
+    handleFeedback,
+    currentChatInstanceRef,
+  } = useEmbeddedChatbotContext()
+  const appConfig = useMemo(() => {
+    const config = appParams || {}
+
+    return {
+      ...config,
+      supportFeedback: true,
+      opening_statement: currentConversationId ? currentConversationItem?.introduction : (config as any).opening_statement,
+    } as ChatConfig
+  }, [appParams, currentConversationItem?.introduction, currentConversationId])
+  const {
+    chatList,
+    handleSend,
+    handleStop,
+    isResponding,
+    suggestedQuestions,
+  } = useChat(
+    appConfig,
+    {
+      inputs: (currentConversationId ? currentConversationItem?.inputs : newConversationInputs) as any,
+      promptVariables: inputsForms,
+    },
+    appPrevChatList,
+    taskId => stopChatMessageResponding('', taskId, isInstalledApp, appId),
+  )
+
+  useEffect(() => {
+    if (currentChatInstanceRef.current)
+      currentChatInstanceRef.current.handleStop = handleStop
+  }, [])
+
+  const doSend: OnSend = useCallback((message, files) => {
+    const data: any = {
+      query: message,
+      inputs: currentConversationId ? currentConversationItem?.inputs : newConversationInputs,
+      conversation_id: currentConversationId,
+    }
+
+    if (appConfig?.file_upload?.image.enabled && files?.length)
+      data.files = files
+
+    handleSend(
+      getUrl('chat-messages', isInstalledApp, appId || ''),
+      data,
+      {
+        onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, isInstalledApp, appId),
+        onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
+        isPublicAPI: !isInstalledApp,
+      },
+    )
+  }, [
+    appConfig,
+    currentConversationId,
+    currentConversationItem,
+    handleSend,
+    newConversationInputs,
+    handleNewConversationCompleted,
+    isInstalledApp,
+    appId,
+  ])
+  const chatNode = useMemo(() => {
+    if (inputsForms.length) {
+      return (
+        <>
+          {!currentConversationId && (
+            <div className={cn('mx-auto w-full max-w-[720px] tablet:px-4', isMobile && 'px-4')}>
+              <div className='mb-6' />
+              <ConfigPanel />
+              <div
+                className='my-6 h-[1px]'
+                style={{ background: 'linear-gradient(90deg, rgba(242, 244, 247, 0.00) 0%, #F2F4F7 49.17%, rgba(242, 244, 247, 0.00) 100%)' }}
+              />
+            </div>
+          )}
+        </>
+      )
+    }
+
+    return <div className='mb-6' />
+  }, [currentConversationId, inputsForms, isMobile])
+
+  return (
+    <Chat
+      config={appConfig}
+      chatList={chatList}
+      isResponding={isResponding}
+      chatContainerInnerClassName={cn('mx-auto w-full max-w-[720px] tablet:px-4', isMobile && 'px-4')}
+      chatFooterClassName='pb-4'
+      chatFooterInnerClassName={cn('mx-auto w-full max-w-[720px] tablet:px-4', isMobile && 'px-4')}
+      onSend={doSend}
+      onStopResponding={handleStop}
+      chatNode={chatNode}
+      allToolIcons={appMeta?.tool_icons || {}}
+      onFeedback={handleFeedback}
+      suggestedQuestions={suggestedQuestions}
+      answerIcon={isDify() ? <LogoAvatar className='relative shrink-0' /> : null}
+      hideProcessDetail
+    />
+  )
+}
+
+export default ChatWrapper

+ 46 - 0
web/app/components/base/chat/embedded-chatbot/config-panel/form-input.tsx

@@ -0,0 +1,46 @@
+import type { FC } from 'react'
+import { useTranslation } from 'react-i18next'
+import { memo } from 'react'
+
+type InputProps = {
+  form: any
+  value: string
+  onChange: (variable: string, value: string) => void
+}
+const FormInput: FC<InputProps> = ({
+  form,
+  value,
+  onChange,
+}) => {
+  const { t } = useTranslation()
+  const {
+    type,
+    label,
+    required,
+    max_length,
+    variable,
+  } = form
+
+  if (type === 'paragraph') {
+    return (
+      <textarea
+        value={value}
+        className='grow h-[104px] rounded-lg bg-gray-100 px-2.5 py-2 outline-none appearance-none resize-none'
+        onChange={e => onChange(variable, e.target.value)}
+        placeholder={`${label}${!required ? `(${t('appDebug.variableTable.optional')})` : ''}`}
+      />
+    )
+  }
+
+  return (
+    <input
+      className='grow h-9 rounded-lg bg-gray-100 px-2.5 outline-none appearance-none'
+      value={value || ''}
+      maxLength={max_length}
+      onChange={e => onChange(variable, e.target.value)}
+      placeholder={`${label}${!required ? `(${t('appDebug.variableTable.optional')})` : ''}`}
+    />
+  )
+}
+
+export default memo(FormInput)

+ 83 - 0
web/app/components/base/chat/embedded-chatbot/config-panel/form.tsx

@@ -0,0 +1,83 @@
+import { useCallback } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useEmbeddedChatbotContext } from '../context'
+import Input from './form-input'
+import { PortalSelect } from '@/app/components/base/select'
+
+const Form = () => {
+  const { t } = useTranslation()
+  const {
+    inputsForms,
+    newConversationInputs,
+    handleNewConversationInputsChange,
+    isMobile,
+  } = useEmbeddedChatbotContext()
+
+  const handleFormChange = useCallback((variable: string, value: string) => {
+    handleNewConversationInputsChange({
+      ...newConversationInputs,
+      [variable]: value,
+    })
+  }, [newConversationInputs, handleNewConversationInputsChange])
+
+  const renderField = (form: any) => {
+    const {
+      label,
+      required,
+      variable,
+      options,
+    } = form
+
+    if (form.type === 'text-input' || form.type === 'paragraph') {
+      return (
+        <Input
+          form={form}
+          value={newConversationInputs[variable]}
+          onChange={handleFormChange}
+        />
+      )
+    }
+    if (form.type === 'number') {
+      return (
+        <input
+          className="grow h-9 rounded-lg bg-gray-100 px-2.5 outline-none appearance-none"
+          type="number"
+          value={newConversationInputs[variable] || ''}
+          onChange={e => handleFormChange(variable, e.target.value)}
+          placeholder={`${label}${!required ? `(${t('appDebug.variableTable.optional')})` : ''}`}
+        />
+      )
+    }
+
+    return (
+      <PortalSelect
+        popupClassName='w-[200px]'
+        value={newConversationInputs[variable]}
+        items={options.map((option: string) => ({ value: option, name: option }))}
+        onSelect={item => handleFormChange(variable, item.value as string)}
+        placeholder={`${label}${!required ? `(${t('appDebug.variableTable.optional')})` : ''}`}
+      />
+    )
+  }
+
+  if (!inputsForms.length)
+    return null
+
+  return (
+    <div className='mb-4 py-2'>
+      {
+        inputsForms.map(form => (
+          <div
+            key={form.variable}
+            className={`flex mb-3 last-of-type:mb-0 text-sm text-gray-900 ${isMobile && '!flex-wrap'}`}
+          >
+            <div className={`shrink-0 mr-2 py-2 w-[128px] ${isMobile && '!w-full'}`}>{form.label}</div>
+            {renderField(form)}
+          </div>
+        ))
+      }
+    </div>
+  )
+}
+
+export default Form

+ 168 - 0
web/app/components/base/chat/embedded-chatbot/config-panel/index.tsx

@@ -0,0 +1,168 @@
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import cn from 'classnames'
+import { useEmbeddedChatbotContext } from '../context'
+import Form from './form'
+import Button from '@/app/components/base/button'
+import AppIcon from '@/app/components/base/app-icon'
+import { MessageDotsCircle } from '@/app/components/base/icons/src/vender/solid/communication'
+import { Edit02 } from '@/app/components/base/icons/src/vender/line/general'
+import { Star06 } from '@/app/components/base/icons/src/vender/solid/shapes'
+import { FootLogo } from '@/app/components/share/chat/welcome/massive-component'
+
+const ConfigPanel = () => {
+  const { t } = useTranslation()
+  const {
+    appData,
+    inputsForms,
+    handleStartChat,
+    showConfigPanelBeforeChat,
+    isMobile,
+  } = useEmbeddedChatbotContext()
+  const [collapsed, setCollapsed] = useState(true)
+  const customConfig = appData?.custom_config
+  const site = appData?.site
+
+  return (
+    <div className='flex flex-col max-h-[80%] w-full max-w-[720px]'>
+      <div
+        className={cn(
+          'grow rounded-xl overflow-y-auto',
+          showConfigPanelBeforeChat && 'border-[0.5px] border-gray-100 shadow-lg',
+          !showConfigPanelBeforeChat && collapsed && 'border border-indigo-100',
+          !showConfigPanelBeforeChat && !collapsed && 'border-[0.5px] border-gray-100 shadow-lg',
+        )}
+      >
+        <div
+          className={`
+            flex flex-wrap px-6 py-4 rounded-t-xl bg-indigo-25
+            ${isMobile && '!px-4 !py-3'}
+          `}
+        >
+          {
+            showConfigPanelBeforeChat && (
+              <>
+                <div className='flex items-center h-8 text-2xl font-semibold text-gray-800'>
+                  <AppIcon
+                    icon={appData?.site.icon}
+                    background='transparent'
+                    size='small'
+                  />
+                  {appData?.site.title}
+                </div>
+                {
+                  appData?.site.description && (
+                    <div className='mt-2 w-full text-sm text-gray-500'>
+                      {appData?.site.description}
+                    </div>
+                  )
+                }
+              </>
+            )
+          }
+          {
+            !showConfigPanelBeforeChat && collapsed && (
+              <>
+                <Star06 className='mr-1 mt-1 w-4 h-4 text-indigo-600' />
+                <div className='grow py-[3px] text-[13px] text-indigo-600 leading-[18px] font-medium'>
+                  {t('share.chat.configStatusDes')}
+                </div>
+                <Button
+                  className='shrink-0 px-2 py-0 h-6 bg-white text-xs font-medium text-primary-600 rounded-md'
+                  onClick={() => setCollapsed(false)}
+                >
+                  <Edit02 className='mr-1 w-3 h-3' />
+                  {t('common.operation.edit')}
+                </Button>
+              </>
+            )
+          }
+          {
+            !showConfigPanelBeforeChat && !collapsed && (
+              <>
+                <Star06 className='mr-1 mt-1 w-4 h-4 text-indigo-600' />
+                <div className='grow py-[3px] text-[13px] text-indigo-600 leading-[18px] font-medium'>
+                  {t('share.chat.privatePromptConfigTitle')}
+                </div>
+              </>
+            )
+          }
+        </div>
+        {
+          !collapsed && !showConfigPanelBeforeChat && (
+            <div className='p-6 rounded-b-xl'>
+              <Form />
+              <div className={cn('pl-[136px] flex items-center', isMobile && '!pl-0')}>
+                <Button
+                  type='primary'
+                  className='mr-2 text-sm font-medium'
+                  onClick={() => {
+                    setCollapsed(true)
+                    handleStartChat()
+                  }}
+                >
+                  {t('common.operation.save')}
+                </Button>
+                <Button
+                  className='text-sm font-medium'
+                  onClick={() => setCollapsed(true)}
+                >
+                  {t('common.operation.cancel')}
+                </Button>
+              </div>
+            </div>
+          )
+        }
+        {
+          showConfigPanelBeforeChat && (
+            <div className='p-6 rounded-b-xl'>
+              <Form />
+              <Button
+                className={cn('px-4 py-0 h-9', inputsForms.length && !isMobile && 'ml-[136px]')}
+                type='primary'
+                onClick={handleStartChat}
+              >
+                <MessageDotsCircle className='mr-2 w-4 h-4 text-white' />
+                {t('share.chat.startChat')}
+              </Button>
+            </div>
+          )
+        }
+      </div>
+      {
+        showConfigPanelBeforeChat && (site || customConfig) && (
+          <div className='mt-4 flex flex-wrap justify-between items-center py-2 text-xs text-gray-400'>
+            {site?.privacy_policy
+              ? <div className={cn(isMobile && 'mb-2 w-full text-center')}>{t('share.chat.privacyPolicyLeft')}
+                <a
+                  className='text-gray-500 px-1'
+                  href={site?.privacy_policy}
+                  target='_blank' rel='noopener noreferrer'>{t('share.chat.privacyPolicyMiddle')}</a>
+                {t('share.chat.privacyPolicyRight')}
+              </div>
+              : <div>
+              </div>}
+            {
+              customConfig?.remove_webapp_brand
+                ? null
+                : (
+                  <div className={cn('flex items-center justify-end', isMobile && 'w-full')}>
+                    <div className='flex items-center pr-3 space-x-3'>
+                      <span className='uppercase'>{t('share.chat.powerBy')}</span>
+                      {
+                        customConfig?.replace_webapp_logo
+                          ? <img src={customConfig?.replace_webapp_logo} alt='logo' className='block w-auto h-5' />
+                          : <FootLogo />
+                      }
+                    </div>
+                  </div>
+                )
+            }
+          </div>
+        )
+      }
+    </div>
+  )
+}
+
+export default ConfigPanel

+ 64 - 0
web/app/components/base/chat/embedded-chatbot/context.tsx

@@ -0,0 +1,64 @@
+'use client'
+
+import type { RefObject } from 'react'
+import { createContext, useContext } from 'use-context-selector'
+import type {
+  ChatConfig,
+  ChatItem,
+  Feedback,
+} from '../types'
+import type {
+  AppConversationData,
+  AppData,
+  AppMeta,
+  ConversationItem,
+} from '@/models/share'
+
+export type EmbeddedChatbotContextValue = {
+  appInfoError?: any
+  appInfoLoading?: boolean
+  appMeta?: AppMeta
+  appData?: AppData
+  appParams?: ChatConfig
+  appChatListDataLoading?: boolean
+  currentConversationId: string
+  currentConversationItem?: ConversationItem
+  appPrevChatList: ChatItem[]
+  pinnedConversationList: AppConversationData['data']
+  conversationList: AppConversationData['data']
+  showConfigPanelBeforeChat: boolean
+  newConversationInputs: Record<string, any>
+  handleNewConversationInputsChange: (v: Record<string, any>) => void
+  inputsForms: any[]
+  handleNewConversation: () => void
+  handleStartChat: () => void
+  handleChangeConversation: (conversationId: string) => void
+  handleNewConversationCompleted: (newConversationId: string) => void
+  chatShouldReloadKey: string
+  isMobile: boolean
+  isInstalledApp: boolean
+  appId?: string
+  handleFeedback: (messageId: string, feedback: Feedback) => void
+  currentChatInstanceRef: RefObject<{ handleStop: () => void }>
+}
+
+export const EmbeddedChatbotContext = createContext<EmbeddedChatbotContextValue>({
+  currentConversationId: '',
+  appPrevChatList: [],
+  pinnedConversationList: [],
+  conversationList: [],
+  showConfigPanelBeforeChat: false,
+  newConversationInputs: {},
+  handleNewConversationInputsChange: () => {},
+  inputsForms: [],
+  handleNewConversation: () => {},
+  handleStartChat: () => {},
+  handleChangeConversation: () => {},
+  handleNewConversationCompleted: () => {},
+  chatShouldReloadKey: '',
+  isMobile: false,
+  isInstalledApp: false,
+  handleFeedback: () => {},
+  currentChatInstanceRef: { current: { handleStop: () => {} } },
+})
+export const useEmbeddedChatbotContext = () => useContext(EmbeddedChatbotContext)

+ 58 - 0
web/app/components/base/chat/embedded-chatbot/header.tsx

@@ -0,0 +1,58 @@
+import type { FC } from 'react'
+import React from 'react'
+import { useTranslation } from 'react-i18next'
+// import AppIcon from '@/app/components/base/app-icon'
+import { ReplayIcon } from '@/app/components/app/chat/icon-component'
+import Tooltip from '@/app/components/base/tooltip'
+
+export type IHeaderProps = {
+  isMobile?: boolean
+  customerIcon?: React.ReactNode
+  title: string
+  // icon: string
+  // icon_background: string
+  onCreateNewChat?: () => void
+}
+const Header: FC<IHeaderProps> = ({
+  isMobile,
+  customerIcon,
+  title,
+  // icon,
+  // icon_background,
+  onCreateNewChat,
+}) => {
+  const { t } = useTranslation()
+  if (!isMobile)
+    return null
+
+  return (
+    <div
+      className={`
+        shrink-0 flex items-center justify-between h-14 px-4 bg-gray-100 
+        bg-gradient-to-r from-blue-600 to-sky-500
+      `}
+    >
+      <div className="flex items-center space-x-2">
+        {customerIcon}
+        <div
+          className={'text-sm font-bold text-white'}
+        >
+          {title}
+        </div>
+      </div>
+      <Tooltip
+        selector={'embed-scene-restart-button'}
+        htmlContent={t('share.chat.resetChat')}
+        position='top'
+      >
+        <div className='flex cursor-pointer hover:rounded-lg hover:bg-black/5 w-8 h-8 items-center justify-center' onClick={() => {
+          onCreateNewChat?.()
+        }}>
+          <ReplayIcon className="h-4 w-4 text-sm font-bold text-white" />
+        </div>
+      </Tooltip>
+    </div>
+  )
+}
+
+export default React.memo(Header)

+ 293 - 0
web/app/components/base/chat/embedded-chatbot/hooks.tsx

@@ -0,0 +1,293 @@
+import {
+  useCallback,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+} from 'react'
+import { useTranslation } from 'react-i18next'
+import useSWR from 'swr'
+import { useLocalStorageState } from 'ahooks'
+import produce from 'immer'
+import type {
+  ChatConfig,
+  ChatItem,
+  Feedback,
+} from '../types'
+import { CONVERSATION_ID_INFO } from '../constants'
+import {
+  fetchAppInfo,
+  fetchAppMeta,
+  fetchAppParams,
+  fetchChatList,
+  fetchConversations,
+  generationConversationName,
+  updateFeedback,
+} from '@/service/share'
+import type {
+  // AppData,
+  ConversationItem,
+} from '@/models/share'
+import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils'
+import { useToastContext } from '@/app/components/base/toast'
+import { changeLanguage } from '@/i18n/i18next-config'
+
+export const useEmbeddedChatbot = () => {
+  const isInstalledApp = false
+  const { data: appInfo, isLoading: appInfoLoading, error: appInfoError } = useSWR('appInfo', fetchAppInfo)
+
+  const appData = useMemo(() => {
+    return appInfo
+  }, [appInfo])
+  const appId = useMemo(() => appData?.app_id, [appData])
+
+  useEffect(() => {
+    if (appInfo?.site.default_language)
+      changeLanguage(appInfo.site.default_language)
+  }, [appInfo])
+
+  const [conversationIdInfo, setConversationIdInfo] = useLocalStorageState<Record<string, string>>(CONVERSATION_ID_INFO, {
+    defaultValue: {},
+  })
+  const currentConversationId = useMemo(() => conversationIdInfo?.[appId || ''] || '', [appId, conversationIdInfo])
+  const handleConversationIdInfoChange = useCallback((changeConversationId: string) => {
+    if (appId) {
+      setConversationIdInfo({
+        ...conversationIdInfo,
+        [appId || '']: changeConversationId,
+      })
+    }
+  }, [appId, conversationIdInfo, setConversationIdInfo])
+  const [showConfigPanelBeforeChat, setShowConfigPanelBeforeChat] = useState(true)
+
+  const [newConversationId, setNewConversationId] = useState('')
+  const chatShouldReloadKey = useMemo(() => {
+    if (currentConversationId === newConversationId)
+      return ''
+
+    return currentConversationId
+  }, [currentConversationId, newConversationId])
+
+  const { data: appParams } = useSWR(['appParams', isInstalledApp, appId], () => fetchAppParams(isInstalledApp, appId))
+  const { data: appMeta } = useSWR(['appMeta', isInstalledApp, appId], () => fetchAppMeta(isInstalledApp, appId))
+  const { data: appPinnedConversationData } = useSWR(['appConversationData', isInstalledApp, appId, true], () => fetchConversations(isInstalledApp, appId, undefined, true, 100))
+  const { data: appConversationData, isLoading: appConversationDataLoading, mutate: mutateAppConversationData } = useSWR(['appConversationData', isInstalledApp, appId, false], () => fetchConversations(isInstalledApp, appId, undefined, false, 100))
+  const { data: appChatListData, isLoading: appChatListDataLoading } = useSWR(chatShouldReloadKey ? ['appChatList', chatShouldReloadKey, isInstalledApp, appId] : null, () => fetchChatList(chatShouldReloadKey, isInstalledApp, appId))
+
+  const appPrevChatList = useMemo(() => {
+    const data = appChatListData?.data || []
+    const chatList: ChatItem[] = []
+
+    if (currentConversationId && data.length) {
+      data.forEach((item: any) => {
+        chatList.push({
+          id: `question-${item.id}`,
+          content: item.query,
+          isAnswer: false,
+          message_files: item.message_files?.filter((file: any) => file.belongs_to === 'user') || [],
+        })
+        chatList.push({
+          id: item.id,
+          content: item.answer,
+          agent_thoughts: addFileInfos(item.agent_thoughts ? sortAgentSorts(item.agent_thoughts) : item.agent_thoughts, item.message_files),
+          feedback: item.feedback,
+          isAnswer: true,
+          citation: item.retriever_resources,
+          message_files: item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [],
+        })
+      })
+    }
+
+    return chatList
+  }, [appChatListData, currentConversationId])
+
+  const [showNewConversationItemInList, setShowNewConversationItemInList] = useState(false)
+
+  const pinnedConversationList = useMemo(() => {
+    return appPinnedConversationData?.data || []
+  }, [appPinnedConversationData])
+  const { t } = useTranslation()
+  const newConversationInputsRef = useRef<Record<string, any>>({})
+  const [newConversationInputs, setNewConversationInputs] = useState<Record<string, any>>({})
+  const handleNewConversationInputsChange = useCallback((newInputs: Record<string, any>) => {
+    newConversationInputsRef.current = newInputs
+    setNewConversationInputs(newInputs)
+  }, [])
+  const inputsForms = useMemo(() => {
+    return (appParams?.user_input_form || []).filter((item: any) => item.paragraph || item.select || item['text-input'] || item.number).map((item: any) => {
+      if (item.paragraph) {
+        return {
+          ...item.paragraph,
+          type: 'paragraph',
+        }
+      }
+      if (item.number) {
+        return {
+          ...item.number,
+          type: 'number',
+        }
+      }
+      if (item.select) {
+        return {
+          ...item.select,
+          type: 'select',
+        }
+      }
+
+      return {
+        ...item['text-input'],
+        type: 'text-input',
+      }
+    })
+  }, [appParams])
+  useEffect(() => {
+    const conversationInputs: Record<string, any> = {}
+
+    inputsForms.forEach((item: any) => {
+      conversationInputs[item.variable] = item.default || ''
+    })
+    handleNewConversationInputsChange(conversationInputs)
+  }, [handleNewConversationInputsChange, inputsForms])
+
+  const { data: newConversation } = useSWR(newConversationId ? [isInstalledApp, appId, newConversationId] : null, () => generationConversationName(isInstalledApp, appId, newConversationId), { revalidateOnFocus: false })
+  const [originConversationList, setOriginConversationList] = useState<ConversationItem[]>([])
+  useEffect(() => {
+    if (appConversationData?.data && !appConversationDataLoading)
+      setOriginConversationList(appConversationData?.data)
+  }, [appConversationData, appConversationDataLoading])
+  const conversationList = useMemo(() => {
+    const data = originConversationList.slice()
+
+    if (showNewConversationItemInList && data[0]?.id !== '') {
+      data.unshift({
+        id: '',
+        name: t('share.chat.newChatDefaultName'),
+        inputs: {},
+        introduction: '',
+      })
+    }
+    return data
+  }, [originConversationList, showNewConversationItemInList, t])
+
+  useEffect(() => {
+    if (newConversation) {
+      setOriginConversationList(produce((draft) => {
+        const index = draft.findIndex(item => item.id === newConversation.id)
+
+        if (index > -1)
+          draft[index] = newConversation
+        else
+          draft.unshift(newConversation)
+      }))
+    }
+  }, [newConversation])
+
+  const currentConversationItem = useMemo(() => {
+    let conversationItem = conversationList.find(item => item.id === currentConversationId)
+
+    if (!conversationItem && pinnedConversationList.length)
+      conversationItem = pinnedConversationList.find(item => item.id === currentConversationId)
+
+    return conversationItem
+  }, [conversationList, currentConversationId, pinnedConversationList])
+
+  const { notify } = useToastContext()
+  const checkInputsRequired = useCallback((silent?: boolean) => {
+    if (inputsForms.length) {
+      for (let i = 0; i < inputsForms.length; i += 1) {
+        const item = inputsForms[i]
+
+        if (item.required && !newConversationInputsRef.current[item.variable]) {
+          if (!silent) {
+            notify({
+              type: 'error',
+              message: t('appDebug.errorMessage.valueOfVarRequired', { key: item.variable }),
+            })
+          }
+          return
+        }
+      }
+      return true
+    }
+
+    return true
+  }, [inputsForms, notify, t])
+  const handleStartChat = useCallback(() => {
+    if (checkInputsRequired()) {
+      setShowConfigPanelBeforeChat(false)
+      setShowNewConversationItemInList(true)
+    }
+  }, [setShowConfigPanelBeforeChat, setShowNewConversationItemInList, checkInputsRequired])
+  const currentChatInstanceRef = useRef<{ handleStop: () => void }>({ handleStop: () => { } })
+  const handleChangeConversation = useCallback((conversationId: string) => {
+    currentChatInstanceRef.current.handleStop()
+    setNewConversationId('')
+    handleConversationIdInfoChange(conversationId)
+
+    if (conversationId === '' && !checkInputsRequired(true))
+      setShowConfigPanelBeforeChat(true)
+    else
+      setShowConfigPanelBeforeChat(false)
+  }, [handleConversationIdInfoChange, setShowConfigPanelBeforeChat, checkInputsRequired])
+  const handleNewConversation = useCallback(() => {
+    currentChatInstanceRef.current.handleStop()
+    setNewConversationId('')
+
+    if (showNewConversationItemInList) {
+      handleChangeConversation('')
+    }
+    else if (currentConversationId) {
+      handleConversationIdInfoChange('')
+      setShowConfigPanelBeforeChat(true)
+      setShowNewConversationItemInList(true)
+      handleNewConversationInputsChange({})
+    }
+  }, [handleChangeConversation, currentConversationId, handleConversationIdInfoChange, setShowConfigPanelBeforeChat, setShowNewConversationItemInList, showNewConversationItemInList, handleNewConversationInputsChange])
+
+  const handleNewConversationCompleted = useCallback((newConversationId: string) => {
+    setNewConversationId(newConversationId)
+    handleConversationIdInfoChange(newConversationId)
+    setShowNewConversationItemInList(false)
+    mutateAppConversationData()
+  }, [mutateAppConversationData, handleConversationIdInfoChange])
+
+  const handleFeedback = useCallback(async (messageId: string, feedback: Feedback) => {
+    await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating } }, isInstalledApp, appId)
+    notify({ type: 'success', message: t('common.api.success') })
+  }, [isInstalledApp, appId, t, notify])
+
+  return {
+    appInfoError,
+    appInfoLoading,
+    isInstalledApp,
+    appId,
+    currentConversationId,
+    currentConversationItem,
+    handleConversationIdInfoChange,
+    appData,
+    appParams: appParams || {} as ChatConfig,
+    appMeta,
+    appPinnedConversationData,
+    appConversationData,
+    appConversationDataLoading,
+    appChatListData,
+    appChatListDataLoading,
+    appPrevChatList,
+    pinnedConversationList,
+    conversationList,
+    showConfigPanelBeforeChat,
+    setShowConfigPanelBeforeChat,
+    setShowNewConversationItemInList,
+    newConversationInputs,
+    handleNewConversationInputsChange,
+    inputsForms,
+    handleNewConversation,
+    handleStartChat,
+    handleChangeConversation,
+    handleNewConversationCompleted,
+    newConversationId,
+    chatShouldReloadKey,
+    handleFeedback,
+    currentChatInstanceRef,
+  }
+}

+ 181 - 0
web/app/components/base/chat/embedded-chatbot/index.tsx

@@ -0,0 +1,181 @@
+import {
+  useEffect,
+  useState,
+} from 'react'
+import cn from 'classnames'
+import { useAsyncEffect } from 'ahooks'
+import {
+  EmbeddedChatbotContext,
+  useEmbeddedChatbotContext,
+} from './context'
+import { useEmbeddedChatbot } from './hooks'
+import { isDify } from './utils'
+import { checkOrSetAccessToken } from '@/app/components/share/utils'
+import AppUnavailable from '@/app/components/base/app-unavailable'
+import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
+import Loading from '@/app/components/base/loading'
+import LogoHeader from '@/app/components/base/logo/logo-embeded-chat-header'
+import Header from '@/app/components/base/chat/embedded-chatbot/header'
+import ConfigPanel from '@/app/components/base/chat/embedded-chatbot/config-panel'
+import ChatWrapper from '@/app/components/base/chat/embedded-chatbot/chat-wrapper'
+
+const Chatbot = () => {
+  const {
+    isMobile,
+    appInfoError,
+    appInfoLoading,
+    appData,
+    appPrevChatList,
+    showConfigPanelBeforeChat,
+    appChatListDataLoading,
+    handleNewConversation,
+  } = useEmbeddedChatbotContext()
+
+  const chatReady = (!showConfigPanelBeforeChat || !!appPrevChatList.length)
+  const customConfig = appData?.custom_config
+  const site = appData?.site
+
+  const difyIcon = <LogoHeader />
+
+  useEffect(() => {
+    if (site) {
+      if (customConfig)
+        document.title = `${site.title}`
+      else
+        document.title = `${site.title} - Powered by Dify`
+    }
+  }, [site, customConfig])
+
+  if (appInfoLoading) {
+    return (
+      <Loading type='app' />
+    )
+  }
+
+  if (appInfoError) {
+    return (
+      <AppUnavailable />
+    )
+  }
+  return (
+    <div>
+      <Header
+        isMobile={isMobile}
+        title={site?.title || ''}
+        customerIcon={isDify() ? difyIcon : ''}
+        onCreateNewChat={handleNewConversation}
+      />
+      <div className='flex bg-white overflow-hidden'>
+        <div className={cn('h-[100vh] grow flex flex-col overflow-y-auto', isMobile && '!h-[calc(100vh_-_3rem)]')}>
+          {showConfigPanelBeforeChat && !appChatListDataLoading && !appPrevChatList.length && (
+            <div className={cn('flex w-full items-center justify-center h-full tablet:px-4', isMobile && 'px-4')}>
+              <ConfigPanel />
+            </div>
+          )}
+          {appChatListDataLoading && chatReady && (
+            <Loading type='app' />
+          )}
+          {chatReady && !appChatListDataLoading && (
+            <ChatWrapper />
+          )}
+        </div>
+      </div>
+    </div>
+  )
+}
+
+const EmbeddedChatbotWrapper = () => {
+  const media = useBreakpoints()
+  const isMobile = media === MediaType.mobile
+
+  const {
+    appInfoError,
+    appInfoLoading,
+    appData,
+    appParams,
+    appMeta,
+    appChatListDataLoading,
+    currentConversationId,
+    currentConversationItem,
+    appPrevChatList,
+    pinnedConversationList,
+    conversationList,
+    showConfigPanelBeforeChat,
+    newConversationInputs,
+    handleNewConversationInputsChange,
+    inputsForms,
+    handleNewConversation,
+    handleStartChat,
+    handleChangeConversation,
+    handleNewConversationCompleted,
+    chatShouldReloadKey,
+    isInstalledApp,
+    appId,
+    handleFeedback,
+    currentChatInstanceRef,
+  } = useEmbeddedChatbot()
+
+  return <EmbeddedChatbotContext.Provider value={{
+    appInfoError,
+    appInfoLoading,
+    appData,
+    appParams,
+    appMeta,
+    appChatListDataLoading,
+    currentConversationId,
+    currentConversationItem,
+    appPrevChatList,
+    pinnedConversationList,
+    conversationList,
+    showConfigPanelBeforeChat,
+    newConversationInputs,
+    handleNewConversationInputsChange,
+    inputsForms,
+    handleNewConversation,
+    handleStartChat,
+    handleChangeConversation,
+    handleNewConversationCompleted,
+    chatShouldReloadKey,
+    isMobile,
+    isInstalledApp,
+    appId,
+    handleFeedback,
+    currentChatInstanceRef,
+  }}>
+    <Chatbot />
+  </EmbeddedChatbotContext.Provider>
+}
+
+const EmbeddedChatbot = () => {
+  const [initialized, setInitialized] = useState(false)
+  const [appUnavailable, setAppUnavailable] = useState<boolean>(false)
+  const [isUnknownReason, setIsUnknownReason] = useState<boolean>(false)
+
+  useAsyncEffect(async () => {
+    if (!initialized) {
+      try {
+        await checkOrSetAccessToken()
+      }
+      catch (e: any) {
+        if (e.status === 404) {
+          setAppUnavailable(true)
+        }
+        else {
+          setIsUnknownReason(true)
+          setAppUnavailable(true)
+        }
+      }
+      setInitialized(true)
+    }
+  }, [])
+
+  if (!initialized)
+    return null
+
+  if (appUnavailable)
+    return <AppUnavailable isUnknownReason={isUnknownReason} />
+
+  return <EmbeddedChatbotWrapper />
+}
+
+export default EmbeddedChatbot

+ 3 - 0
web/app/components/base/chat/embedded-chatbot/utils.ts

@@ -0,0 +1,3 @@
+export const isDify = () => {
+  return document.referrer.includes('dify.ai')
+}

+ 2 - 2
web/app/components/share/chat/index.tsx

@@ -73,7 +73,7 @@ const Main: FC<IMainProps> = ({
   * app info
   */
   const [appUnavailable, setAppUnavailable] = useState<boolean>(false)
-  const [isUnknwonReason, setIsUnknwonReason] = useState<boolean>(false)
+  const [isUnknownReason, setIsUnknwonReason] = useState<boolean>(false)
   const [appId, setAppId] = useState<string>('')
   const [isPublicVersion, setIsPublicVersion] = useState<boolean>(true)
   const [siteInfo, setSiteInfo] = useState<SiteInfo | null>()
@@ -839,7 +839,7 @@ const Main: FC<IMainProps> = ({
   }, [appId, messageTaskId, isInstalledApp, installedAppInfo?.id])
 
   if (appUnavailable)
-    return <AppUnavailable isUnknwonReason={isUnknwonReason} />
+    return <AppUnavailable isUnknownReason={isUnknownReason} />
 
   if (!appId || !siteInfo || !promptConfig) {
     return <div className='flex h-screen w-full'>

+ 2 - 2
web/app/components/share/chatbot/index.tsx

@@ -60,7 +60,7 @@ const Main: FC<IMainProps> = ({
   * app info
   */
   const [appUnavailable, setAppUnavailable] = useState<boolean>(false)
-  const [isUnknwonReason, setIsUnknwonReason] = useState<boolean>(false)
+  const [isUnknownReason, setIsUnknwonReason] = useState<boolean>(false)
   const [appId, setAppId] = useState<string>('')
   const [isPublicVersion, setIsPublicVersion] = useState<boolean>(true)
   const [siteInfo, setSiteInfo] = useState<SiteInfo | null>()
@@ -715,7 +715,7 @@ const Main: FC<IMainProps> = ({
   )
 
   if (appUnavailable)
-    return <AppUnavailable isUnknwonReason={isUnknwonReason} />
+    return <AppUnavailable isUnknownReason={isUnknownReason} />
 
   if (!appId || !siteInfo || !promptConfig) {
     return <div className='flex h-screen w-full'>