dify
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import Chat from '../chat'
|
||||
import type {
|
||||
ChatConfig,
|
||||
ChatItem,
|
||||
OnSend,
|
||||
} from '../types'
|
||||
import { useChat } from '../chat/hooks'
|
||||
import { getLastAnswer, isValidGeneratedAnswer } from '../utils'
|
||||
import { useEmbeddedChatbotContext } from './context'
|
||||
import { isDify } from './utils'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import InputsForm from '@/app/components/base/chat/embedded-chatbot/inputs-form'
|
||||
import {
|
||||
fetchSuggestedQuestions,
|
||||
getUrl,
|
||||
stopChatMessageResponding,
|
||||
} from '@/service/share'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import LogoAvatar from '@/app/components/base/logo/logo-embedded-chat-avatar'
|
||||
import AnswerIcon from '@/app/components/base/answer-icon'
|
||||
import SuggestedQuestions from '@/app/components/base/chat/chat/answer/suggested-questions'
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { FileEntity } from '../../file-uploader/types'
|
||||
import Avatar from '../../avatar'
|
||||
|
||||
const ChatWrapper = () => {
|
||||
const {
|
||||
appData,
|
||||
appParams,
|
||||
appPrevChatList,
|
||||
currentConversationId,
|
||||
currentConversationItem,
|
||||
currentConversationInputs,
|
||||
inputsForms,
|
||||
newConversationInputs,
|
||||
newConversationInputsRef,
|
||||
handleNewConversationCompleted,
|
||||
isMobile,
|
||||
isInstalledApp,
|
||||
appId,
|
||||
appMeta,
|
||||
handleFeedback,
|
||||
currentChatInstanceRef,
|
||||
themeBuilder,
|
||||
clearChatList,
|
||||
setClearChatList,
|
||||
setIsResponding,
|
||||
allInputsHidden,
|
||||
initUserVariables,
|
||||
} = useEmbeddedChatbotContext()
|
||||
const appConfig = useMemo(() => {
|
||||
const config = appParams || {}
|
||||
|
||||
return {
|
||||
...config,
|
||||
file_upload: {
|
||||
...(config as any).file_upload,
|
||||
fileUploadConfig: (config as any).system_parameters,
|
||||
},
|
||||
supportFeedback: true,
|
||||
opening_statement: currentConversationItem?.introduction || (config as any).opening_statement,
|
||||
} as ChatConfig
|
||||
}, [appParams, currentConversationItem?.introduction])
|
||||
const {
|
||||
chatList,
|
||||
setTargetMessageId,
|
||||
handleSend,
|
||||
handleStop,
|
||||
isResponding: respondingState,
|
||||
suggestedQuestions,
|
||||
} = useChat(
|
||||
appConfig,
|
||||
{
|
||||
inputs: (currentConversationId ? currentConversationInputs : newConversationInputs) as any,
|
||||
inputsForm: inputsForms,
|
||||
},
|
||||
appPrevChatList,
|
||||
taskId => stopChatMessageResponding('', taskId, isInstalledApp, appId),
|
||||
clearChatList,
|
||||
setClearChatList,
|
||||
)
|
||||
const inputsFormValue = currentConversationId ? currentConversationInputs : newConversationInputsRef?.current
|
||||
const inputDisabled = useMemo(() => {
|
||||
if (allInputsHidden)
|
||||
return false
|
||||
|
||||
let hasEmptyInput = ''
|
||||
let fileIsUploading = false
|
||||
const requiredVars = inputsForms.filter(({ required, type }) => required && type !== InputVarType.checkbox) // boolean can be not checked
|
||||
if (requiredVars.length) {
|
||||
requiredVars.forEach(({ variable, label, type }) => {
|
||||
if (hasEmptyInput)
|
||||
return
|
||||
|
||||
if (fileIsUploading)
|
||||
return
|
||||
|
||||
if (!inputsFormValue?.[variable])
|
||||
hasEmptyInput = label as string
|
||||
|
||||
if ((type === InputVarType.singleFile || type === InputVarType.multiFiles) && inputsFormValue?.[variable]) {
|
||||
const files = inputsFormValue[variable]
|
||||
if (Array.isArray(files))
|
||||
fileIsUploading = files.find(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId)
|
||||
else
|
||||
fileIsUploading = files.transferMethod === TransferMethod.local_file && !files.uploadedId
|
||||
}
|
||||
})
|
||||
}
|
||||
if (hasEmptyInput)
|
||||
return true
|
||||
|
||||
if (fileIsUploading)
|
||||
return true
|
||||
return false
|
||||
}, [inputsFormValue, inputsForms, allInputsHidden])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentChatInstanceRef.current)
|
||||
currentChatInstanceRef.current.handleStop = handleStop
|
||||
}, [currentChatInstanceRef, handleStop])
|
||||
useEffect(() => {
|
||||
setIsResponding(respondingState)
|
||||
}, [respondingState, setIsResponding])
|
||||
|
||||
const doSend: OnSend = useCallback((message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => {
|
||||
const data: any = {
|
||||
query: message,
|
||||
files,
|
||||
inputs: currentConversationId ? currentConversationInputs : newConversationInputs,
|
||||
conversation_id: currentConversationId,
|
||||
parent_message_id: (isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || null,
|
||||
}
|
||||
|
||||
handleSend(
|
||||
getUrl('chat-messages', isInstalledApp, appId || ''),
|
||||
data,
|
||||
{
|
||||
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, isInstalledApp, appId),
|
||||
onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
|
||||
isPublicAPI: !isInstalledApp,
|
||||
},
|
||||
)
|
||||
}, [currentConversationId, currentConversationInputs, newConversationInputs, chatList, handleSend, isInstalledApp, appId, handleNewConversationCompleted])
|
||||
|
||||
const doRegenerate = useCallback((chatItem: ChatItem, editedQuestion?: { message: string, files?: FileEntity[] }) => {
|
||||
const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)!
|
||||
const parentAnswer = chatList.find(item => item.id === question.parentMessageId)
|
||||
doSend(editedQuestion ? editedQuestion.message : question.content,
|
||||
editedQuestion ? editedQuestion.files : question.message_files,
|
||||
true,
|
||||
isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null,
|
||||
)
|
||||
}, [chatList, doSend])
|
||||
|
||||
const messageList = useMemo(() => {
|
||||
if (currentConversationId || chatList.length > 1)
|
||||
return chatList
|
||||
// Without messages we are in the welcome screen, so hide the opening statement from chatlist
|
||||
return chatList.filter(item => !item.isOpeningStatement)
|
||||
}, [chatList, currentConversationId])
|
||||
|
||||
const [collapsed, setCollapsed] = useState(!!currentConversationId)
|
||||
|
||||
const chatNode = useMemo(() => {
|
||||
if (allInputsHidden || !inputsForms.length)
|
||||
return null
|
||||
if (isMobile) {
|
||||
if (!currentConversationId)
|
||||
return <InputsForm collapsed={collapsed} setCollapsed={setCollapsed} />
|
||||
return <div className='mb-4'></div>
|
||||
}
|
||||
else {
|
||||
return <InputsForm collapsed={collapsed} setCollapsed={setCollapsed} />
|
||||
}
|
||||
}, [inputsForms.length, isMobile, currentConversationId, collapsed, allInputsHidden])
|
||||
|
||||
const welcome = useMemo(() => {
|
||||
const welcomeMessage = chatList.find(item => item.isOpeningStatement)
|
||||
if (respondingState)
|
||||
return null
|
||||
if (currentConversationId)
|
||||
return null
|
||||
if (!welcomeMessage)
|
||||
return null
|
||||
if (!collapsed && inputsForms.length > 0 && !allInputsHidden)
|
||||
return null
|
||||
if (welcomeMessage.suggestedQuestions && welcomeMessage.suggestedQuestions?.length > 0) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center px-4 py-12', isMobile ? 'min-h-[30vh] py-0' : 'h-[50vh]')}>
|
||||
<div className='flex max-w-[720px] grow gap-4'>
|
||||
<AppIcon
|
||||
size='xl'
|
||||
iconType={appData?.site.icon_type}
|
||||
icon={appData?.site.icon}
|
||||
background={appData?.site.icon_background}
|
||||
imageUrl={appData?.site.icon_url}
|
||||
/>
|
||||
<div className='body-lg-regular grow rounded-2xl bg-chat-bubble-bg px-4 py-3 text-text-primary'>
|
||||
<Markdown content={welcomeMessage.content} />
|
||||
<SuggestedQuestions item={welcomeMessage} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className={cn('flex h-[50vh] flex-col items-center justify-center gap-3 py-12', isMobile ? 'min-h-[30vh] py-0' : 'h-[50vh]')}>
|
||||
<AppIcon
|
||||
size='xl'
|
||||
iconType={appData?.site.icon_type}
|
||||
icon={appData?.site.icon}
|
||||
background={appData?.site.icon_background}
|
||||
imageUrl={appData?.site.icon_url}
|
||||
/>
|
||||
<div className='max-w-[768px] px-4'>
|
||||
<Markdown className='!body-2xl-regular !text-text-tertiary' content={welcomeMessage.content} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}, [appData?.site.icon, appData?.site.icon_background, appData?.site.icon_type, appData?.site.icon_url, chatList, collapsed, currentConversationId, inputsForms.length, respondingState, allInputsHidden])
|
||||
|
||||
const answerIcon = isDify()
|
||||
? <LogoAvatar className='relative shrink-0' />
|
||||
: (appData?.site && appData.site.use_icon_as_answer_icon)
|
||||
? <AnswerIcon
|
||||
iconType={appData.site.icon_type}
|
||||
icon={appData.site.icon}
|
||||
background={appData.site.icon_background}
|
||||
imageUrl={appData.site.icon_url}
|
||||
/>
|
||||
: null
|
||||
|
||||
return (
|
||||
<Chat
|
||||
appData={appData || undefined}
|
||||
config={appConfig}
|
||||
chatList={messageList}
|
||||
isResponding={respondingState}
|
||||
chatContainerInnerClassName={cn('mx-auto w-full max-w-full px-4', messageList.length && 'pt-4')}
|
||||
chatFooterClassName={cn('pb-4', !isMobile && 'rounded-b-2xl')}
|
||||
chatFooterInnerClassName={cn('mx-auto w-full max-w-full px-4', isMobile && 'px-2')}
|
||||
onSend={doSend}
|
||||
inputs={currentConversationId ? currentConversationInputs as any : newConversationInputs}
|
||||
inputsForm={inputsForms}
|
||||
onRegenerate={doRegenerate}
|
||||
onStopResponding={handleStop}
|
||||
chatNode={
|
||||
<>
|
||||
{chatNode}
|
||||
{welcome}
|
||||
</>
|
||||
}
|
||||
allToolIcons={appMeta?.tool_icons || {}}
|
||||
onFeedback={handleFeedback}
|
||||
suggestedQuestions={suggestedQuestions}
|
||||
answerIcon={answerIcon}
|
||||
hideProcessDetail
|
||||
themeBuilder={themeBuilder}
|
||||
switchSibling={siblingMessageId => setTargetMessageId(siblingMessageId)}
|
||||
inputDisabled={inputDisabled}
|
||||
isMobile={isMobile}
|
||||
questionIcon={
|
||||
initUserVariables?.avatar_url
|
||||
? <Avatar
|
||||
avatar={initUserVariables.avatar_url}
|
||||
name={initUserVariables.name || 'user'}
|
||||
size={40}
|
||||
/> : undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatWrapper
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client'
|
||||
|
||||
import type { RefObject } from 'react'
|
||||
import { createContext, useContext } from 'use-context-selector'
|
||||
import type {
|
||||
ChatConfig,
|
||||
ChatItem,
|
||||
Feedback,
|
||||
} from '../types'
|
||||
import type { ThemeBuilder } from './theme/theme-context'
|
||||
import type {
|
||||
AppConversationData,
|
||||
AppData,
|
||||
AppMeta,
|
||||
ConversationItem,
|
||||
} from '@/models/share'
|
||||
import { noop } from 'lodash-es'
|
||||
|
||||
export type EmbeddedChatbotContextValue = {
|
||||
appMeta: AppMeta | null
|
||||
appData: AppData | null
|
||||
appParams: ChatConfig | null
|
||||
appChatListDataLoading?: boolean
|
||||
currentConversationId: string
|
||||
currentConversationItem?: ConversationItem
|
||||
appPrevChatList: ChatItem[]
|
||||
pinnedConversationList: AppConversationData['data']
|
||||
conversationList: AppConversationData['data']
|
||||
newConversationInputs: Record<string, any>
|
||||
newConversationInputsRef: RefObject<Record<string, any>>
|
||||
handleNewConversationInputsChange: (v: Record<string, any>) => void
|
||||
inputsForms: any[]
|
||||
handleNewConversation: () => void
|
||||
handleStartChat: (callback?: any) => void
|
||||
handleChangeConversation: (conversationId: string) => void
|
||||
handleNewConversationCompleted: (newConversationId: string) => void
|
||||
chatShouldReloadKey: string
|
||||
isMobile: boolean
|
||||
isInstalledApp: boolean
|
||||
allowResetChat: boolean
|
||||
appId?: string
|
||||
handleFeedback: (messageId: string, feedback: Feedback) => void
|
||||
currentChatInstanceRef: RefObject<{ handleStop: () => void }>
|
||||
themeBuilder?: ThemeBuilder
|
||||
clearChatList?: boolean
|
||||
setClearChatList: (state: boolean) => void
|
||||
isResponding?: boolean
|
||||
setIsResponding: (state: boolean) => void,
|
||||
currentConversationInputs: Record<string, any> | null,
|
||||
setCurrentConversationInputs: (v: Record<string, any>) => void,
|
||||
allInputsHidden: boolean
|
||||
initUserVariables?: {
|
||||
name?: string
|
||||
avatar_url?: string
|
||||
}
|
||||
}
|
||||
|
||||
export const EmbeddedChatbotContext = createContext<EmbeddedChatbotContextValue>({
|
||||
appData: null,
|
||||
appMeta: null,
|
||||
appParams: null,
|
||||
appChatListDataLoading: false,
|
||||
currentConversationId: '',
|
||||
appPrevChatList: [],
|
||||
pinnedConversationList: [],
|
||||
conversationList: [],
|
||||
newConversationInputs: {},
|
||||
newConversationInputsRef: { current: {} },
|
||||
handleNewConversationInputsChange: noop,
|
||||
inputsForms: [],
|
||||
handleNewConversation: noop,
|
||||
handleStartChat: noop,
|
||||
handleChangeConversation: noop,
|
||||
handleNewConversationCompleted: noop,
|
||||
chatShouldReloadKey: '',
|
||||
isMobile: false,
|
||||
isInstalledApp: false,
|
||||
allowResetChat: true,
|
||||
handleFeedback: noop,
|
||||
currentChatInstanceRef: { current: { handleStop: noop } },
|
||||
clearChatList: false,
|
||||
setClearChatList: noop,
|
||||
isResponding: false,
|
||||
setIsResponding: noop,
|
||||
currentConversationInputs: {},
|
||||
setCurrentConversationInputs: noop,
|
||||
allInputsHidden: false,
|
||||
initUserVariables: {},
|
||||
})
|
||||
export const useEmbeddedChatbotContext = () => useContext(EmbeddedChatbotContext)
|
||||
@@ -0,0 +1,183 @@
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { RiCollapseDiagonal2Line, RiExpandDiagonal2Line, RiResetLeftLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { Theme } from '../theme/theme-context'
|
||||
import { CssTransform } from '../theme/utils'
|
||||
import {
|
||||
useEmbeddedChatbotContext,
|
||||
} from '../context'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import ViewFormDropdown from '@/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown'
|
||||
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
||||
import cn from '@/utils/classnames'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
|
||||
export type IHeaderProps = {
|
||||
isMobile?: boolean
|
||||
allowResetChat?: boolean
|
||||
customerIcon?: React.ReactNode
|
||||
title: string
|
||||
theme?: Theme
|
||||
onCreateNewChat?: () => void
|
||||
}
|
||||
const Header: FC<IHeaderProps> = ({
|
||||
isMobile,
|
||||
allowResetChat,
|
||||
customerIcon,
|
||||
title,
|
||||
theme,
|
||||
onCreateNewChat,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
appData,
|
||||
currentConversationId,
|
||||
inputsForms,
|
||||
allInputsHidden,
|
||||
} = useEmbeddedChatbotContext()
|
||||
|
||||
const isClient = typeof window !== 'undefined'
|
||||
const isIframe = isClient ? window.self !== window.top : false
|
||||
const [parentOrigin, setParentOrigin] = useState('')
|
||||
const [showToggleExpandButton, setShowToggleExpandButton] = useState(false)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
|
||||
const handleMessageReceived = useCallback((event: MessageEvent) => {
|
||||
let currentParentOrigin = parentOrigin
|
||||
if (!currentParentOrigin && event.data.type === 'dify-chatbot-config') {
|
||||
currentParentOrigin = event.origin
|
||||
setParentOrigin(event.origin)
|
||||
}
|
||||
if (event.origin !== currentParentOrigin)
|
||||
return
|
||||
if (event.data.type === 'dify-chatbot-config')
|
||||
setShowToggleExpandButton(event.data.payload.isToggledByButton && !event.data.payload.isDraggable)
|
||||
}, [parentOrigin])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isIframe) return
|
||||
|
||||
const listener = (event: MessageEvent) => handleMessageReceived(event)
|
||||
window.addEventListener('message', listener)
|
||||
|
||||
window.parent.postMessage({ type: 'dify-chatbot-iframe-ready' }, '*')
|
||||
|
||||
return () => window.removeEventListener('message', listener)
|
||||
}, [isIframe, handleMessageReceived])
|
||||
|
||||
const handleToggleExpand = useCallback(() => {
|
||||
if (!isIframe || !showToggleExpandButton) return
|
||||
setExpanded(!expanded)
|
||||
window.parent.postMessage({
|
||||
type: 'dify-chatbot-expand-change',
|
||||
}, parentOrigin)
|
||||
}, [isIframe, parentOrigin, showToggleExpandButton, expanded])
|
||||
|
||||
if (!isMobile) {
|
||||
return (
|
||||
<div className='flex h-14 shrink-0 items-center justify-end p-3'>
|
||||
<div className='flex items-center gap-1'>
|
||||
{/* powered by */}
|
||||
<div className='shrink-0'>
|
||||
{!appData?.custom_config?.remove_webapp_brand && (
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 px-2',
|
||||
)}>
|
||||
<div className='system-2xs-medium-uppercase text-text-tertiary'>{t('share.chat.poweredBy')}</div>
|
||||
{
|
||||
systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
|
||||
? <img src={systemFeatures.branding.workspace_logo} alt='logo' className='block h-5 w-auto' />
|
||||
: appData?.custom_config?.replace_webapp_logo
|
||||
? <img src={`${appData?.custom_config?.replace_webapp_logo}`} alt='logo' className='block h-5 w-auto' />
|
||||
: <DifyLogo size='small' />
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{currentConversationId && (
|
||||
<Divider type='vertical' className='h-3.5' />
|
||||
)}
|
||||
{
|
||||
showToggleExpandButton && (
|
||||
<Tooltip
|
||||
popupContent={expanded ? t('share.chat.collapse') : t('share.chat.expand')}
|
||||
>
|
||||
<ActionButton size='l' onClick={handleToggleExpand}>
|
||||
{
|
||||
expanded
|
||||
? <RiCollapseDiagonal2Line className='h-[18px] w-[18px]' />
|
||||
: <RiExpandDiagonal2Line className='h-[18px] w-[18px]' />
|
||||
}
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
{currentConversationId && allowResetChat && (
|
||||
<Tooltip
|
||||
popupContent={t('share.chat.resetChat')}
|
||||
>
|
||||
<ActionButton size='l' onClick={onCreateNewChat}>
|
||||
<RiResetLeftLine className='h-[18px] w-[18px]' />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{currentConversationId && inputsForms.length > 0 && !allInputsHidden && (
|
||||
<ViewFormDropdown />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex h-14 shrink-0 items-center justify-between rounded-t-2xl px-3')}
|
||||
style={CssTransform(theme?.headerBorderBottomStyle ?? '')}
|
||||
>
|
||||
<div className="flex grow items-center space-x-3">
|
||||
{customerIcon}
|
||||
<div
|
||||
className='system-md-semibold truncate'
|
||||
style={CssTransform(theme?.colorFontOnHeaderStyle ?? '')}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center gap-1'>
|
||||
{
|
||||
showToggleExpandButton && (
|
||||
<Tooltip
|
||||
popupContent={expanded ? t('share.chat.collapse') : t('share.chat.expand')}
|
||||
>
|
||||
<ActionButton size='l' onClick={handleToggleExpand}>
|
||||
{
|
||||
expanded
|
||||
? <RiCollapseDiagonal2Line className={cn('h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
|
||||
: <RiExpandDiagonal2Line className={cn('h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
|
||||
}
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
{currentConversationId && allowResetChat && (
|
||||
<Tooltip
|
||||
popupContent={t('share.chat.resetChat')}
|
||||
>
|
||||
<ActionButton size='l' onClick={onCreateNewChat}>
|
||||
<RiResetLeftLine className={cn('h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{currentConversationId && inputsForms.length > 0 && !allInputsHidden && (
|
||||
<ViewFormDropdown iconColor={theme?.colorPathOnHeader} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Header)
|
||||
430
dify/web/app/components/base/chat/embedded-chatbot/hooks.tsx
Normal file
430
dify/web/app/components/base/chat/embedded-chatbot/hooks.tsx
Normal file
@@ -0,0 +1,430 @@
|
||||
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 { buildChatItemTree, getProcessedInputsFromUrlParams, getProcessedSystemVariablesFromUrlParams, getProcessedUserVariablesFromUrlParams } from '../utils'
|
||||
import { getProcessedFilesFromResponse } from '../../file-uploader/utils'
|
||||
import {
|
||||
fetchChatList,
|
||||
fetchConversations,
|
||||
generationConversationName,
|
||||
updateFeedback,
|
||||
} from '@/service/share'
|
||||
import type {
|
||||
// AppData,
|
||||
ConversationItem,
|
||||
} from '@/models/share'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import { changeLanguage } from '@/i18n-config/i18next-config'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils'
|
||||
import { noop } from 'lodash-es'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
|
||||
function getFormattedChatList(messages: any[]) {
|
||||
const newChatList: ChatItem[] = []
|
||||
messages.forEach((item) => {
|
||||
const questionFiles = item.message_files?.filter((file: any) => file.belongs_to === 'user') || []
|
||||
newChatList.push({
|
||||
id: `question-${item.id}`,
|
||||
content: item.query,
|
||||
isAnswer: false,
|
||||
message_files: getProcessedFilesFromResponse(questionFiles.map((item: any) => ({ ...item, related_id: item.id }))),
|
||||
parentMessageId: item.parent_message_id || undefined,
|
||||
})
|
||||
const answerFiles = item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || []
|
||||
newChatList.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: getProcessedFilesFromResponse(answerFiles.map((item: any) => ({ ...item, related_id: item.id }))),
|
||||
parentMessageId: `question-${item.id}`,
|
||||
})
|
||||
})
|
||||
return newChatList
|
||||
}
|
||||
|
||||
export const useEmbeddedChatbot = () => {
|
||||
const isInstalledApp = false
|
||||
const appInfo = useWebAppStore(s => s.appInfo)
|
||||
const appMeta = useWebAppStore(s => s.appMeta)
|
||||
const appParams = useWebAppStore(s => s.appParams)
|
||||
const embeddedConversationId = useWebAppStore(s => s.embeddedConversationId)
|
||||
const embeddedUserId = useWebAppStore(s => s.embeddedUserId)
|
||||
const appId = useMemo(() => appInfo?.app_id, [appInfo])
|
||||
|
||||
const [userId, setUserId] = useState<string>()
|
||||
const [conversationId, setConversationId] = useState<string>()
|
||||
|
||||
useEffect(() => {
|
||||
setUserId(embeddedUserId || undefined)
|
||||
}, [embeddedUserId])
|
||||
|
||||
useEffect(() => {
|
||||
setConversationId(embeddedConversationId || undefined)
|
||||
}, [embeddedConversationId])
|
||||
|
||||
useEffect(() => {
|
||||
const setLanguageFromParams = async () => {
|
||||
// Check URL parameters for language override
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const localeParam = urlParams.get('locale')
|
||||
|
||||
// Check for encoded system variables
|
||||
const systemVariables = await getProcessedSystemVariablesFromUrlParams()
|
||||
const localeFromSysVar = systemVariables.locale
|
||||
|
||||
if (localeParam) {
|
||||
// If locale parameter exists in URL, use it instead of default
|
||||
await changeLanguage(localeParam)
|
||||
}
|
||||
else if (localeFromSysVar) {
|
||||
// If locale is set as a system variable, use that
|
||||
await changeLanguage(localeFromSysVar)
|
||||
}
|
||||
else if (appInfo?.site.default_language) {
|
||||
// Otherwise use the default from app config
|
||||
await changeLanguage(appInfo.site.default_language)
|
||||
}
|
||||
}
|
||||
|
||||
setLanguageFromParams()
|
||||
}, [appInfo])
|
||||
|
||||
const [conversationIdInfo, setConversationIdInfo] = useLocalStorageState<Record<string, Record<string, string>>>(CONVERSATION_ID_INFO, {
|
||||
defaultValue: {},
|
||||
})
|
||||
const allowResetChat = !conversationId
|
||||
const currentConversationId = useMemo(() => conversationIdInfo?.[appId || '']?.[userId || 'DEFAULT'] || conversationId || '',
|
||||
[appId, conversationIdInfo, userId, conversationId])
|
||||
const handleConversationIdInfoChange = useCallback((changeConversationId: string) => {
|
||||
if (appId) {
|
||||
let prevValue = conversationIdInfo?.[appId || '']
|
||||
if (typeof prevValue === 'string')
|
||||
prevValue = {}
|
||||
setConversationIdInfo({
|
||||
...conversationIdInfo,
|
||||
[appId || '']: {
|
||||
...prevValue,
|
||||
[userId || 'DEFAULT']: changeConversationId,
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [appId, conversationIdInfo, setConversationIdInfo, userId])
|
||||
|
||||
const [newConversationId, setNewConversationId] = useState('')
|
||||
const chatShouldReloadKey = useMemo(() => {
|
||||
if (currentConversationId === newConversationId)
|
||||
return ''
|
||||
|
||||
return currentConversationId
|
||||
}, [currentConversationId, newConversationId])
|
||||
|
||||
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 [clearChatList, setClearChatList] = useState(false)
|
||||
const [isResponding, setIsResponding] = useState(false)
|
||||
const appPrevChatList = useMemo(
|
||||
() => (currentConversationId && appChatListData?.data.length)
|
||||
? buildChatItemTree(getFormattedChatList(appChatListData.data))
|
||||
: [],
|
||||
[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 [initInputs, setInitInputs] = useState<Record<string, any>>({})
|
||||
const [initUserVariables, setInitUserVariables] = 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.external_data_tool).map((item: any) => {
|
||||
if (item.paragraph) {
|
||||
let value = initInputs[item.paragraph.variable]
|
||||
if (value && item.paragraph.max_length && value.length > item.paragraph.max_length)
|
||||
value = value.slice(0, item.paragraph.max_length)
|
||||
|
||||
return {
|
||||
...item.paragraph,
|
||||
default: value || item.default || item.paragraph.default,
|
||||
type: 'paragraph',
|
||||
}
|
||||
}
|
||||
if (item.number) {
|
||||
const convertedNumber = Number(initInputs[item.number.variable])
|
||||
return {
|
||||
...item.number,
|
||||
default: convertedNumber || item.default || item.number.default,
|
||||
type: 'number',
|
||||
}
|
||||
}
|
||||
|
||||
if (item.checkbox) {
|
||||
const preset = initInputs[item.checkbox.variable] === true
|
||||
return {
|
||||
...item.checkbox,
|
||||
default: preset || item.default || item.checkbox.default,
|
||||
type: 'checkbox',
|
||||
}
|
||||
}
|
||||
|
||||
if (item.select) {
|
||||
const isInputInOptions = item.select.options.includes(initInputs[item.select.variable])
|
||||
return {
|
||||
...item.select,
|
||||
default: (isInputInOptions ? initInputs[item.select.variable] : undefined) || item.select.default,
|
||||
type: 'select',
|
||||
}
|
||||
}
|
||||
|
||||
if (item['file-list']) {
|
||||
return {
|
||||
...item['file-list'],
|
||||
type: 'file-list',
|
||||
}
|
||||
}
|
||||
|
||||
if (item.file) {
|
||||
return {
|
||||
...item.file,
|
||||
type: 'file',
|
||||
}
|
||||
}
|
||||
|
||||
if (item.json_object) {
|
||||
return {
|
||||
...item.json_object,
|
||||
type: 'json_object',
|
||||
}
|
||||
}
|
||||
|
||||
let value = initInputs[item['text-input'].variable]
|
||||
if (value && item['text-input'].max_length && value.length > item['text-input'].max_length)
|
||||
value = value.slice(0, item['text-input'].max_length)
|
||||
|
||||
return {
|
||||
...item['text-input'],
|
||||
default: value || item.default || item['text-input'].default,
|
||||
type: 'text-input',
|
||||
}
|
||||
})
|
||||
}, [initInputs, appParams])
|
||||
|
||||
const allInputsHidden = useMemo(() => {
|
||||
return inputsForms.length > 0 && inputsForms.every(item => item.hide === true)
|
||||
}, [inputsForms])
|
||||
|
||||
useEffect(() => {
|
||||
// init inputs from url params
|
||||
(async () => {
|
||||
const inputs = await getProcessedInputsFromUrlParams()
|
||||
const userVariables = await getProcessedUserVariablesFromUrlParams()
|
||||
setInitInputs(inputs)
|
||||
setInitUserVariables(userVariables)
|
||||
})()
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
const conversationInputs: Record<string, any> = {}
|
||||
|
||||
inputsForms.forEach((item: any) => {
|
||||
conversationInputs[item.variable] = item.default || null
|
||||
})
|
||||
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 currentConversationLatestInputs = useMemo(() => {
|
||||
if (!currentConversationId || !appChatListData?.data.length)
|
||||
return newConversationInputsRef.current || {}
|
||||
return appChatListData.data.slice().pop().inputs || {}
|
||||
}, [appChatListData, currentConversationId])
|
||||
const [currentConversationInputs, setCurrentConversationInputs] = useState<Record<string, any>>(currentConversationLatestInputs || {})
|
||||
useEffect(() => {
|
||||
if (currentConversationItem)
|
||||
setCurrentConversationInputs(currentConversationLatestInputs || {})
|
||||
}, [currentConversationItem, currentConversationLatestInputs])
|
||||
|
||||
const { notify } = useToastContext()
|
||||
const checkInputsRequired = useCallback((silent?: boolean) => {
|
||||
if (allInputsHidden)
|
||||
return true
|
||||
|
||||
let hasEmptyInput = ''
|
||||
let fileIsUploading = false
|
||||
const requiredVars = inputsForms.filter(({ required, type }) => required && type !== InputVarType.checkbox)
|
||||
if (requiredVars.length) {
|
||||
requiredVars.forEach(({ variable, label, type }) => {
|
||||
if (hasEmptyInput)
|
||||
return
|
||||
|
||||
if (fileIsUploading)
|
||||
return
|
||||
|
||||
if (!newConversationInputsRef.current[variable] && !silent)
|
||||
hasEmptyInput = label as string
|
||||
|
||||
if ((type === InputVarType.singleFile || type === InputVarType.multiFiles) && newConversationInputsRef.current[variable] && !silent) {
|
||||
const files = newConversationInputsRef.current[variable]
|
||||
if (Array.isArray(files))
|
||||
fileIsUploading = files.find(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId)
|
||||
else
|
||||
fileIsUploading = files.transferMethod === TransferMethod.local_file && !files.uploadedId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (hasEmptyInput) {
|
||||
notify({ type: 'error', message: t('appDebug.errorMessage.valueOfVarRequired', { key: hasEmptyInput }) })
|
||||
return false
|
||||
}
|
||||
|
||||
if (fileIsUploading) {
|
||||
notify({ type: 'info', message: t('appDebug.errorMessage.waitForFileUpload') })
|
||||
return
|
||||
}
|
||||
|
||||
return true
|
||||
}, [inputsForms, notify, t, allInputsHidden])
|
||||
const handleStartChat = useCallback((callback?: any) => {
|
||||
if (checkInputsRequired()) {
|
||||
setShowNewConversationItemInList(true)
|
||||
callback?.()
|
||||
}
|
||||
}, [setShowNewConversationItemInList, checkInputsRequired])
|
||||
const currentChatInstanceRef = useRef<{ handleStop: () => void }>({ handleStop: noop })
|
||||
const handleChangeConversation = useCallback((conversationId: string) => {
|
||||
currentChatInstanceRef.current.handleStop()
|
||||
setNewConversationId('')
|
||||
handleConversationIdInfoChange(conversationId)
|
||||
if (conversationId)
|
||||
setClearChatList(false)
|
||||
}, [handleConversationIdInfoChange, setClearChatList])
|
||||
const handleNewConversation = useCallback(async () => {
|
||||
currentChatInstanceRef.current.handleStop()
|
||||
setShowNewConversationItemInList(true)
|
||||
handleChangeConversation('')
|
||||
handleNewConversationInputsChange(await getProcessedInputsFromUrlParams())
|
||||
setClearChatList(true)
|
||||
}, [handleChangeConversation, setShowNewConversationItemInList, handleNewConversationInputsChange, setClearChatList])
|
||||
|
||||
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, content: feedback.content } }, isInstalledApp, appId)
|
||||
notify({ type: 'success', message: t('common.api.success') })
|
||||
}, [isInstalledApp, appId, t, notify])
|
||||
|
||||
return {
|
||||
isInstalledApp,
|
||||
allowResetChat,
|
||||
appId,
|
||||
currentConversationId,
|
||||
currentConversationItem,
|
||||
handleConversationIdInfoChange,
|
||||
appData: appInfo,
|
||||
appParams: appParams || {} as ChatConfig,
|
||||
appMeta,
|
||||
appPinnedConversationData,
|
||||
appConversationData,
|
||||
appConversationDataLoading,
|
||||
appChatListData,
|
||||
appChatListDataLoading,
|
||||
appPrevChatList,
|
||||
pinnedConversationList,
|
||||
conversationList,
|
||||
setShowNewConversationItemInList,
|
||||
newConversationInputs,
|
||||
newConversationInputsRef,
|
||||
handleNewConversationInputsChange,
|
||||
inputsForms,
|
||||
handleNewConversation,
|
||||
handleStartChat,
|
||||
handleChangeConversation,
|
||||
handleNewConversationCompleted,
|
||||
newConversationId,
|
||||
chatShouldReloadKey,
|
||||
handleFeedback,
|
||||
currentChatInstanceRef,
|
||||
clearChatList,
|
||||
setClearChatList,
|
||||
isResponding,
|
||||
setIsResponding,
|
||||
currentConversationInputs,
|
||||
setCurrentConversationInputs,
|
||||
allInputsHidden,
|
||||
initUserVariables,
|
||||
}
|
||||
}
|
||||
179
dify/web/app/components/base/chat/embedded-chatbot/index.tsx
Normal file
179
dify/web/app/components/base/chat/embedded-chatbot/index.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
'use client'
|
||||
import {
|
||||
useEffect,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
EmbeddedChatbotContext,
|
||||
useEmbeddedChatbotContext,
|
||||
} from './context'
|
||||
import { useEmbeddedChatbot } from './hooks'
|
||||
import { isDify } from './utils'
|
||||
import { useThemeContext } from './theme/theme-context'
|
||||
import { CssTransform } from './theme/utils'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import LogoHeader from '@/app/components/base/logo/logo-embedded-chat-header'
|
||||
import Header from '@/app/components/base/chat/embedded-chatbot/header'
|
||||
import ChatWrapper from '@/app/components/base/chat/embedded-chatbot/chat-wrapper'
|
||||
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
||||
import cn from '@/utils/classnames'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
|
||||
const Chatbot = () => {
|
||||
const {
|
||||
isMobile,
|
||||
allowResetChat,
|
||||
appData,
|
||||
appChatListDataLoading,
|
||||
chatShouldReloadKey,
|
||||
handleNewConversation,
|
||||
themeBuilder,
|
||||
} = useEmbeddedChatbotContext()
|
||||
const { t } = useTranslation()
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
|
||||
const customConfig = appData?.custom_config
|
||||
const site = appData?.site
|
||||
|
||||
const difyIcon = <LogoHeader />
|
||||
|
||||
useEffect(() => {
|
||||
themeBuilder?.buildTheme(site?.chat_color_theme, site?.chat_color_theme_inverted)
|
||||
}, [site, customConfig, themeBuilder])
|
||||
|
||||
useDocumentTitle(site?.title || 'Chat')
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col rounded-2xl',
|
||||
isMobile ? 'h-[calc(100vh_-_60px)] shadow-xs' : 'h-[100vh] bg-chatbot-bg',
|
||||
)}
|
||||
style={isMobile ? Object.assign({}, CssTransform(themeBuilder?.theme?.backgroundHeaderColorStyle ?? '')) : {}}
|
||||
>
|
||||
<Header
|
||||
isMobile={isMobile}
|
||||
allowResetChat={allowResetChat}
|
||||
title={site?.title || ''}
|
||||
customerIcon={isDify() ? difyIcon : ''}
|
||||
theme={themeBuilder?.theme}
|
||||
onCreateNewChat={handleNewConversation}
|
||||
/>
|
||||
<div className={cn('flex grow flex-col overflow-y-auto', isMobile && 'm-[0.5px] !h-[calc(100vh_-_3rem)] rounded-2xl bg-chatbot-bg')}>
|
||||
{appChatListDataLoading && (
|
||||
<Loading type='app' />
|
||||
)}
|
||||
{!appChatListDataLoading && (
|
||||
<ChatWrapper key={chatShouldReloadKey} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* powered by */}
|
||||
{isMobile && (
|
||||
<div className='flex h-[60px] shrink-0 items-center pl-2'>
|
||||
{!appData?.custom_config?.remove_webapp_brand && (
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 px-2',
|
||||
)}>
|
||||
<div className='system-2xs-medium-uppercase text-text-tertiary'>{t('share.chat.poweredBy')}</div>
|
||||
{
|
||||
systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
|
||||
? <img src={systemFeatures.branding.workspace_logo} alt='logo' className='block h-5 w-auto' />
|
||||
: appData?.custom_config?.replace_webapp_logo
|
||||
? <img src={`${appData?.custom_config?.replace_webapp_logo}`} alt='logo' className='block h-5 w-auto' />
|
||||
: <DifyLogo size='small' />
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const EmbeddedChatbotWrapper = () => {
|
||||
const media = useBreakpoints()
|
||||
const isMobile = media === MediaType.mobile
|
||||
const themeBuilder = useThemeContext()
|
||||
|
||||
const {
|
||||
appData,
|
||||
appParams,
|
||||
appMeta,
|
||||
appChatListDataLoading,
|
||||
currentConversationId,
|
||||
currentConversationItem,
|
||||
appPrevChatList,
|
||||
pinnedConversationList,
|
||||
conversationList,
|
||||
newConversationInputs,
|
||||
newConversationInputsRef,
|
||||
handleNewConversationInputsChange,
|
||||
inputsForms,
|
||||
handleNewConversation,
|
||||
handleStartChat,
|
||||
handleChangeConversation,
|
||||
handleNewConversationCompleted,
|
||||
chatShouldReloadKey,
|
||||
isInstalledApp,
|
||||
allowResetChat,
|
||||
appId,
|
||||
handleFeedback,
|
||||
currentChatInstanceRef,
|
||||
clearChatList,
|
||||
setClearChatList,
|
||||
isResponding,
|
||||
setIsResponding,
|
||||
currentConversationInputs,
|
||||
setCurrentConversationInputs,
|
||||
allInputsHidden,
|
||||
initUserVariables,
|
||||
} = useEmbeddedChatbot()
|
||||
|
||||
return <EmbeddedChatbotContext.Provider value={{
|
||||
appData,
|
||||
appParams,
|
||||
appMeta,
|
||||
appChatListDataLoading,
|
||||
currentConversationId,
|
||||
currentConversationItem,
|
||||
appPrevChatList,
|
||||
pinnedConversationList,
|
||||
conversationList,
|
||||
newConversationInputs,
|
||||
newConversationInputsRef,
|
||||
handleNewConversationInputsChange,
|
||||
inputsForms,
|
||||
handleNewConversation,
|
||||
handleStartChat,
|
||||
handleChangeConversation,
|
||||
handleNewConversationCompleted,
|
||||
chatShouldReloadKey,
|
||||
isMobile,
|
||||
isInstalledApp,
|
||||
allowResetChat,
|
||||
appId,
|
||||
handleFeedback,
|
||||
currentChatInstanceRef,
|
||||
themeBuilder,
|
||||
clearChatList,
|
||||
setClearChatList,
|
||||
isResponding,
|
||||
setIsResponding,
|
||||
currentConversationInputs,
|
||||
setCurrentConversationInputs,
|
||||
allInputsHidden,
|
||||
initUserVariables,
|
||||
}}>
|
||||
<Chatbot />
|
||||
</EmbeddedChatbotContext.Provider>
|
||||
}
|
||||
|
||||
const EmbeddedChatbot = () => {
|
||||
return <EmbeddedChatbotWrapper />
|
||||
}
|
||||
|
||||
export default EmbeddedChatbot
|
||||
@@ -0,0 +1,142 @@
|
||||
import React, { memo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEmbeddedChatbotContext } from '../context'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { PortalSelect } from '@/app/components/base/select'
|
||||
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
|
||||
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
|
||||
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
|
||||
|
||||
type Props = {
|
||||
showTip?: boolean
|
||||
}
|
||||
|
||||
const InputsFormContent = ({ showTip }: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
appParams,
|
||||
inputsForms,
|
||||
currentConversationId,
|
||||
currentConversationInputs,
|
||||
setCurrentConversationInputs,
|
||||
newConversationInputs,
|
||||
newConversationInputsRef,
|
||||
handleNewConversationInputsChange,
|
||||
} = useEmbeddedChatbotContext()
|
||||
const inputsFormValue = currentConversationId ? currentConversationInputs : newConversationInputs
|
||||
|
||||
const handleFormChange = useCallback((variable: string, value: any) => {
|
||||
setCurrentConversationInputs({
|
||||
...currentConversationInputs,
|
||||
[variable]: value,
|
||||
})
|
||||
handleNewConversationInputsChange({
|
||||
...newConversationInputsRef.current,
|
||||
[variable]: value,
|
||||
})
|
||||
}, [newConversationInputsRef, handleNewConversationInputsChange, currentConversationInputs, setCurrentConversationInputs])
|
||||
|
||||
const visibleInputsForms = inputsForms.filter(form => form.hide !== true)
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
{visibleInputsForms.map(form => (
|
||||
<div key={form.variable} className='space-y-1'>
|
||||
{form.type !== InputVarType.checkbox && (
|
||||
<div className='flex h-6 items-center gap-1'>
|
||||
<div className='system-md-semibold text-text-secondary'>{form.label}</div>
|
||||
{!form.required && (
|
||||
<div className='system-xs-regular text-text-tertiary'>{t('workflow.panel.optional')}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{form.type === InputVarType.textInput && (
|
||||
<Input
|
||||
value={inputsFormValue?.[form.variable] || ''}
|
||||
onChange={e => handleFormChange(form.variable, e.target.value)}
|
||||
placeholder={form.label}
|
||||
/>
|
||||
)}
|
||||
{form.type === InputVarType.number && (
|
||||
<Input
|
||||
type='number'
|
||||
value={inputsFormValue?.[form.variable] || ''}
|
||||
onChange={e => handleFormChange(form.variable, e.target.value)}
|
||||
placeholder={form.label}
|
||||
/>
|
||||
)}
|
||||
{form.type === InputVarType.paragraph && (
|
||||
<Textarea
|
||||
value={inputsFormValue?.[form.variable] || ''}
|
||||
onChange={e => handleFormChange(form.variable, e.target.value)}
|
||||
placeholder={form.label}
|
||||
/>
|
||||
)}
|
||||
{form.type === InputVarType.checkbox && (
|
||||
<BoolInput
|
||||
name={form.label}
|
||||
value={inputsFormValue?.[form.variable]}
|
||||
required={form.required}
|
||||
onChange={value => handleFormChange(form.variable, value)}
|
||||
/>
|
||||
)}
|
||||
{form.type === InputVarType.select && (
|
||||
<PortalSelect
|
||||
popupClassName='w-[200px]'
|
||||
value={inputsFormValue?.[form.variable] ?? form.default ?? ''}
|
||||
items={form.options.map((option: string) => ({ value: option, name: option }))}
|
||||
onSelect={item => handleFormChange(form.variable, item.value as string)}
|
||||
placeholder={form.label}
|
||||
/>
|
||||
)}
|
||||
{form.type === InputVarType.singleFile && (
|
||||
<FileUploaderInAttachmentWrapper
|
||||
value={inputsFormValue?.[form.variable] ? [inputsFormValue?.[form.variable]] : []}
|
||||
onChange={files => handleFormChange(form.variable, files[0])}
|
||||
fileConfig={{
|
||||
allowed_file_types: form.allowed_file_types,
|
||||
allowed_file_extensions: form.allowed_file_extensions,
|
||||
allowed_file_upload_methods: form.allowed_file_upload_methods,
|
||||
number_limits: 1,
|
||||
fileUploadConfig: (appParams as any).system_parameters,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{form.type === InputVarType.multiFiles && (
|
||||
<FileUploaderInAttachmentWrapper
|
||||
value={inputsFormValue?.[form.variable] || []}
|
||||
onChange={files => handleFormChange(form.variable, files)}
|
||||
fileConfig={{
|
||||
allowed_file_types: form.allowed_file_types,
|
||||
allowed_file_extensions: form.allowed_file_extensions,
|
||||
allowed_file_upload_methods: form.allowed_file_upload_methods,
|
||||
number_limits: form.max_length,
|
||||
fileUploadConfig: (appParams as any).system_parameters,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{form.type === InputVarType.jsonObject && (
|
||||
<CodeEditor
|
||||
language={CodeLanguage.json}
|
||||
value={inputsFormValue?.[form.variable] || ''}
|
||||
onChange={v => handleFormChange(form.variable, v)}
|
||||
noWrapper
|
||||
className='bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1'
|
||||
placeholder={
|
||||
<div className='whitespace-pre'>{form.json_schema}</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{showTip && (
|
||||
<div className='system-xs-regular text-text-tertiary'>{t('share.chat.chatFormTip')}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(InputsFormContent)
|
||||
@@ -0,0 +1,84 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Message3Fill } from '@/app/components/base/icons/src/public/other'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import InputsFormContent from '@/app/components/base/chat/embedded-chatbot/inputs-form/content'
|
||||
import { useEmbeddedChatbotContext } from '../context'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
collapsed: boolean
|
||||
setCollapsed: (collapsed: boolean) => void
|
||||
}
|
||||
|
||||
const InputsFormNode = ({
|
||||
collapsed,
|
||||
setCollapsed,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
isMobile,
|
||||
currentConversationId,
|
||||
themeBuilder,
|
||||
handleStartChat,
|
||||
allInputsHidden,
|
||||
inputsForms,
|
||||
} = useEmbeddedChatbotContext()
|
||||
|
||||
if (allInputsHidden || inputsForms.length === 0)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className={cn('mb-6 flex flex-col items-center px-4 pt-6', isMobile && 'mb-4 pt-4')}>
|
||||
<div className={cn(
|
||||
'w-full max-w-[672px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-md',
|
||||
collapsed && 'border border-components-card-border bg-components-card-bg shadow-none',
|
||||
)}>
|
||||
<div className={cn(
|
||||
'flex items-center gap-3 rounded-t-2xl px-6 py-4',
|
||||
!collapsed && 'border-b border-divider-subtle',
|
||||
isMobile && 'px-4 py-3',
|
||||
)}>
|
||||
<Message3Fill className='h-6 w-6 shrink-0' />
|
||||
<div className='system-xl-semibold grow text-text-secondary'>{t('share.chat.chatSettingsTitle')}</div>
|
||||
{collapsed && (
|
||||
<Button className='uppercase text-text-tertiary' size='small' variant='ghost' onClick={() => setCollapsed(false)}>{t('common.operation.edit')}</Button>
|
||||
)}
|
||||
{!collapsed && currentConversationId && (
|
||||
<Button className='uppercase text-text-tertiary' size='small' variant='ghost' onClick={() => setCollapsed(true)}>{t('common.operation.close')}</Button>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className={cn('p-6', isMobile && 'p-4')}>
|
||||
<InputsFormContent />
|
||||
</div>
|
||||
)}
|
||||
{!collapsed && !currentConversationId && (
|
||||
<div className={cn('p-6', isMobile && 'p-4')}>
|
||||
<Button
|
||||
variant='primary'
|
||||
className='w-full'
|
||||
onClick={() => handleStartChat(() => setCollapsed(true))}
|
||||
style={
|
||||
themeBuilder?.theme
|
||||
? {
|
||||
backgroundColor: themeBuilder?.theme.primaryColor,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>{t('share.chat.startChat')}</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{collapsed && (
|
||||
<div className='flex w-full max-w-[720px] items-center py-4'>
|
||||
<Divider bgStyle='gradient' className='h-px basis-1/2 rotate-180' />
|
||||
<Divider bgStyle='gradient' className='h-px basis-1/2' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InputsFormNode
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiChatSettingsLine,
|
||||
} from '@remixicon/react'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
|
||||
import { Message3Fill } from '@/app/components/base/icons/src/public/other'
|
||||
import InputsFormContent from '@/app/components/base/chat/embedded-chatbot/inputs-form/content'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
iconColor?: string
|
||||
}
|
||||
const ViewFormDropdown = ({ iconColor }: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='bottom-end'
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: 4,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => setOpen(v => !v)}
|
||||
>
|
||||
<ActionButton size='l' state={open ? ActionButtonState.Hover : ActionButtonState.Default}>
|
||||
<RiChatSettingsLine className={cn('h-[18px] w-[18px]', iconColor)} />
|
||||
</ActionButton>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-50">
|
||||
<div className='w-[400px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg backdrop-blur-sm'>
|
||||
<div className='flex items-center gap-3 rounded-t-2xl border-b border-divider-subtle px-6 py-4'>
|
||||
<Message3Fill className='h-6 w-6 shrink-0' />
|
||||
<div className='system-xl-semibold grow text-text-secondary'>{t('share.chat.chatSettingsTitle')}</div>
|
||||
</div>
|
||||
<div className='p-6'>
|
||||
<InputsFormContent />
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
export default ViewFormDropdown
|
||||
@@ -0,0 +1,73 @@
|
||||
import { createContext, useContext } from 'use-context-selector'
|
||||
import { hexToRGBA } from './utils'
|
||||
|
||||
export class Theme {
|
||||
public chatColorTheme: string | null
|
||||
public chatColorThemeInverted: boolean
|
||||
|
||||
public primaryColor = '#1C64F2'
|
||||
public backgroundHeaderColorStyle = 'backgroundImage: linear-gradient(to right, #2563eb, #0ea5e9)'
|
||||
public headerBorderBottomStyle = ''
|
||||
public colorFontOnHeaderStyle = 'color: white'
|
||||
public colorPathOnHeader = 'text-text-primary-on-surface'
|
||||
public backgroundButtonDefaultColorStyle = 'backgroundColor: #1C64F2'
|
||||
public roundedBackgroundColorStyle = 'backgroundColor: rgb(245 248 255)'
|
||||
public chatBubbleColorStyle = ''
|
||||
|
||||
constructor(chatColorTheme: string | null = null, chatColorThemeInverted = false) {
|
||||
this.chatColorTheme = chatColorTheme
|
||||
this.chatColorThemeInverted = chatColorThemeInverted
|
||||
this.configCustomColor()
|
||||
this.configInvertedColor()
|
||||
}
|
||||
|
||||
private configCustomColor() {
|
||||
if (this.chatColorTheme !== null && this.chatColorTheme !== '') {
|
||||
this.primaryColor = this.chatColorTheme ?? '#1C64F2'
|
||||
this.backgroundHeaderColorStyle = `backgroundColor: ${this.primaryColor}`
|
||||
this.backgroundButtonDefaultColorStyle = `backgroundColor: ${this.primaryColor}; color: ${this.colorFontOnHeaderStyle};`
|
||||
this.roundedBackgroundColorStyle = `backgroundColor: ${hexToRGBA(this.primaryColor, 0.05)}`
|
||||
this.chatBubbleColorStyle = `backgroundColor: ${hexToRGBA(this.primaryColor, 0.15)}`
|
||||
}
|
||||
}
|
||||
|
||||
private configInvertedColor() {
|
||||
if (this.chatColorThemeInverted) {
|
||||
this.backgroundHeaderColorStyle = 'backgroundColor: #ffffff'
|
||||
this.colorFontOnHeaderStyle = `color: ${this.primaryColor}`
|
||||
this.headerBorderBottomStyle = 'borderBottom: 1px solid #ccc'
|
||||
this.colorPathOnHeader = this.primaryColor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ThemeBuilder {
|
||||
private _theme?: Theme
|
||||
private buildChecker = false
|
||||
|
||||
public get theme() {
|
||||
if (this._theme === undefined) {
|
||||
this._theme = new Theme()
|
||||
return this._theme
|
||||
}
|
||||
else {
|
||||
return this._theme
|
||||
}
|
||||
}
|
||||
|
||||
public buildTheme(chatColorTheme: string | null = null, chatColorThemeInverted = false) {
|
||||
if (!this.buildChecker) {
|
||||
this._theme = new Theme(chatColorTheme, chatColorThemeInverted)
|
||||
this.buildChecker = true
|
||||
}
|
||||
else {
|
||||
if (this.theme?.chatColorTheme !== chatColorTheme || this.theme?.chatColorThemeInverted !== chatColorThemeInverted) {
|
||||
this._theme = new Theme(chatColorTheme, chatColorThemeInverted)
|
||||
this.buildChecker = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeBuilder>(new ThemeBuilder())
|
||||
export const useThemeContext = () => useContext(ThemeContext)
|
||||
@@ -0,0 +1,29 @@
|
||||
export function hexToRGBA(hex: string, opacity: number): string {
|
||||
hex = hex.replace('#', '')
|
||||
|
||||
const r = Number.parseInt(hex.slice(0, 2), 16)
|
||||
const g = Number.parseInt(hex.slice(2, 4), 16)
|
||||
const b = Number.parseInt(hex.slice(4, 6), 16)
|
||||
|
||||
// Returning an RGB color object
|
||||
return `rgba(${r},${g},${b},${opacity.toString()})`
|
||||
}
|
||||
|
||||
/**
|
||||
* Since strings cannot be directly assigned to the 'style' attribute in JSX,
|
||||
* this method transforms the string into an object representation of the styles.
|
||||
*/
|
||||
export function CssTransform(cssString: string): object {
|
||||
if (cssString.length === 0)
|
||||
return {}
|
||||
|
||||
const style: object = {}
|
||||
const propertyValuePairs = cssString.split(';')
|
||||
for (const pair of propertyValuePairs) {
|
||||
if (pair.trim().length > 0) {
|
||||
const [property, value] = pair.split(':')
|
||||
Object.assign(style, { [property.trim()]: value.trim() })
|
||||
}
|
||||
}
|
||||
return style
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const isDify = () => {
|
||||
return document.referrer.includes('dify.ai')
|
||||
}
|
||||
Reference in New Issue
Block a user