| @@ -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 /> | |||
| } | |||
| </> | |||
| )} | |||
| @@ -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> | |||
| ) | |||
| @@ -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> | |||
| ) | |||
| } | |||
| @@ -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} | |||
| @@ -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 | |||
| @@ -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) | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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) | |||
| @@ -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) | |||
| @@ -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, | |||
| } | |||
| } | |||
| @@ -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 | |||
| @@ -0,0 +1,3 @@ | |||
| export const isDify = () => { | |||
| return document.referrer.includes('dify.ai') | |||
| } | |||
| @@ -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'> | |||
| @@ -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'> | |||