Feat: web app dark mode (#14732)

This commit is contained in:
KVOJJJin
2025-03-03 14:44:51 +08:00
committed by GitHub
parent e53052ab7a
commit d0d0bf570e
98 changed files with 3006 additions and 2496 deletions

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import Chat from '../chat'
import type {
ChatConfig,
@@ -9,14 +9,17 @@ import type {
import { useChat } from '../chat/hooks'
import { getLastAnswer, isValidGeneratedAnswer } from '../utils'
import { useChatWithHistoryContext } from './context'
import Header from './header'
import ConfigPanel from './config-panel'
import { InputVarType } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import InputsForm from '@/app/components/base/chat/chat-with-history/inputs-form'
import {
fetchSuggestedQuestions,
getUrl,
stopChatMessageResponding,
} from '@/service/share'
import AppIcon from '@/app/components/base/app-icon'
import AnswerIcon from '@/app/components/base/answer-icon'
import cn from '@/utils/classnames'
const ChatWrapper = () => {
const {
@@ -26,6 +29,7 @@ const ChatWrapper = () => {
currentConversationItem,
inputsForms,
newConversationInputs,
newConversationInputsRef,
handleNewConversationCompleted,
isMobile,
isInstalledApp,
@@ -65,6 +69,38 @@ const ChatWrapper = () => {
appPrevChatTree,
taskId => stopChatMessageResponding('', taskId, isInstalledApp, appId),
)
const inputsFormValue = currentConversationId ? currentConversationItem?.inputs : newConversationInputsRef?.current
const inputDisabled = useMemo(() => {
let hasEmptyInput = ''
let fileIsUploading = false
const requiredVars = inputsForms.filter(({ required }) => required)
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])
useEffect(() => {
if (currentChatInstanceRef.current)
@@ -107,42 +143,48 @@ const ChatWrapper = () => {
doSend(question.content, question.message_files, true, isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null)
}, [chatList, doSend])
const chatNode = useMemo(() => {
if (inputsForms.length) {
return (
<>
<Header
isMobile={isMobile}
title={currentConversationItem?.name || ''}
/>
{
!currentConversationId && (
<div className={`mx-auto w-full max-w-[720px] ${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>
)
}
</>
)
}
const messageList = useMemo(() => {
if (currentConversationId)
return chatList
return chatList.filter(item => !item.isOpeningStatement)
}, [chatList, currentConversationId])
const [collapsed, setCollapsed] = useState(!!currentConversationId)
const chatNode = useMemo(() => {
if (!inputsForms.length)
return null
if (isMobile) {
if (!currentConversationId)
return <InputsForm collapsed={collapsed} setCollapsed={setCollapsed} />
return null
}
else {
return <InputsForm collapsed={collapsed} setCollapsed={setCollapsed} />
}
}, [inputsForms.length, isMobile, currentConversationId, collapsed])
const welcome = useMemo(() => {
const welcomeMessage = chatList.find(item => item.isOpeningStatement)
if (currentConversationId)
return null
if (!welcomeMessage)
return null
if (!collapsed && inputsForms.length > 0)
return null
return (
<Header
isMobile={isMobile}
title={currentConversationItem?.name || ''}
/>
<div className={cn('h-[50vh] py-12 flex flex-col items-center justify-center gap-3')}>
<AppIcon
size='xl'
iconType={appData?.site.icon_type}
icon={appData?.site.icon}
background={appData?.site.icon_background}
imageUrl={appData?.site.icon_url}
/>
<div className='text-text-tertiary body-2xl-regular'>{welcomeMessage.content}</div>
</div>
)
}, [
currentConversationId,
inputsForms,
currentConversationItem,
isMobile,
])
}, [appData?.site.icon, appData?.site.icon_background, appData?.site.icon_type, appData?.site.icon_url, chatList, collapsed, currentConversationId, inputsForms.length])
const answerIcon = (appData?.site && appData.site.use_icon_as_answer_icon)
? <AnswerIcon
@@ -160,7 +202,7 @@ const ChatWrapper = () => {
<Chat
appData={appData}
config={appConfig}
chatList={chatList}
chatList={messageList}
isResponding={isResponding}
chatContainerInnerClassName={`mx-auto pt-6 w-full max-w-[720px] ${isMobile && 'px-4'}`}
chatFooterClassName='pb-4'
@@ -170,7 +212,12 @@ const ChatWrapper = () => {
inputsForm={inputsForms}
onRegenerate={doRegenerate}
onStopResponding={handleStop}
chatNode={chatNode}
chatNode={
<>
{chatNode}
{welcome}
</>
}
allToolIcons={appMeta?.tool_icons || {}}
onFeedback={handleFeedback}
suggestedQuestions={suggestedQuestions}
@@ -178,6 +225,8 @@ const ChatWrapper = () => {
hideProcessDetail
themeBuilder={themeBuilder}
switchSibling={siblingMessageId => setTargetMessageId(siblingMessageId)}
inputDisabled={inputDisabled}
isMobile={isMobile}
/>
</div>
)

View File

@@ -1,47 +0,0 @@
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { memo } from 'react'
import Textarea from '@/app/components/base/textarea'
interface 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='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)

View File

@@ -1,117 +0,0 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useChatWithHistoryContext } from '../context'
import Input from './form-input'
import { PortalSelect } from '@/app/components/base/select'
import { InputVarType } from '@/app/components/workflow/types'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
const Form = () => {
const { t } = useTranslation()
const {
appParams,
inputsForms,
newConversationInputs,
newConversationInputsRef,
handleNewConversationInputsChange,
isMobile,
} = useChatWithHistoryContext()
const handleFormChange = useCallback((variable: string, value: any) => {
handleNewConversationInputsChange({
...newConversationInputsRef.current,
[variable]: value,
})
}, [newConversationInputsRef, 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')})` : ''}`}
/>
)
}
if (form.type === InputVarType.singleFile) {
return (
<FileUploaderInAttachmentWrapper
value={newConversationInputs[variable] ? [newConversationInputs[variable]] : []}
onChange={files => handleFormChange(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,
}}
/>
)
}
if (form.type === InputVarType.multiFiles) {
return (
<FileUploaderInAttachmentWrapper
value={newConversationInputs[variable]}
onChange={files => handleFormChange(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,
}}
/>
)
}
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

View File

@@ -1,172 +0,0 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useChatWithHistoryContext } 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 LogoSite from '@/app/components/base/logo/logo-site'
const ConfigPanel = () => {
const { t } = useTranslation()
const {
appData,
inputsForms,
handleStartChat,
showConfigPanelBeforeChat,
isMobile,
} = useChatWithHistoryContext()
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={`
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
iconType={appData?.site.icon_type}
icon={appData?.site.icon}
background='transparent'
imageUrl={appData?.site.icon_url}
size='small'
className="mr-2"
/>
{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
variant='secondary-accent'
size='small'
className='shrink-0'
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={`pl-[136px] flex items-center ${isMobile && '!pl-0'}`}>
<Button
variant='primary'
className='mr-2'
onClick={() => {
setCollapsed(true)
handleStartChat()
}}
>
{t('common.operation.save')}
</Button>
<Button
onClick={() => setCollapsed(true)}
>
{t('common.operation.cancel')}
</Button>
</div>
</div>
)
}
{
showConfigPanelBeforeChat && (
<div className='p-6 rounded-b-xl'>
<Form />
<Button
className={`${inputsForms.length && !isMobile && 'ml-[136px]'}`}
variant='primary'
size='large'
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={`flex items-center ${isMobile && 'w-full justify-end'}`}>{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={`flex items-center justify-end ${isMobile && 'w-full'}`}>
<div className='flex items-center pr-3 space-x-3'>
<span className='uppercase'>{t('share.chat.poweredBy')}</span>
{
customConfig?.replace_webapp_logo
? <img src={customConfig?.replace_webapp_logo} alt='logo' className='block w-auto h-5' />
: <LogoSite className='!h-5' />
}
</div>
</div>
)
}
</div>
)
}
</div>
)
}
export default ConfigPanel

View File

@@ -28,13 +28,12 @@ export type ChatWithHistoryContextValue = {
appPrevChatTree: ChatItemInTree[]
pinnedConversationList: AppConversationData['data']
conversationList: AppConversationData['data']
showConfigPanelBeforeChat: boolean
newConversationInputs: Record<string, any>
newConversationInputsRef: RefObject<Record<string, any>>
handleNewConversationInputsChange: (v: Record<string, any>) => void
inputsForms: any[]
handleNewConversation: () => void
handleStartChat: () => void
handleStartChat: (callback?: any) => void
handleChangeConversation: (conversationId: string) => void
handlePinConversation: (conversationId: string) => void
handleUnpinConversation: (conversationId: string) => void
@@ -49,6 +48,8 @@ export type ChatWithHistoryContextValue = {
handleFeedback: (messageId: string, feedback: Feedback) => void
currentChatInstanceRef: RefObject<{ handleStop: () => void }>
themeBuilder?: ThemeBuilder
sidebarCollapseState?: boolean
handleSidebarCollapse: (state: boolean) => void
}
export const ChatWithHistoryContext = createContext<ChatWithHistoryContextValue>({
@@ -56,7 +57,6 @@ export const ChatWithHistoryContext = createContext<ChatWithHistoryContextValue>
appPrevChatTree: [],
pinnedConversationList: [],
conversationList: [],
showConfigPanelBeforeChat: false,
newConversationInputs: {},
newConversationInputsRef: { current: {} },
handleNewConversationInputsChange: () => {},
@@ -75,5 +75,7 @@ export const ChatWithHistoryContext = createContext<ChatWithHistoryContextValue>
isInstalledApp: false,
handleFeedback: () => {},
currentChatInstanceRef: { current: { handleStop: () => {} } },
sidebarCollapseState: false,
handleSidebarCollapse: () => {},
})
export const useChatWithHistoryContext = () => useContext(ChatWithHistoryContext)

View File

@@ -1,60 +1,148 @@
import { useState } from 'react'
import { useChatWithHistoryContext } from './context'
import Sidebar from './sidebar'
import AppIcon from '@/app/components/base/app-icon'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
Edit05,
Menu01,
} from '@/app/components/base/icons/src/vender/line/general'
RiMenuLine,
} from '@remixicon/react'
import { useChatWithHistoryContext } from './context'
import Operation from './header/operation'
import Sidebar from './sidebar'
import MobileOperationDropdown from './header/mobile-operation-dropdown'
import AppIcon from '@/app/components/base/app-icon'
import ActionButton from '@/app/components/base/action-button'
import { Message3Fill } from '@/app/components/base/icons/src/public/other'
import InputsFormContent from '@/app/components/base/chat/chat-with-history/inputs-form/content'
import Confirm from '@/app/components/base/confirm'
import RenameModal from '@/app/components/base/chat/chat-with-history/sidebar/rename-modal'
import type { ConversationItem } from '@/models/share'
const HeaderInMobile = () => {
const {
appData,
currentConversationId,
currentConversationItem,
pinnedConversationList,
handleNewConversation,
handlePinConversation,
handleUnpinConversation,
handleDeleteConversation,
handleRenameConversation,
conversationRenaming,
} = useChatWithHistoryContext()
const { t } = useTranslation()
const isPin = pinnedConversationList.some(item => item.id === currentConversationId)
const [showConfirm, setShowConfirm] = useState<ConversationItem | null>(null)
const [showRename, setShowRename] = useState<ConversationItem | null>(null)
const handleOperate = useCallback((type: string) => {
if (type === 'pin')
handlePinConversation(currentConversationId)
if (type === 'unpin')
handleUnpinConversation(currentConversationId)
if (type === 'delete')
setShowConfirm(currentConversationItem as any)
if (type === 'rename')
setShowRename(currentConversationItem as any)
}, [currentConversationId, currentConversationItem, handlePinConversation, handleUnpinConversation])
const handleCancelConfirm = useCallback(() => {
setShowConfirm(null)
}, [])
const handleDelete = useCallback(() => {
if (showConfirm)
handleDeleteConversation(showConfirm.id, { onSuccess: handleCancelConfirm })
}, [showConfirm, handleDeleteConversation, handleCancelConfirm])
const handleCancelRename = useCallback(() => {
setShowRename(null)
}, [])
const handleRename = useCallback((newName: string) => {
if (showRename)
handleRenameConversation(showRename.id, newName, { onSuccess: handleCancelRename })
}, [showRename, handleRenameConversation, handleCancelRename])
const [showSidebar, setShowSidebar] = useState(false)
const [showChatSettings, setShowChatSettings] = useState(false)
return (
<>
<div className='shrink-0 flex items-center px-3 h-[44px] border-b-[0.5px] border-b-gray-200'>
<div
className='shrink-0 flex items-center justify-center w-8 h-8 rounded-lg'
onClick={() => setShowSidebar(true)}
>
<Menu01 className='w-4 h-4 text-gray-700' />
<div className='shrink-0 flex items-center px-2 py-3 gap-1 bg-mask-top2bottom-gray-50-to-transparent'>
<ActionButton size='l' className='shrink-0' onClick={() => setShowSidebar(true)}>
<RiMenuLine className='w-[18px] h-[18px]' />
</ActionButton>
<div className='grow flex justify-center items-center'>
{!currentConversationId && (
<>
<AppIcon
className='mr-2'
size='tiny'
icon={appData?.site.icon}
iconType={appData?.site.icon_type}
imageUrl={appData?.site.icon_url}
background={appData?.site.icon_background}
/>
<div className='text-text-secondary system-md-semibold truncate'>
{appData?.site.title}
</div>
</>
)}
{currentConversationId && (
<Operation
title={currentConversationItem?.name || ''}
isPinned={!!isPin}
togglePin={() => handleOperate(isPin ? 'unpin' : 'pin')}
isShowDelete
isShowRenameConversation
onRenameConversation={() => handleOperate('rename')}
onDelete={() => handleOperate('delete')}
/>
)}
</div>
<div className='grow flex justify-center items-center px-3'>
<AppIcon
className='mr-2'
size='tiny'
icon={appData?.site.icon}
iconType={appData?.site.icon_type}
imageUrl={appData?.site.icon_url}
background={appData?.site.icon_background}
/>
<div className='py-1 text-base font-semibold text-gray-800 truncate'>
{appData?.site.title}
<MobileOperationDropdown
handleResetChat={handleNewConversation}
handleViewChatSettings={() => setShowChatSettings(true)}
/>
</div>
{showSidebar && (
<div className='fixed inset-0 z-50 flex p-1 bg-background-overlay'
onClick={() => setShowSidebar(false)}
>
<div className='flex h-full w-[calc(100vw_-_40px)] bg-components-panel-bg backdrop-blur-sm rounded-xl shadow-lg' onClick={e => e.stopPropagation()}>
<Sidebar />
</div>
</div>
<div
className='shrink-0 flex items-center justify-center w-8 h-8 rounded-lg'
onClick={handleNewConversation}
)}
{showChatSettings && (
<div className='fixed inset-0 z-50 flex justify-end p-1 bg-background-overlay'
onClick={() => setShowChatSettings(false)}
>
<Edit05 className='w-4 h-4 text-gray-700' />
</div>
</div>
{
showSidebar && (
<div className='fixed inset-0 z-50'
style={{ backgroundColor: 'rgba(35, 56, 118, 0.2)' }}
onClick={() => setShowSidebar(false)}
>
<div className='inline-block h-full bg-white' onClick={e => e.stopPropagation()}>
<Sidebar />
<div className='flex flex-col h-full w-[calc(100vw_-_40px)] bg-components-panel-bg backdrop-blur-sm rounded-xl shadow-lg' onClick={e => e.stopPropagation()}>
<div className='flex items-center gap-3 px-4 py-3 rounded-t-2xl border-b border-divider-subtle'>
<Message3Fill className='shrink-0 w-6 h-6' />
<div className='grow text-text-secondary system-xl-semibold'>{t('share.chat.chatSettingsTitle')}</div>
</div>
<div className='p-4'>
<InputsFormContent showTip />
</div>
</div>
)
}
</div>
)}
{!!showConfirm && (
<Confirm
title={t('share.chat.deleteConversation.title')}
content={t('share.chat.deleteConversation.content') || ''}
isShow
onCancel={handleCancelConfirm}
onConfirm={handleDelete}
/>
)}
{showRename && (
<RenameModal
isShow
onClose={handleCancelRename}
saveLoading={conversationRenaming}
name={showRename?.name || ''}
onSave={handleRename}
/>
)}
</>
)
}

View File

@@ -1,25 +0,0 @@
import type { FC } from 'react'
import { memo } from 'react'
type HeaderProps = {
title: string
isMobile: boolean
}
const Header: FC<HeaderProps> = ({
title,
isMobile,
}) => {
return (
<div
className={`
sticky top-0 flex items-center px-8 h-16 bg-white/80 text-base font-medium
text-gray-900 border-b-[0.5px] border-b-gray-100 backdrop-blur-md z-10
${isMobile && '!h-12'}
`}
>
{title}
</div>
)
}
export default memo(Header)

View File

@@ -0,0 +1,151 @@
import { useCallback, useState } from 'react'
import {
RiEditBoxLine,
RiLayoutRight2Line,
RiResetLeftLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import {
useChatWithHistoryContext,
} from '../context'
import Operation from './operation'
import ActionButton from '@/app/components/base/action-button'
import AppIcon from '@/app/components/base/app-icon'
import Tooltip from '@/app/components/base/tooltip'
import ViewFormDropdown from '@/app/components/base/chat/chat-with-history/inputs-form/view-form-dropdown'
import Confirm from '@/app/components/base/confirm'
import RenameModal from '@/app/components/base/chat/chat-with-history/sidebar/rename-modal'
import type { ConversationItem } from '@/models/share'
import cn from '@/utils/classnames'
const Header = () => {
const {
appData,
currentConversationId,
currentConversationItem,
inputsForms,
pinnedConversationList,
handlePinConversation,
handleUnpinConversation,
conversationRenaming,
handleRenameConversation,
handleDeleteConversation,
handleNewConversation,
sidebarCollapseState,
handleSidebarCollapse,
} = useChatWithHistoryContext()
const { t } = useTranslation()
const isSidebarCollapsed = sidebarCollapseState
const isPin = pinnedConversationList.some(item => item.id === currentConversationId)
const [showConfirm, setShowConfirm] = useState<ConversationItem | null>(null)
const [showRename, setShowRename] = useState<ConversationItem | null>(null)
const handleOperate = useCallback((type: string) => {
if (type === 'pin')
handlePinConversation(currentConversationId)
if (type === 'unpin')
handleUnpinConversation(currentConversationId)
if (type === 'delete')
setShowConfirm(currentConversationItem as any)
if (type === 'rename')
setShowRename(currentConversationItem as any)
}, [currentConversationId, currentConversationItem, handlePinConversation, handleUnpinConversation])
const handleCancelConfirm = useCallback(() => {
setShowConfirm(null)
}, [])
const handleDelete = useCallback(() => {
if (showConfirm)
handleDeleteConversation(showConfirm.id, { onSuccess: handleCancelConfirm })
}, [showConfirm, handleDeleteConversation, handleCancelConfirm])
const handleCancelRename = useCallback(() => {
setShowRename(null)
}, [])
const handleRename = useCallback((newName: string) => {
if (showRename)
handleRenameConversation(showRename.id, newName, { onSuccess: handleCancelRename })
}, [showRename, handleRenameConversation, handleCancelRename])
return (
<>
<div className='shrink-0 h-14 p-3 flex items-center justify-between'>
<div className={cn('flex items-center gap-1 transition-all duration-200 ease-in-out', !isSidebarCollapsed && 'opacity-0 user-select-none')}>
<ActionButton className={cn(!isSidebarCollapsed && 'cursor-default')} size='l' onClick={() => handleSidebarCollapse(false)}>
<RiLayoutRight2Line className='w-[18px] h-[18px]' />
</ActionButton>
<div className='shrink-0 mr-1'>
<AppIcon
size='large'
iconType={appData?.site.icon_type}
icon={appData?.site.icon}
background={appData?.site.icon_background}
imageUrl={appData?.site.icon_url}
/>
</div>
{!currentConversationId && (
<div className={cn('grow text-text-secondary system-md-semibold truncate')}>{appData?.site.title}</div>
)}
{currentConversationId && currentConversationItem && isSidebarCollapsed && (
<>
<div className='p-1 text-divider-deep'>/</div>
<Operation
title={currentConversationItem?.name || ''}
isPinned={!!isPin}
togglePin={() => handleOperate(isPin ? 'unpin' : 'pin')}
isShowDelete
isShowRenameConversation
onRenameConversation={() => handleOperate('rename')}
onDelete={() => handleOperate('delete')}
/>
</>
)}
<div className='px-1 flex items-center'>
<div className='h-[14px] w-px bg-divider-regular'></div>
</div>
{isSidebarCollapsed && (
<ActionButton size='l' onClick={handleNewConversation}>
<RiEditBoxLine className='w-[18px] h-[18px]' />
</ActionButton>
)}
</div>
<div className='flex items-center gap-1'>
{currentConversationId && (
<Tooltip
popupContent={t('share.chat.resetChat')}
>
<ActionButton size='l' onClick={handleNewConversation}>
<RiResetLeftLine className='w-[18px] h-[18px]' />
</ActionButton>
</Tooltip>
)}
{currentConversationId && inputsForms.length > 0 && (
<ViewFormDropdown />
)}
</div>
</div>
{!!showConfirm && (
<Confirm
title={t('share.chat.deleteConversation.title')}
content={t('share.chat.deleteConversation.content') || ''}
isShow
onCancel={handleCancelConfirm}
onConfirm={handleDelete}
/>
)}
{showRename && (
<RenameModal
isShow
onClose={handleCancelRename}
saveLoading={conversationRenaming}
name={showRename?.name || ''}
onSave={handleRename}
/>
)}
</>
)
}
export default Header

View File

@@ -0,0 +1,55 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiMoreFill,
} from '@remixicon/react'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
type Props = {
handleResetChat: () => void
handleViewChatSettings: () => void
}
const MobileOperationDropdown = ({
handleResetChat,
handleViewChatSettings,
}: 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}>
<RiMoreFill className='w-[18px] h-[18px]' />
</ActionButton>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-40">
<div
className={'min-w-[160px] p-1 bg-components-panel-bg-blur backdrop-blur-sm rounded-xl border-[0.5px] border-components-panel-border shadow-lg'}
>
<div className='flex items-center space-x-1 px-3 py-1.5 rounded-lg text-text-secondary system-md-regular cursor-pointer hover:bg-state-base-hover' onClick={handleResetChat}>
<span className='grow'>{t('share.chat.resetChat')}</span>
</div>
<div className='flex items-center space-x-1 px-3 py-1.5 rounded-lg text-text-secondary system-md-regular cursor-pointer hover:bg-state-base-hover' onClick={handleViewChatSettings}>
<span className='grow'>{t('share.chat.viewChatSettings')}</span>
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default MobileOperationDropdown

View File

@@ -0,0 +1,73 @@
'use client'
import type { FC } from 'react'
import React, { useState } from 'react'
import type { Placement } from '@floating-ui/react'
import {
RiArrowDownSLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import cn from '@/utils/classnames'
type Props = {
title: string
isPinned: boolean
isShowRenameConversation?: boolean
onRenameConversation?: () => void
isShowDelete: boolean
togglePin: () => void
onDelete: () => void
placement?: Placement
}
const Operation: FC<Props> = ({
title,
isPinned,
togglePin,
isShowRenameConversation,
onRenameConversation,
isShowDelete,
onDelete,
placement = 'bottom-start',
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement={placement}
offset={4}
>
<PortalToFollowElemTrigger
onClick={() => setOpen(v => !v)}
>
<div className={cn('flex items-center p-1.5 pl-2 rounded-lg text-text-secondary cursor-pointer hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
<div className='system-md-semibold'>{title}</div>
<RiArrowDownSLine className='w-4 h-4 ' />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-50">
<div
className={'min-w-[120px] p-1 bg-components-panel-bg-blur backdrop-blur-sm rounded-xl border-[0.5px] border-components-panel-border shadow-lg'}
>
<div className={cn('flex items-center space-x-1 px-3 py-1.5 rounded-lg text-text-secondary system-md-regular cursor-pointer hover:bg-state-base-hover')} onClick={togglePin}>
<span className='grow'>{isPinned ? t('explore.sidebar.action.unpin') : t('explore.sidebar.action.pin')}</span>
</div>
{isShowRenameConversation && (
<div className={cn('flex items-center space-x-1 px-3 py-1.5 rounded-lg text-text-secondary system-md-regular cursor-pointer hover:bg-state-base-hover')} onClick={onRenameConversation}>
<span className='grow'>{t('explore.sidebar.action.rename')}</span>
</div>
)}
{isShowDelete && (
<div className={cn('group flex items-center space-x-1 px-3 py-1.5 rounded-lg text-text-secondary system-md-regular cursor-pointer hover:bg-state-destructive-hover hover:text-text-destructive')} onClick={onDelete} >
<span className='grow'>{t('explore.sidebar.action.delete')}</span>
</div>
)}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default React.memo(Operation)

View File

@@ -110,6 +110,19 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
changeLanguage(appData.site.default_language)
}, [appData])
const [sidebarCollapseState, setSidebarCollapseState] = useState<boolean>(false)
const handleSidebarCollapse = useCallback((state: boolean) => {
if (appId) {
setSidebarCollapseState(state)
localStorage.setItem('webappSidebarCollapse', state ? 'collapsed' : 'expanded')
}
}, [appId, setSidebarCollapseState])
useEffect(() => {
if (appId) {
const localState = localStorage.getItem('webappSidebarCollapse')
setSidebarCollapseState(localState === 'collapsed')
}
}, [appId])
const [conversationIdInfo, setConversationIdInfo] = useLocalStorageState<Record<string, string>>(CONVERSATION_ID_INFO, {
defaultValue: {},
})
@@ -122,7 +135,6 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
})
}
}, [appId, conversationIdInfo, setConversationIdInfo])
const [showConfigPanelBeforeChat, setShowConfigPanelBeforeChat] = useState(true)
const [newConversationId, setNewConversationId] = useState('')
const chatShouldReloadKey = useMemo(() => {
@@ -287,23 +299,18 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
return true
}, [inputsForms, notify, t])
const handleStartChat = useCallback(() => {
const handleStartChat = useCallback((callback: any) => {
if (checkInputsRequired()) {
setShowConfigPanelBeforeChat(false)
setShowNewConversationItemInList(true)
callback?.()
}
}, [setShowConfigPanelBeforeChat, setShowNewConversationItemInList, checkInputsRequired])
}, [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])
}, [handleConversationIdInfoChange])
const handleNewConversation = useCallback(() => {
currentChatInstanceRef.current.handleStop()
setNewConversationId('')
@@ -313,11 +320,10 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
}
else if (currentConversationId) {
handleConversationIdInfoChange('')
setShowConfigPanelBeforeChat(true)
setShowNewConversationItemInList(true)
handleNewConversationInputsChange({})
}
}, [handleChangeConversation, currentConversationId, handleConversationIdInfoChange, setShowConfigPanelBeforeChat, setShowNewConversationItemInList, showNewConversationItemInList, handleNewConversationInputsChange])
}, [handleChangeConversation, currentConversationId, handleConversationIdInfoChange, setShowNewConversationItemInList, showNewConversationItemInList, handleNewConversationInputsChange])
const handleUpdateConversationList = useCallback(() => {
mutateAppConversationData()
mutateAppPinnedConversationData()
@@ -435,8 +441,6 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
appPrevChatTree,
pinnedConversationList,
conversationList,
showConfigPanelBeforeChat,
setShowConfigPanelBeforeChat,
setShowNewConversationItemInList,
newConversationInputs,
newConversationInputsRef,
@@ -456,5 +460,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
chatShouldReloadKey,
handleFeedback,
currentChatInstanceRef,
sidebarCollapseState,
handleSidebarCollapse,
}
}

View File

@@ -11,14 +11,15 @@ import {
} from './context'
import { useChatWithHistory } from './hooks'
import Sidebar from './sidebar'
import Header from './header'
import HeaderInMobile from './header-in-mobile'
import ConfigPanel from './config-panel'
import ChatWrapper from './chat-wrapper'
import type { InstalledApp } from '@/models/explore'
import Loading from '@/app/components/base/loading'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { checkOrSetAccessToken } from '@/app/components/share/utils'
import AppUnavailable from '@/app/components/base/app-unavailable'
import cn from '@/utils/classnames'
type ChatWithHistoryProps = {
className?: string
@@ -30,18 +31,18 @@ const ChatWithHistory: FC<ChatWithHistoryProps> = ({
appInfoError,
appData,
appInfoLoading,
appPrevChatTree,
showConfigPanelBeforeChat,
appChatListDataLoading,
chatShouldReloadKey,
isMobile,
themeBuilder,
sidebarCollapseState,
} = useChatWithHistoryContext()
const chatReady = (!showConfigPanelBeforeChat || !!appPrevChatTree.length)
const isSidebarCollapsed = sidebarCollapseState
const customConfig = appData?.custom_config
const site = appData?.site
const [showSidePanel, setShowSidePanel] = useState(false)
useEffect(() => {
themeBuilder?.buildTheme(site?.chat_color_theme, site?.chat_color_theme_inverted)
if (site) {
@@ -65,35 +66,44 @@ const ChatWithHistory: FC<ChatWithHistoryProps> = ({
}
return (
<div className={`h-full flex bg-white ${className} ${isMobile && 'flex-col'}`}>
{
!isMobile && (
<div className={cn(
'h-full flex bg-background-default-burn',
isMobile && 'flex-col',
className,
)}>
{!isMobile && (
<div className={cn(
'flex flex-col w-[236px] p-1 pr-0 transition-all duration-200 ease-in-out',
isSidebarCollapsed && 'w-0 !p-0 overflow-hidden',
)}>
<Sidebar />
)
}
{
isMobile && (
<HeaderInMobile />
)
}
<div className={`grow overflow-hidden ${showConfigPanelBeforeChat && !appPrevChatTree.length && 'flex items-center justify-center'}`}>
{
showConfigPanelBeforeChat && !appChatListDataLoading && !appPrevChatTree.length && (
<div className={`flex w-full items-center justify-center h-full ${isMobile && 'px-4'}`}>
<ConfigPanel />
</div>
)
}
{
appChatListDataLoading && chatReady && (
</div>
)}
{isMobile && (
<HeaderInMobile />
)}
<div className={cn('relative grow p-2')}>
{isSidebarCollapsed && (
<div
className={cn(
'z-20 absolute top-0 w-[256px] h-full flex flex-col p-2 transition-all duration-500 ease-in-out',
showSidePanel ? 'left-0' : 'left-[-248px]',
)}
onMouseEnter={() => setShowSidePanel(true)}
onMouseLeave={() => setShowSidePanel(false)}
>
<Sidebar isPanel />
</div>
)}
<div className='h-full flex flex-col bg-chatbot-bg rounded-2xl border-[0,5px] border-components-panel-border-subtle overflow-hidden'>
{!isMobile && <Header />}
{appChatListDataLoading && (
<Loading type='app' />
)
}
{
chatReady && !appChatListDataLoading && (
)}
{!appChatListDataLoading && (
<ChatWrapper key={chatShouldReloadKey} />
)
}
)}
</div>
</div>
</div>
)
@@ -123,7 +133,6 @@ const ChatWithHistoryWrap: FC<ChatWithHistoryWrapProps> = ({
appPrevChatTree,
pinnedConversationList,
conversationList,
showConfigPanelBeforeChat,
newConversationInputs,
newConversationInputsRef,
handleNewConversationInputsChange,
@@ -142,6 +151,8 @@ const ChatWithHistoryWrap: FC<ChatWithHistoryWrapProps> = ({
appId,
handleFeedback,
currentChatInstanceRef,
sidebarCollapseState,
handleSidebarCollapse,
} = useChatWithHistory(installedAppInfo)
return (
@@ -157,7 +168,6 @@ const ChatWithHistoryWrap: FC<ChatWithHistoryWrapProps> = ({
appPrevChatTree,
pinnedConversationList,
conversationList,
showConfigPanelBeforeChat,
newConversationInputs,
newConversationInputsRef,
handleNewConversationInputsChange,
@@ -178,6 +188,8 @@ const ChatWithHistoryWrap: FC<ChatWithHistoryWrapProps> = ({
handleFeedback,
currentChatInstanceRef,
themeBuilder,
sidebarCollapseState,
handleSidebarCollapse,
}}>
<ChatWithHistory className={className} />
</ChatWithHistoryContext.Provider>

View File

@@ -0,0 +1,118 @@
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useChatWithHistoryContext } 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'
type Props = {
showTip?: boolean
}
const InputsFormContent = ({ showTip }: Props) => {
const { t } = useTranslation()
const {
appParams,
inputsForms,
currentConversationId,
currentConversationItem,
newConversationInputs,
newConversationInputsRef,
handleNewConversationInputsChange,
} = useChatWithHistoryContext()
const inputsFormValue = currentConversationId ? currentConversationItem?.inputs : newConversationInputs
const readonly = !!currentConversationId
const handleFormChange = useCallback((variable: string, value: any) => {
handleNewConversationInputsChange({
...newConversationInputsRef.current,
[variable]: value,
})
}, [newConversationInputsRef, handleNewConversationInputsChange])
return (
<div className='space-y-4'>
{inputsForms.map(form => (
<div key={form.variable} className='space-y-1'>
<div className='h-6 flex items-center gap-1'>
<div className='text-text-secondary system-md-semibold'>{form.label}</div>
{!form.required && (
<div className='text-text-tertiary system-xs-regular'>{t('appDebug.variableTable.optional')}</div>
)}
</div>
{form.type === InputVarType.textInput && (
<Input
value={inputsFormValue?.[form.variable] || ''}
onChange={e => handleFormChange(form.variable, e.target.value)}
placeholder={form.label}
readOnly={readonly}
disabled={readonly}
/>
)}
{form.type === InputVarType.number && (
<Input
type='number'
value={inputsFormValue?.[form.variable] || ''}
onChange={e => handleFormChange(form.variable, e.target.value)}
placeholder={form.label}
readOnly={readonly}
disabled={readonly}
/>
)}
{form.type === InputVarType.paragraph && (
<Textarea
value={inputsFormValue?.[form.variable] || ''}
onChange={e => handleFormChange(form.variable, e.target.value)}
placeholder={form.label}
readOnly={readonly}
disabled={readonly}
/>
)}
{form.type === InputVarType.select && (
<PortalSelect
popupClassName='w-[200px]'
value={inputsFormValue?.[form.variable]}
items={form.options.map((option: string) => ({ value: option, name: option }))}
onSelect={item => handleFormChange(form.variable, item.value as string)}
placeholder={form.label}
readonly={readonly}
/>
)}
{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,
}}
/>
)}
</div>
))}
{showTip && (
<div className='text-text-tertiary system-xs-regular'>{t('share.chat.chatFormTip')}</div>
)}
</div>
)
}
export default InputsFormContent

View File

@@ -0,0 +1,79 @@
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/chat-with-history/inputs-form/content'
import { useChatWithHistoryContext } 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,
handleStartChat,
themeBuilder,
} = useChatWithHistoryContext()
return (
<div className={cn('pt-6 px-4 flex flex-col items-center', isMobile && 'pt-4')}>
<div className={cn(
'w-full max-w-[672px] bg-components-panel-bg rounded-2xl border-[0.5px] border-components-panel-border shadow-md',
collapsed && 'bg-components-card-bg border border-components-card-border shadow-none',
)}>
<div className={cn(
'flex items-center gap-3 px-6 py-4 rounded-t-2xl',
!collapsed && 'border-b border-divider-subtle',
isMobile && 'px-4 py-3',
)}>
<Message3Fill className='shrink-0 w-6 h-6' />
<div className='grow text-text-secondary system-xl-semibold'>{t('share.chat.chatSettingsTitle')}</div>
{collapsed && (
<Button className='text-text-tertiary uppercase' size='small' variant='ghost' onClick={() => setCollapsed(false)}>{currentConversationId ? t('common.operation.view') : t('common.operation.edit')}</Button>
)}
{!collapsed && currentConversationId && (
<Button className='text-text-tertiary uppercase' size='small' variant='ghost' onClick={() => setCollapsed(true)}>{t('common.operation.close')}</Button>
)}
</div>
{!collapsed && (
<div className={cn('p-6', isMobile && 'p-4')}>
<InputsFormContent showTip={!!currentConversationId} />
</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='py-4 flex items-center w-full max-w-[720px]'>
<Divider bgStyle='gradient' className='basis-1/2 h-px rotate-180' />
<Divider bgStyle='gradient' className='basis-1/2 h-px' />
</div>
)}
</div>
)
}
export default InputsFormNode

View File

@@ -0,0 +1,48 @@
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/chat-with-history/inputs-form/content'
const ViewFormDropdown = () => {
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='w-[18px] h-[18px]' />
</ActionButton>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-50">
<div className='w-[400px] bg-components-panel-bg backdrop-blur-sm rounded-2xl border-[0.5px] border-components-panel-border shadow-lg'>
<div className='flex items-center gap-3 px-6 py-4 rounded-t-2xl border-b border-divider-subtle'>
<Message3Fill className='shrink-0 w-6 h-6' />
<div className='grow text-text-secondary system-xl-semibold'>{t('share.chat.chatSettingsTitle')}</div>
</div>
<div className='p-6'>
<InputsFormContent showTip />
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default ViewFormDropdown

View File

@@ -3,22 +3,34 @@ import {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
RiEditBoxLine,
RiExpandRightLine,
RiLayoutLeft2Line,
} from '@remixicon/react'
import { useChatWithHistoryContext } from '../context'
import List from './list'
import AppIcon from '@/app/components/base/app-icon'
import ActionButton from '@/app/components/base/action-button'
import Button from '@/app/components/base/button'
import { Edit05 } from '@/app/components/base/icons/src/vender/line/general'
import type { ConversationItem } from '@/models/share'
import List from '@/app/components/base/chat/chat-with-history/sidebar/list'
import MenuDropdown from '@/app/components/share/text-generation/menu-dropdown'
import Confirm from '@/app/components/base/confirm'
import RenameModal from '@/app/components/base/chat/chat-with-history/sidebar/rename-modal'
import LogoSite from '@/app/components/base/logo/logo-site'
import type { ConversationItem } from '@/models/share'
import cn from '@/utils/classnames'
const Sidebar = () => {
type Props = {
isPanel?: boolean
}
const Sidebar = ({ isPanel }: Props) => {
const { t } = useTranslation()
const {
appData,
handleNewConversation,
pinnedConversationList,
conversationList,
handleNewConversation,
currentConversationId,
handleChangeConversation,
handlePinConversation,
@@ -26,8 +38,12 @@ const Sidebar = () => {
conversationRenaming,
handleRenameConversation,
handleDeleteConversation,
sidebarCollapseState,
handleSidebarCollapse,
isMobile,
} = useChatWithHistoryContext()
const isSidebarCollapsed = sidebarCollapseState
const [showConfirm, setShowConfirm] = useState<ConversationItem | null>(null)
const [showRename, setShowRename] = useState<ConversationItem | null>(null)
@@ -60,66 +76,83 @@ const Sidebar = () => {
}, [showRename, handleRenameConversation, handleCancelRename])
return (
<div className='shrink-0 h-full flex flex-col w-[240px] border-r border-r-gray-100'>
{
!isMobile && (
<div className='shrink-0 flex p-4'>
<AppIcon
className='mr-3'
size='small'
iconType={appData?.site.icon_type}
icon={appData?.site.icon}
background={appData?.site.icon_background}
imageUrl={appData?.site.icon_url}
/>
<div className='py-1 text-base font-semibold text-gray-800'>
{appData?.site.title}
</div>
</div>
)
}
<div className='shrink-0 p-4'>
<Button
variant='secondary-accent'
className='justify-start w-full'
onClick={handleNewConversation}
>
<Edit05 className='mr-2 w-4 h-4' />
<div className={cn(
'grow flex flex-col',
isPanel && 'rounded-xl bg-components-panel-bg border-[0.5px] border-components-panel-border-subtle shadow-lg',
)}>
<div className={cn(
'shrink-0 flex items-center gap-3 p-3 pr-2',
)}>
<div className='shrink-0'>
<AppIcon
size='large'
iconType={appData?.site.icon_type}
icon={appData?.site.icon}
background={appData?.site.icon_background}
imageUrl={appData?.site.icon_url}
/>
</div>
<div className={cn('grow text-text-secondary system-md-semibold truncate')}>{appData?.site.title}</div>
{!isMobile && isSidebarCollapsed && (
<ActionButton size='l' onClick={() => handleSidebarCollapse(false)}>
<RiExpandRightLine className='w-[18px] h-[18px]' />
</ActionButton>
)}
{!isMobile && !isSidebarCollapsed && (
<ActionButton size='l' onClick={() => handleSidebarCollapse(true)}>
<RiLayoutLeft2Line className='w-[18px] h-[18px]' />
</ActionButton>
)}
</div>
<div className='shrink-0 px-3 py-4'>
<Button variant='secondary-accent' className='w-full justify-center' onClick={handleNewConversation}>
<RiEditBoxLine className='w-4 h-4 mr-1' />
{t('share.chat.newChat')}
</Button>
</div>
<div className='grow px-4 py-2 overflow-y-auto'>
{
!!pinnedConversationList.length && (
<div className='mb-4'>
<List
isPin
title={t('share.chat.pinnedTitle') || ''}
list={pinnedConversationList}
onChangeConversation={handleChangeConversation}
onOperate={handleOperate}
currentConversationId={currentConversationId}
/>
</div>
)
}
{
!!conversationList.length && (
<div className='grow h-0 pt-4 px-3 space-y-2 overflow-y-auto'>
{/* pinned list */}
{!!pinnedConversationList.length && (
<div className='mb-4'>
<List
title={(pinnedConversationList.length && t('share.chat.unpinnedTitle')) || ''}
list={conversationList}
isPin
title={t('share.chat.pinnedTitle') || ''}
list={pinnedConversationList}
onChangeConversation={handleChangeConversation}
onOperate={handleOperate}
currentConversationId={currentConversationId}
/>
)
}
</div>
)}
{!!conversationList.length && (
<List
title={(pinnedConversationList.length && t('share.chat.unpinnedTitle')) || ''}
list={conversationList}
onChangeConversation={handleChangeConversation}
onOperate={handleOperate}
currentConversationId={currentConversationId}
/>
)}
</div>
{appData?.site.copyright && (
<div className='px-4 pb-4 text-xs text-gray-400'>
© {(new Date()).getFullYear()} {appData?.site.copyright}
<div className='shrink-0 p-3 flex items-center justify-between'>
<MenuDropdown placement='top-start' data={appData?.site} />
{/* powered by */}
<div className='shrink-0'>
{!appData?.custom_config?.remove_webapp_brand && (
<div className={cn(
'shrink-0 px-2 flex items-center gap-1.5',
)}>
<div className='text-text-tertiary system-2xs-medium-uppercase'>{t('share.chat.poweredBy')}</div>
{appData?.custom_config?.replace_webapp_logo && (
<img src={appData?.custom_config?.replace_webapp_logo} alt='logo' className='block w-auto h-5' />
)}
{!appData?.custom_config?.replace_webapp_logo && (
<LogoSite className='!h-5' />
)}
</div>
)}
</div>
)}
</div>
{!!showConfirm && (
<Confirm
title={t('share.chat.deleteConversation.title')}

View File

@@ -5,8 +5,8 @@ import {
} from 'react'
import { useHover } from 'ahooks'
import type { ConversationItem } from '@/models/share'
import { MessageDotsCircle } from '@/app/components/base/icons/src/vender/solid/communication'
import ItemOperation from '@/app/components/explore/item-operation'
import Operation from '@/app/components/base/chat/chat-with-history/sidebar/operation'
import cn from '@/utils/classnames'
type ItemProps = {
isPin?: boolean
@@ -24,23 +24,23 @@ const Item: FC<ItemProps> = ({
}) => {
const ref = useRef(null)
const isHovering = useHover(ref)
const isSelected = currentConversationId === item.id
return (
<div
ref={ref}
key={item.id}
className={`
flex mb-0.5 last-of-type:mb-0 py-1.5 pl-3 pr-1.5 text-sm font-medium text-gray-700
rounded-lg cursor-pointer hover:bg-gray-50 group
${currentConversationId === item.id && 'text-primary-600 bg-primary-50'}
`}
className={cn(
'group flex p-1 pl-3 rounded-lg cursor-pointer text-components-menu-item-text system-sm-medium hover:bg-state-base-hover',
isSelected && 'bg-state-accent-active hover:bg-state-accent-active text-text-accent',
)}
onClick={() => onChangeConversation(item.id)}
>
<MessageDotsCircle className={`shrink-0 mt-1 mr-2 w-4 h-4 text-gray-400 ${currentConversationId === item.id && 'text-primary-600'}`} />
<div className='grow py-0.5 break-all' title={item.name}>{item.name}</div>
<div className='grow p-1 pl-0 truncate' title={item.name}>{item.name}</div>
{item.id !== '' && (
<div className='shrink-0 h-6' onClick={e => e.stopPropagation()}>
<ItemOperation
<div className='shrink-0' onClick={e => e.stopPropagation()}>
<Operation
isActive={isSelected}
isPinned={!!isPin}
isItemHovering={isHovering}
togglePin={() => onOperate(isPin ? 'unpin' : 'pin', item)}

View File

@@ -19,26 +19,20 @@ const List: FC<ListProps> = ({
currentConversationId,
}) => {
return (
<div>
{
title && (
<div className='mb-0.5 px-3 h-[26px] text-xs font-medium text-gray-500'>
{title}
</div>
)
}
{
list.map(item => (
<Item
key={item.id}
isPin={isPin}
item={item}
onOperate={onOperate}
onChangeConversation={onChangeConversation}
currentConversationId={currentConversationId}
/>
))
}
<div className='space-y-0.5'>
{title && (
<div className='px-3 pt-2 pb-1 text-text-tertiary system-xs-medium-uppercase'>{title}</div>
)}
{list.map(item => (
<Item
key={item.id}
isPin={isPin}
item={item}
onOperate={onOperate}
onChangeConversation={onChangeConversation}
currentConversationId={currentConversationId}
/>
))}
</div>
)
}

View File

@@ -0,0 +1,101 @@
'use client'
import type { FC } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import {
RiDeleteBinLine,
RiEditLine,
RiMoreFill,
RiPushpinLine,
RiUnpinLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useBoolean } from 'ahooks'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
import cn from '@/utils/classnames'
type Props = {
isActive?: boolean
isItemHovering?: boolean
isPinned: boolean
isShowRenameConversation?: boolean
onRenameConversation?: () => void
isShowDelete: boolean
togglePin: () => void
onDelete: () => void
}
const Operation: FC<Props> = ({
isActive,
isItemHovering,
isPinned,
togglePin,
isShowRenameConversation,
onRenameConversation,
isShowDelete,
onDelete,
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const ref = useRef(null)
const [isHovering, { setTrue: setIsHovering, setFalse: setNotHovering }] = useBoolean(false)
useEffect(() => {
if (!isItemHovering && !isHovering)
setOpen(false)
}, [isItemHovering, isHovering])
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-end'
offset={4}
>
<PortalToFollowElemTrigger
onClick={() => setOpen(v => !v)}
>
<ActionButton
className={cn((isItemHovering || open) ? 'opacity-100' : 'opacity-0')}
state={
isActive
? ActionButtonState.Active
: open
? ActionButtonState.Hover
: ActionButtonState.Default
}
>
<RiMoreFill className='w-4 h-4' />
</ActionButton>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-50">
<div
ref={ref}
className={'min-w-[120px] p-1 bg-components-panel-bg-blur backdrop-blur-sm rounded-xl border-[0.5px] border-components-panel-border shadow-lg'}
onMouseEnter={setIsHovering}
onMouseLeave={setNotHovering}
onClick={(e) => {
e.stopPropagation()
}}
>
<div className={cn('flex items-center space-x-1 px-2 py-1.5 rounded-lg text-text-secondary system-md-regular cursor-pointer hover:bg-state-base-hover')} onClick={togglePin}>
{isPinned && <RiUnpinLine className='shrink-0 w-4 h-4 text-text-tertiary' />}
{!isPinned && <RiPushpinLine className='shrink-0 w-4 h-4 text-text-tertiary' />}
<span className='grow'>{isPinned ? t('explore.sidebar.action.unpin') : t('explore.sidebar.action.pin')}</span>
</div>
{isShowRenameConversation && (
<div className={cn('flex items-center space-x-1 px-2 py-1.5 rounded-lg text-text-secondary system-md-regular cursor-pointer hover:bg-state-base-hover')} onClick={onRenameConversation}>
<RiEditLine className='shrink-0 w-4 h-4 text-text-tertiary' />
<span className='grow'>{t('explore.sidebar.action.rename')}</span>
</div>
)}
{isShowDelete && (
<div className={cn('group flex items-center space-x-1 px-2 py-1.5 rounded-lg text-text-secondary system-md-regular cursor-pointer hover:bg-state-destructive-hover hover:text-text-destructive')} onClick={onDelete} >
<RiDeleteBinLine className={cn('shrink-0 w-4 h-4 text-text-tertiary group-hover:text-text-destructive')} />
<span className='grow'>{t('explore.sidebar.action.delete')}</span>
</div>
)}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default React.memo(Operation)

View File

@@ -4,6 +4,7 @@ import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
export type IRenameModalProps = {
isShow: boolean
@@ -29,16 +30,16 @@ const RenameModal: FC<IRenameModalProps> = ({
isShow={isShow}
onClose={onClose}
>
<div className={'mt-6 font-medium text-sm leading-[21px] text-gray-900'}>{t('common.chat.conversationName')}</div>
<input className={'mt-2 w-full rounded-lg h-10 box-border px-3 text-sm leading-10 bg-gray-100'}
<div className={'mt-6 font-medium text-sm leading-[21px] text-text-primary'}>{t('common.chat.conversationName')}</div>
<Input className='mt-2 w-full h-10'
value={tempName}
onChange={e => setTempName(e.target.value)}
placeholder={t('common.chat.conversationNamePlaceholder') || ''}
/>
<div className='mt-10 flex justify-end'>
<Button className='mr-2 flex-shrink-0' onClick={onClose}>{t('common.operation.cancel')}</Button>
<Button variant='primary' className='flex-shrink-0' onClick={() => onSave(tempName)} loading={saveLoading}>{t('common.operation.save')}</Button>
<Button className='mr-2 shrink-0' onClick={onClose}>{t('common.operation.cancel')}</Button>
<Button variant='primary' className='shrink-0' onClick={() => onSave(tempName)} loading={saveLoading}>{t('common.operation.save')}</Button>
</div>
</Modal>
)

View File

@@ -105,7 +105,7 @@ const Answer: FC<AnswerProps> = ({
<div className='shrink-0 relative w-10 h-10'>
{answerIcon || <AnswerIcon />}
{responding && (
<div className='absolute -top-[3px] -left-[3px] pl-[6px] flex items-center w-4 h-4 bg-white rounded-full shadow-xs border-[0.5px] border-gray-50'>
<div className='absolute -top-[3px] -left-[3px] pl-[6px] flex items-center w-4 h-4 bg-background-section-burn rounded-full shadow-xs border-[0.5px] border-divider-subtle'>
<LoadingAnim type='avatar' />
</div>
)}

View File

@@ -13,7 +13,7 @@ const More: FC<MoreProps> = ({
const { t } = useTranslation()
return (
<div className='flex items-center mt-1 h-[18px] text-xs text-gray-400 opacity-0 group-hover:opacity-100'>
<div className='mt-1 flex items-center system-xs-regular text-text-quaternary opacity-0 group-hover:opacity-100'>
{
more && (
<>

View File

@@ -5,23 +5,24 @@ import {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
RiClipboardLine,
RiEditLine,
RiReplay15Line,
RiThumbDownLine,
RiThumbUpLine,
} from '@remixicon/react'
import type { ChatItem } from '../../types'
import { useChatContext } from '../context'
import RegenerateBtn from '@/app/components/base/regenerate-btn'
import cn from '@/utils/classnames'
import CopyBtn from '@/app/components/base/copy-btn'
import { MessageFast } from '@/app/components/base/icons/src/vender/solid/communication'
import AudioBtn from '@/app/components/base/audio-btn'
import AnnotationCtrlBtn from '@/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-btn'
import copy from 'copy-to-clipboard'
import Toast from '@/app/components/base/toast'
import EditReplyModal from '@/app/components/app/annotation/edit-annotation-modal'
import {
ThumbsDown,
ThumbsUp,
} from '@/app/components/base/icons/src/vender/line/alertsAndFeedback'
import Tooltip from '@/app/components/base/tooltip'
import Log from '@/app/components/base/chat/chat/log'
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
import NewAudioButton from '@/app/components/base/new-audio-button'
import cn from '@/utils/classnames'
interface OperationProps {
type OperationProps = {
item: ChatItem
question: string
index: number
@@ -60,7 +61,6 @@ const Operation: FC<OperationProps> = ({
adminFeedback,
agent_thoughts,
} = item
const hasAnnotation = !!annotation?.id
const [localFeedback, setLocalFeedback] = useState(config?.supportAnnotation ? adminFeedback : feedback)
const content = useMemo(() => {
@@ -102,121 +102,68 @@ const Operation: FC<OperationProps> = ({
<div
className={cn(
'absolute flex justify-end gap-1',
hasWorkflowProcess && '-top-3.5 -right-3.5',
!positionRight && '-top-3.5 -right-3.5',
hasWorkflowProcess && '-bottom-4 right-2',
!positionRight && '-bottom-4 right-2',
!hasWorkflowProcess && positionRight && '!top-[9px]',
)}
style={(!hasWorkflowProcess && positionRight) ? { left: contentWidth + 8 } : {}}
>
{!isOpeningStatement && (
<CopyBtn
value={content}
className='hidden group-hover:block'
/>
{showPromptLog && (
<div className='hidden group-hover:block'>
<Log logItem={item} />
</div>
)}
{!isOpeningStatement && (showPromptLog || config?.text_to_speech?.enabled) && (
<div className='hidden group-hover:flex items-center w-max h-[28px] p-0.5 rounded-lg bg-white border-[0.5px] border-gray-100 shadow-md shrink-0'>
{showPromptLog && (
<>
<Log logItem={item} />
<div className='mx-1 w-[1px] h-[14px] bg-gray-200' />
</>
)}
{!isOpeningStatement && (
<div className='hidden group-hover:flex ml-1 items-center gap-0.5 p-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg shadow-md backdrop-blur-sm'>
{(config?.text_to_speech?.enabled) && (
<>
<AudioBtn
id={id}
value={content}
noCache={false}
voice={config?.text_to_speech?.voice}
className='hidden group-hover:block'
/>
</>
<NewAudioButton
id={id}
value={content}
voice={config?.text_to_speech?.voice}
/>
)}
<ActionButton onClick={() => {
copy(content)
Toast.notify({ type: 'success', message: t('common.actionMsg.copySuccessfully') })
}}>
<RiClipboardLine className='w-4 h-4' />
</ActionButton>
{!noChatInput && (
<ActionButton onClick={() => onRegenerate?.(item)}>
<RiReplay15Line className='w-4 h-4' />
</ActionButton>
)}
{(config?.supportAnnotation && config.annotation_reply?.enabled) && (
<ActionButton onClick={() => setIsShowReplyModal(true)}>
<RiEditLine className='w-4 h-4' />
</ActionButton>
)}
</div>
)}
{(!isOpeningStatement && config?.supportAnnotation && config.annotation_reply?.enabled) && (
<AnnotationCtrlBtn
appId={config?.appId || ''}
messageId={id}
annotationId={annotation?.id || ''}
className='hidden group-hover:block ml-1 shrink-0'
cached={hasAnnotation}
query={question}
answer={content}
onAdded={(id, authorName) => onAnnotationAdded?.(id, authorName, question, content, index)}
onEdit={() => setIsShowReplyModal(true)}
onRemoved={() => onAnnotationRemoved?.(index)}
/>
{!isOpeningStatement && config?.supportFeedback && onFeedback && (
<div className='hidden group-hover:flex ml-1 items-center gap-0.5 p-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg shadow-md backdrop-blur-sm'>
{!localFeedback?.rating && (
<>
<ActionButton onClick={() => handleFeedback('like')}>
<RiThumbUpLine className='w-4 h-4' />
</ActionButton>
<ActionButton onClick={() => handleFeedback('dislike')}>
<RiThumbDownLine className='w-4 h-4' />
</ActionButton>
</>
)}
{localFeedback?.rating === 'like' && (
<ActionButton state={ActionButtonState.Active} onClick={() => handleFeedback(null)}>
<RiThumbUpLine className='w-4 h-4' />
</ActionButton>
)}
{localFeedback?.rating === 'dislike' && (
<ActionButton state={ActionButtonState.Destructive} onClick={() => handleFeedback(null)}>
<RiThumbDownLine className='w-4 h-4' />
</ActionButton>
)}
</div>
)}
{
annotation?.id && (
<div
className='relative box-border flex items-center justify-center h-7 w-7 p-0.5 rounded-lg bg-white cursor-pointer text-[#444CE7] shadow-md group-hover:hidden'
>
<div className='p-1 rounded-lg bg-[#EEF4FF] '>
<MessageFast className='w-4 h-4' />
</div>
</div>
)
}
{
!isOpeningStatement && !noChatInput && <RegenerateBtn className='hidden group-hover:block mr-1' onClick={() => onRegenerate?.(item)} />
}
{
config?.supportFeedback && !localFeedback?.rating && onFeedback && !isOpeningStatement && (
<div className='hidden group-hover:flex shrink-0 items-center px-0.5 bg-white border-[0.5px] border-gray-100 shadow-md text-gray-500 rounded-lg'>
<Tooltip popupContent={t('appDebug.operation.agree')}>
<div
className='flex items-center justify-center mr-0.5 w-6 h-6 rounded-md hover:bg-black/5 hover:text-gray-800 cursor-pointer'
onClick={() => handleFeedback('like')}
>
<ThumbsUp className='w-4 h-4' />
</div>
</Tooltip>
<Tooltip
popupContent={t('appDebug.operation.disagree')}
>
<div
className='flex items-center justify-center w-6 h-6 rounded-md hover:bg-black/5 hover:text-gray-800 cursor-pointer'
onClick={() => handleFeedback('dislike')}
>
<ThumbsDown className='w-4 h-4' />
</div>
</Tooltip>
</div>
)
}
{
config?.supportFeedback && localFeedback?.rating && onFeedback && !isOpeningStatement && (
<Tooltip
popupContent={localFeedback.rating === 'like' ? t('appDebug.operation.cancelAgree') : t('appDebug.operation.cancelDisagree')}
>
<div
className={`
flex items-center justify-center w-7 h-7 rounded-[10px] border-[2px] border-white cursor-pointer
${localFeedback.rating === 'like' && 'bg-blue-50 text-blue-600'}
${localFeedback.rating === 'dislike' && 'bg-red-100 text-red-600'}
`}
onClick={() => handleFeedback(null)}
>
{
localFeedback.rating === 'like' && (
<ThumbsUp className='w-4 h-4' />
)
}
{
localFeedback.rating === 'dislike' && (
<ThumbsDown className='w-4 h-4' />
)
}
</div>
</Tooltip>
)
}
</div>
<EditReplyModal
isShow={isShowReplyModal}

View File

@@ -2,6 +2,7 @@ import type { FC } from 'react'
import { memo } from 'react'
import type { ChatItem } from '../../types'
import { useChatContext } from '../context'
import Button from '@/app/components/base/button'
type SuggestedQuestionsProps = {
item: ChatItem
@@ -21,13 +22,14 @@ const SuggestedQuestions: FC<SuggestedQuestionsProps> = ({
return (
<div className='flex flex-wrap'>
{suggestedQuestions.filter(q => !!q && q.trim()).map((question, index) => (
<div
<Button
key={index}
className='mt-1 mr-1 max-w-full last:mr-0 shrink-0 py-[5px] leading-[18px] items-center px-4 rounded-lg border border-gray-200 shadow-xs bg-white text-xs font-medium text-primary-600 cursor-pointer'
variant='secondary-accent'
className='mt-1 mr-1 max-w-full last:mr-0 shrink-0'
onClick={() => onSend?.(question)}
>
{question}
</div>),
</Button>),
)}
</div>
)

View File

@@ -1,6 +1,5 @@
import {
useEffect,
useMemo,
useState,
} from 'react'
import {
@@ -36,19 +35,6 @@ const WorkflowProcessItem = ({
const succeeded = data.status === WorkflowRunningStatus.Succeeded
const failed = data.status === WorkflowRunningStatus.Failed || data.status === WorkflowRunningStatus.Stopped
const background = useMemo(() => {
if (collapse)
return 'linear-gradient(90deg, rgba(200, 206, 218, 0.20) 0%, rgba(200, 206, 218, 0.04) 100%)'
if (running && !collapse)
return 'linear-gradient(180deg, #E1E4EA 0%, #EAECF0 100%)'
if (succeeded && !collapse)
return 'linear-gradient(180deg, #ECFDF3 0%, #F6FEF9 100%)'
if (failed && !collapse)
return 'linear-gradient(180deg, #FEE4E2 0%, #FEF3F2 100%)'
}, [running, succeeded, failed, collapse])
useEffect(() => {
setCollapse(!expand)
}, [expand])
@@ -56,12 +42,13 @@ const WorkflowProcessItem = ({
return (
<div
className={cn(
'-mx-1 px-2.5 rounded-xl border-[0.5px]',
collapse ? 'py-[7px] border-components-panel-border' : 'pt-[7px] px-1 pb-1 border-components-panel-border-subtle',
'-mx-1 px-2.5 rounded-xl',
collapse ? 'py-[7px] border-l-[0.25px] border-components-panel-border' : 'pt-[7px] px-1 pb-1 border-[0.5px] border-components-panel-border-subtle',
running && !collapse && 'bg-background-section-burn',
succeeded && !collapse && 'bg-state-success-hover',
failed && !collapse && 'bg-state-destructive-hover',
collapse && 'bg-workflow-process-bg',
)}
style={{
background,
}}
>
<div
className={cn('flex items-center cursor-pointer', !collapse && 'px-1.5', readonly && 'cursor-default')}
@@ -85,7 +72,7 @@ const WorkflowProcessItem = ({
<div className={cn('system-xs-medium text-text-secondary', !collapse && 'grow')}>
{t('workflow.common.workflowProcess')}
</div>
{!readonly && <RiArrowRightSLine className={`'ml-1 w-4 h-4 text-text-tertiary' ${collapse ? '' : 'rotate-90'}`} />}
{!readonly && <RiArrowRightSLine className={cn('ml-1 w-4 h-4 text-text-tertiary', !collapse && 'rotate-90')} />}
</div>
{
!collapse && !readonly && (

View File

@@ -40,6 +40,7 @@ type ChatInputAreaProps = {
inputsForm?: InputForm[]
theme?: Theme | null
isResponding?: boolean
disabled?: boolean
}
const ChatInputArea = ({
showFeatureBar,
@@ -53,6 +54,7 @@ const ChatInputArea = ({
inputsForm = [],
theme,
isResponding,
disabled,
}: ChatInputAreaProps) => {
const { t } = useTranslation()
const { notify } = useToastContext()
@@ -155,6 +157,7 @@ const ChatInputArea = ({
className={cn(
'relative pb-[9px] bg-components-panel-bg-blur border border-components-chat-input-border rounded-xl shadow-md z-10',
isDragActive && 'border border-dashed border-components-option-card-option-selected-border',
disabled && 'opacity-50 pointer-events-none border-components-panel-border shadow-none',
)}
>
<div className='relative px-[9px] pt-[9px] max-h-[158px] overflow-x-hidden overflow-y-auto'>

View File

@@ -77,9 +77,9 @@ const Citation: FC<CitationProps> = ({
return (
<div className='mt-3 -mb-1'>
<div className='flex items-center mb-2 text-xs font-medium text-gray-500'>
<div className='flex items-center mb-2 system-xs-medium text-text-tertiary'>
{t('common.chat.citation.title')}
<div className='grow ml-2 h-[1px] bg-black/5' />
<div className='grow ml-2 h-[1px] bg-divider-regular' />
</div>
<div className='relative flex flex-wrap'>
{
@@ -87,7 +87,7 @@ const Citation: FC<CitationProps> = ({
<div
key={index}
className='absolute top-0 left-0 w-auto mr-1 mb-1 pl-7 pr-2 max-w-[240px] h-7 text-xs whitespace-nowrap opacity-0 -z-10'
ref={ele => (elesRef.current[index] = ele!)}
ref={(ele: any) => (elesRef.current[index] = ele!)}
>
{res.documentName}
</div>
@@ -106,13 +106,13 @@ const Citation: FC<CitationProps> = ({
{
limitNumberInOneLine < resourcesLength && (
<div
className='flex items-center px-2 h-7 bg-white rounded-lg text-xs font-medium text-gray-500 cursor-pointer'
className='flex items-center px-2 h-7 bg-components-panel-bg rounded-lg text-text-tertiary system-xs-medium cursor-pointer'
onClick={() => setShowMore(v => !v)}
>
{
!showMore
? `+ ${resourcesLength - limitNumberInOneLine}`
: <RiArrowDownSLine className='w-4 h-4 text-gray-600 rotate-180' />
: <RiArrowDownSLine className='w-4 h-4 text-text-tertiary rotate-180' />
}
</div>
)

View File

@@ -47,29 +47,29 @@ const Popup: FC<PopupProps> = ({
}}
>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
<div className='flex items-center px-2 max-w-[240px] h-7 bg-white rounded-lg'>
<div className='flex items-center px-2 max-w-[240px] h-7 bg-components-button-secondary-bg rounded-lg'>
<FileIcon type={fileType} className='shrink-0 mr-1 w-4 h-4' />
<div className='text-xs text-gray-600 truncate'>{data.documentName}</div>
<div className='text-xs text-text-tertiary truncate'>{data.documentName}</div>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{ zIndex: 1000 }}>
<div className='max-w-[360px] bg-gray-50 rounded-xl shadow-lg'>
<div className='max-w-[360px] bg-background-section-burn rounded-xl shadow-lg'>
<div className='px-4 pt-3 pb-2'>
<div className='flex items-center h-[18px]'>
<FileIcon type={fileType} className='shrink-0 mr-1 w-4 h-4' />
<div className='text-xs font-medium text-gray-600 truncate'>{data.documentName}</div>
<div className='system-xs-medium text-text-tertiary truncate'>{data.documentName}</div>
</div>
</div>
<div className='px-4 py-0.5 max-h-[450px] bg-white rounded-lg overflow-y-auto'>
<div className='px-4 py-0.5 max-h-[450px] bg-components-panel-bg rounded-lg overflow-y-auto'>
<div className='w-full'>
{
data.sources.map((source, index) => (
<Fragment key={index}>
<div className='group py-3'>
<div className='flex items-center justify-between mb-2'>
<div className='flex items-center px-1.5 h-5 border border-gray-200 rounded-md'>
<Hash02 className='mr-0.5 w-3 h-3 text-gray-400' />
<div className='text-[11px] font-medium text-gray-500'>
<div className='flex items-center px-1.5 h-5 border border-divider-subtle rounded-md'>
<Hash02 className='mr-0.5 w-3 h-3 text-text-quaternary' />
<div className='text-[11px] font-medium text-text-tertiary'>
{source.segment_position || index + 1}
</div>
</div>
@@ -77,17 +77,17 @@ const Popup: FC<PopupProps> = ({
showHitInfo && (
<Link
href={`/datasets/${source.dataset_id}/documents/${source.document_id}`}
className='hidden items-center h-[18px] text-xs text-primary-600 group-hover:flex'>
className='hidden items-center h-[18px] text-xs text-text-accent group-hover:flex'>
{t('common.chat.citation.linkToDataset')}
<ArrowUpRight className='ml-1 w-3 h-3' />
</Link>
)
}
</div>
<div className='text-[13px] text-gray-800 break-words'>{source.content}</div>
<div className='text-[13px] text-text-secondary break-words'>{source.content}</div>
{
showHitInfo && (
<div className='flex items-center mt-2 text-xs font-medium text-gray-500 flex-wrap'>
<div className='flex items-center mt-2 system-xs-medium text-text-quaternary flex-wrap'>
<Tooltip
text={t('common.chat.citation.characters')}
data={source.word_count}
@@ -114,7 +114,7 @@ const Popup: FC<PopupProps> = ({
</div>
{
index !== data.sources.length - 1 && (
<div className='my-1 h-[1px] bg-black/5' />
<div className='my-1 h-[1px] bg-divider-regular' />
)
}
</Fragment>

View File

@@ -28,14 +28,14 @@ const ProgressTooltip: FC<ProgressTooltipProps> = ({
onMouseLeave={() => setOpen(false)}
>
<div className='grow flex items-center'>
<div className='mr-1 w-16 h-1.5 rounded-[3px] border border-gray-400 overflow-hidden'>
<div className='bg-gray-400 h-full' style={{ width: `${data * 100}%` }}></div>
<div className='mr-1 w-16 h-1.5 rounded-[3px] border border-components-progress-gray-border overflow-hidden'>
<div className='bg-components-progress-gray-progress h-full' style={{ width: `${data * 100}%` }}></div>
</div>
{data}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{ zIndex: 1001 }}>
<div className='p-3 bg-white text-xs font-medium text-gray-500 rounded-lg shadow-lg'>
<div className='p-3 bg-components-tooltip-bg system-xs-medium text-text-quaternary rounded-lg shadow-lg'>
{t('common.chat.citation.hitScore')} {data}
</div>
</PortalToFollowElemContent>

View File

@@ -35,7 +35,7 @@ const Tooltip: FC<TooltipProps> = ({
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{ zIndex: 1001 }}>
<div className='p-3 bg-white text-xs font-medium text-gray-500 rounded-lg shadow-lg'>
<div className='p-3 bg-components-tooltip-bg system-xs-medium text-text-quaternary rounded-lg shadow-lg'>
{text} {data}
</div>
</PortalToFollowElemContent>

View File

@@ -397,6 +397,7 @@ export const useChat = (
)
setSuggestQuestions(data)
}
// eslint-disable-next-line unused-imports/no-unused-vars
catch (e) {
setSuggestQuestions([])
}
@@ -555,7 +556,7 @@ export const useChat = (
if (!item.execution_metadata?.parallel_id)
return item.node_id === nodeFinishedData.node_id
return item.node_id === nodeFinishedData.node_id && (item.execution_metadata?.parallel_id === nodeFinishedData.execution_metadata.parallel_id)
return item.node_id === nodeFinishedData.node_id && (item.execution_metadata?.parallel_id === nodeFinishedData.execution_metadata?.parallel_id)
})
responseItem.workflowProcess!.tracing[currentIndex] = nodeFinishedData as any

View File

@@ -70,6 +70,8 @@ export type ChatProps = {
showFileUpload?: boolean
onFeatureBarClick?: (state: boolean) => void
noSpacing?: boolean
inputDisabled?: boolean
isMobile?: boolean
}
const Chat: FC<ChatProps> = ({
@@ -106,6 +108,8 @@ const Chat: FC<ChatProps> = ({
showFileUpload,
onFeatureBarClick,
noSpacing,
inputDisabled,
isMobile,
}) => {
const { t } = useTranslation()
const { currentLogItem, setCurrentLogItem, showPromptLogModal, setShowPromptLogModal, showAgentLogModal, setShowAgentLogModal } = useAppStore(useShallow(state => ({
@@ -273,12 +277,14 @@ const Chat: FC<ChatProps> = ({
<TryToAsk
suggestedQuestions={suggestedQuestions}
onSend={onSend}
isMobile={isMobile}
/>
)
}
{
!noChatInput && (
<ChatInputArea
disabled={inputDisabled}
showFeatureBar={showFeatureBar}
showFileUpload={showFileUpload}
featureBarDisabled={isResponding}

View File

@@ -1,8 +1,8 @@
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { File02 } from '@/app/components/base/icons/src/vender/line/files'
import { RiFileList3Line } from '@remixicon/react'
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import { useStore as useAppStore } from '@/app/components/app/store'
import ActionButton from '@/app/components/base/action-button'
type LogProps = {
logItem: IChatItem
@@ -10,7 +10,6 @@ type LogProps = {
const Log: FC<LogProps> = ({
logItem,
}) => {
const { t } = useTranslation()
const setCurrentLogItem = useAppStore(s => s.setCurrentLogItem)
const setShowPromptLogModal = useAppStore(s => s.setShowPromptLogModal)
const setShowAgentLogModal = useAppStore(s => s.setShowAgentLogModal)
@@ -20,7 +19,7 @@ const Log: FC<LogProps> = ({
return (
<div
className='shrink-0 p-1 flex items-center justify-center rounded-[6px] font-medium text-gray-500 hover:bg-gray-50 cursor-pointer hover:text-gray-700'
className='ml-1 flex items-center gap-0.5 p-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg shadow-md backdrop-blur-sm'
onClick={(e) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
@@ -33,8 +32,9 @@ const Log: FC<LogProps> = ({
setShowPromptLogModal(true)
}}
>
<File02 className='mr-1 w-4 h-4' />
<div className='text-xs leading-4'>{runID ? t('appLog.viewLog') : isAgent ? t('appLog.agentLog') : t('appLog.promptLog')}</div>
<ActionButton>
<RiFileList3Line className='w-4 h-4' />
</ActionButton>
</div>
)
}

View File

@@ -2,46 +2,37 @@ import type { FC } from 'react'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import type { OnSend } from '../types'
import { Star04 } from '@/app/components/base/icons/src/vender/solid/shapes'
import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider'
import cn from '@/utils/classnames'
type TryToAskProps = {
suggestedQuestions: string[]
onSend: OnSend
isMobile?: boolean
}
const TryToAsk: FC<TryToAskProps> = ({
suggestedQuestions,
onSend,
isMobile,
}) => {
const { t } = useTranslation()
return (
<div>
<div className='flex items-center mb-2.5 py-2'>
<div
className='grow h-[1px]'
style={{
background: 'linear-gradient(270deg, #F3F4F6 0%, rgba(243, 244, 246, 0) 100%)',
}}
/>
<div className='shrink-0 flex items-center px-3 text-gray-500'>
<Star04 className='mr-1 w-2.5 h-2.5' />
<span className='text-xs text-gray-500 font-medium'>{t('appDebug.feature.suggestedQuestionsAfterAnswer.tryToAsk')}</span>
</div>
<div
className='grow h-[1px]'
style={{
background: 'linear-gradient(270deg, rgba(243, 244, 246, 0) 0%, #F3F4F6 100%)',
}}
/>
<div className='mb-2 py-2'>
<div className={cn('flex items-center justify-between gap-2 mb-2.5', isMobile && 'justify-end')}>
<Divider bgStyle='gradient' className='grow h-px rotate-180' />
<div className='shrink-0 text-text-tertiary system-xs-medium-uppercase'>{t('appDebug.feature.suggestedQuestionsAfterAnswer.tryToAsk')}</div>
{!isMobile && <Divider bgStyle='gradient' className='grow h-px' />}
</div>
<div className='flex flex-wrap justify-center'>
<div className={cn('flex flex-wrap justify-center', isMobile && 'justify-end')}>
{
suggestedQuestions.map((suggestQuestion, index) => (
<Button
size='small'
key={index}
variant='secondary-accent'
className='mb-2 mr-2 last:mr-0'
className='mb-1 mr-1 last:mr-0'
onClick={() => onSend(suggestQuestion)}
>
{suggestQuestion}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import Chat from '../chat'
import type {
ChatConfig,
@@ -9,16 +9,19 @@ import type {
import { useChat } from '../chat/hooks'
import { getLastAnswer, isValidGeneratedAnswer } from '../utils'
import { useEmbeddedChatbotContext } from './context'
import ConfigPanel from './config-panel'
import { isDify } from './utils'
import cn from '@/utils/classnames'
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 cn from '@/utils/classnames'
const ChatWrapper = () => {
const {
@@ -29,6 +32,7 @@ const ChatWrapper = () => {
currentConversationItem,
inputsForms,
newConversationInputs,
newConversationInputsRef,
handleNewConversationCompleted,
isMobile,
isInstalledApp,
@@ -67,6 +71,38 @@ const ChatWrapper = () => {
appPrevChatList,
taskId => stopChatMessageResponding('', taskId, isInstalledApp, appId),
)
const inputsFormValue = currentConversationId ? currentConversationItem?.inputs : newConversationInputsRef?.current
const inputDisabled = useMemo(() => {
let hasEmptyInput = ''
let fileIsUploading = false
const requiredVars = inputsForms.filter(({ required }) => required)
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])
useEffect(() => {
if (currentChatInstanceRef.current)
@@ -108,26 +144,48 @@ const ChatWrapper = () => {
doSend(question.content, question.message_files, true, isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null)
}, [chatList, doSend])
const chatNode = useMemo(() => {
if (inputsForms.length) {
return (
<>
{!currentConversationId && (
<div className={cn('mx-auto w-full max-w-full 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>
)}
</>
)
}
const messageList = useMemo(() => {
if (currentConversationId)
return chatList
return chatList.filter(item => !item.isOpeningStatement)
}, [chatList, currentConversationId])
return null
}, [currentConversationId, inputsForms, isMobile])
const [collapsed, setCollapsed] = useState(!!currentConversationId)
const chatNode = useMemo(() => {
if (!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])
const welcome = useMemo(() => {
const welcomeMessage = chatList.find(item => item.isOpeningStatement)
if (currentConversationId)
return null
if (!welcomeMessage)
return null
if (!collapsed && inputsForms.length > 0)
return null
return (
<div className={cn('h-[50vh] py-12 flex flex-col items-center justify-center gap-3')}>
<AppIcon
size='xl'
iconType={appData?.site.icon_type}
icon={appData?.site.icon}
background={appData?.site.icon_background}
imageUrl={appData?.site.icon_url}
/>
<div className='text-text-tertiary body-2xl-regular'>{welcomeMessage.content}</div>
</div>
)
}, [appData?.site.icon, appData?.site.icon_background, appData?.site.icon_type, appData?.site.icon_url, chatList, collapsed, currentConversationId, inputsForms.length])
const answerIcon = isDify()
? <LogoAvatar className='relative shrink-0' />
@@ -144,17 +202,22 @@ const ChatWrapper = () => {
<Chat
appData={appData}
config={appConfig}
chatList={chatList}
chatList={messageList}
isResponding={isResponding}
chatContainerInnerClassName={cn('mx-auto w-full max-w-full tablet:px-4', isMobile && 'px-4')}
chatFooterClassName='pb-4'
chatFooterInnerClassName={cn('mx-auto w-full max-w-full tablet:px-4', isMobile && 'px-4')}
chatFooterClassName={cn('pb-4', !isMobile && 'rounded-b-2xl')}
chatFooterInnerClassName={cn('mx-auto w-full max-w-full tablet:px-4', isMobile && 'px-2')}
onSend={doSend}
inputs={currentConversationId ? currentConversationItem?.inputs as any : newConversationInputs}
inputsForm={inputsForms}
onRegenerate={doRegenerate}
onStopResponding={handleStop}
chatNode={chatNode}
chatNode={
<>
{chatNode}
{welcome}
</>
}
allToolIcons={appMeta?.tool_icons || {}}
onFeedback={handleFeedback}
suggestedQuestions={suggestedQuestions}
@@ -162,6 +225,8 @@ const ChatWrapper = () => {
hideProcessDetail
themeBuilder={themeBuilder}
switchSibling={siblingMessageId => setTargetMessageId(siblingMessageId)}
inputDisabled={inputDisabled}
isMobile={isMobile}
/>
)
}

View File

@@ -1,47 +0,0 @@
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { memo } from 'react'
import Textarea from '@/app/components/base/textarea'
interface 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='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)

View File

@@ -1,129 +0,0 @@
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'
import { InputVarType } from '@/app/components/workflow/types'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
const Form = () => {
const { t } = useTranslation()
const {
appParams,
inputsForms,
newConversationInputs,
newConversationInputsRef,
handleNewConversationInputsChange,
isMobile,
} = useEmbeddedChatbotContext()
const handleFormChange = useCallback((variable: string, value: any) => {
handleNewConversationInputsChange({
...newConversationInputsRef.current,
[variable]: value,
})
}, [newConversationInputsRef, 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')})` : ''}`}
/>
)
}
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')})` : ''}`}
/>
)
}
if (form.type === InputVarType.singleFile) {
return (
<FileUploaderInAttachmentWrapper
value={newConversationInputs[variable] ? [newConversationInputs[variable]] : []}
onChange={files => handleFormChange(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,
}}
/>
)
}
if (form.type === InputVarType.multiFiles) {
return (
<FileUploaderInAttachmentWrapper
value={newConversationInputs[variable]}
onChange={files => handleFormChange(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,
}}
/>
)
}
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

View File

@@ -1,180 +0,0 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useEmbeddedChatbotContext } from '../context'
import { useThemeContext } from '../theme/theme-context'
import { CssTransform } from '../theme/utils'
import Form from './form'
import cn from '@/utils/classnames'
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 LogoSite from '@/app/components/base/logo/logo-site'
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
const themeBuilder = useThemeContext()
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
style={CssTransform(themeBuilder.theme?.roundedBackgroundColorStyle ?? '')}
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
iconType={appData?.site.icon_type}
icon={appData?.site.icon}
imageUrl={appData?.site.icon_url}
background='transparent'
size='small'
className="mr-2"
/>
{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
styleCss={CssTransform(themeBuilder.theme?.backgroundButtonDefaultColorStyle ?? '')}
variant='secondary-accent'
size='small'
className='shrink-0 text-white'
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
styleCss={CssTransform(themeBuilder.theme?.backgroundButtonDefaultColorStyle ?? '')}
variant='primary'
className='mr-2'
onClick={() => {
setCollapsed(true)
handleStartChat()
}}
>
{t('common.operation.save')}
</Button>
<Button
onClick={() => setCollapsed(true)}
>
{t('common.operation.cancel')}
</Button>
</div>
</div>
)
}
{
showConfigPanelBeforeChat && (
<div className='p-6 rounded-b-xl'>
<Form />
<Button
styleCss={CssTransform(themeBuilder.theme?.backgroundButtonDefaultColorStyle ?? '')}
className={cn(inputsForms.length && !isMobile && 'ml-[136px]')}
variant='primary'
size='large'
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.poweredBy')}</span>
{
customConfig?.replace_webapp_logo
? <img src={customConfig?.replace_webapp_logo} alt='logo' className='block w-auto h-5' />
: <LogoSite className='!h-5' />
}
</div>
</div>
)
}
</div>
)
}
</div>
)
}
export default ConfigPanel

View File

@@ -15,7 +15,7 @@ import type {
ConversationItem,
} from '@/models/share'
export interface EmbeddedChatbotContextValue {
export type EmbeddedChatbotContextValue = {
appInfoError?: any
appInfoLoading?: boolean
appMeta?: AppMeta
@@ -27,13 +27,12 @@ export interface EmbeddedChatbotContextValue {
appPrevChatList: ChatItem[]
pinnedConversationList: AppConversationData['data']
conversationList: AppConversationData['data']
showConfigPanelBeforeChat: boolean
newConversationInputs: Record<string, any>
newConversationInputsRef: RefObject<Record<string, any>>
handleNewConversationInputsChange: (v: Record<string, any>) => void
inputsForms: any[]
handleNewConversation: () => void
handleStartChat: () => void
handleStartChat: (callback?: any) => void
handleChangeConversation: (conversationId: string) => void
handleNewConversationCompleted: (newConversationId: string) => void
chatShouldReloadKey: string
@@ -50,7 +49,6 @@ export const EmbeddedChatbotContext = createContext<EmbeddedChatbotContextValue>
appPrevChatList: [],
pinnedConversationList: [],
conversationList: [],
showConfigPanelBeforeChat: false,
newConversationInputs: {},
newConversationInputsRef: { current: {} },
handleNewConversationInputsChange: () => {},

View File

@@ -1,56 +0,0 @@
import type { FC } from 'react'
import React from 'react'
import { RiRefreshLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import type { Theme } from './theme/theme-context'
import { CssTransform } from './theme/utils'
import Tooltip from '@/app/components/base/tooltip'
export type IHeaderProps = {
isMobile?: boolean
customerIcon?: React.ReactNode
title: string
theme?: Theme
onCreateNewChat?: () => void
}
const Header: FC<IHeaderProps> = ({
isMobile,
customerIcon,
title,
theme,
onCreateNewChat,
}) => {
const { t } = useTranslation()
if (!isMobile)
return null
return (
<div
className={`
shrink-0 flex items-center justify-between h-14 px-4
`}
style={Object.assign({}, CssTransform(theme?.backgroundHeaderColorStyle ?? ''), CssTransform(theme?.headerBorderBottomStyle ?? '')) }
>
<div className="flex items-center space-x-2">
{customerIcon}
<div
className={'text-sm font-bold text-white'}
style={CssTransform(theme?.colorFontOnHeaderStyle ?? '')}
>
{title}
</div>
</div>
<Tooltip
popupContent={t('share.chat.resetChat')}
>
<div className='flex cursor-pointer hover:rounded-lg hover:bg-black/5 w-8 h-8 items-center justify-center' onClick={() => {
onCreateNewChat?.()
}}>
<RiRefreshLine className="h-4 w-4 text-sm font-bold text-white" color={theme?.colorPathOnHeader}/>
</div>
</Tooltip>
</div>
)
}
export default React.memo(Header)

View File

@@ -0,0 +1,109 @@
import type { FC } from 'react'
import React from 'react'
import { 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 LogoSite from '@/app/components/base/logo/logo-site'
import cn from '@/utils/classnames'
export type IHeaderProps = {
isMobile?: boolean
customerIcon?: React.ReactNode
title: string
theme?: Theme
onCreateNewChat?: () => void
}
const Header: FC<IHeaderProps> = ({
isMobile,
customerIcon,
title,
theme,
onCreateNewChat,
}) => {
const { t } = useTranslation()
const {
appData,
currentConversationId,
inputsForms,
} = useEmbeddedChatbotContext()
if (!isMobile) {
return (
<div className='shrink-0 h-14 p-3 flex items-center justify-end'>
<div className='flex items-center gap-1'>
{/* powered by */}
<div className='shrink-0'>
{!appData?.custom_config?.remove_webapp_brand && (
<div className={cn(
'shrink-0 px-2 flex items-center gap-1.5',
)}>
<div className='text-text-tertiary system-2xs-medium-uppercase'>{t('share.chat.poweredBy')}</div>
{appData?.custom_config?.replace_webapp_logo && (
<img src={appData?.custom_config?.replace_webapp_logo} alt='logo' className='block w-auto h-5' />
)}
{!appData?.custom_config?.replace_webapp_logo && (
<LogoSite className='!h-5' />
)}
</div>
)}
</div>
{currentConversationId && (
<Divider type='vertical' className='h-3.5' />
)}
{currentConversationId && (
<Tooltip
popupContent={t('share.chat.resetChat')}
>
<ActionButton size='l' onClick={onCreateNewChat}>
<RiResetLeftLine className='w-[18px] h-[18px]' />
</ActionButton>
</Tooltip>
)}
{currentConversationId && inputsForms.length > 0 && (
<ViewFormDropdown />
)}
</div>
</div>
)
}
return (
<div
className={cn('shrink-0 flex items-center justify-between h-14 px-3 rounded-t-2xl')}
style={Object.assign({}, CssTransform(theme?.backgroundHeaderColorStyle ?? ''), CssTransform(theme?.headerBorderBottomStyle ?? '')) }
>
<div className="grow flex 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'>
{currentConversationId && (
<Tooltip
popupContent={t('share.chat.resetChat')}
>
<ActionButton size='l' onClick={onCreateNewChat}>
<RiResetLeftLine className={cn('w-[18px] h-[18px]', theme?.colorPathOnHeader)} />
</ActionButton>
</Tooltip>
)}
{currentConversationId && inputsForms.length > 0 && (
<ViewFormDropdown iconColor={theme?.colorPathOnHeader} />
)}
</div>
</div>
)
}
export default React.memo(Header)

View File

@@ -88,7 +88,6 @@ export const useEmbeddedChatbot = () => {
})
}
}, [appId, conversationIdInfo, setConversationIdInfo])
const [showConfigPanelBeforeChat, setShowConfigPanelBeforeChat] = useState(true)
const [newConversationId, setNewConversationId] = useState('')
const chatShouldReloadKey = useMemo(() => {
@@ -273,23 +272,18 @@ export const useEmbeddedChatbot = () => {
return true
}, [inputsForms, notify, t])
const handleStartChat = useCallback(() => {
const handleStartChat = useCallback((callback?: any) => {
if (checkInputsRequired()) {
setShowConfigPanelBeforeChat(false)
setShowNewConversationItemInList(true)
callback?.()
}
}, [setShowConfigPanelBeforeChat, setShowNewConversationItemInList, checkInputsRequired])
}, [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])
}, [handleConversationIdInfoChange])
const handleNewConversation = useCallback(() => {
currentChatInstanceRef.current.handleStop()
setNewConversationId('')
@@ -299,11 +293,10 @@ export const useEmbeddedChatbot = () => {
}
else if (currentConversationId) {
handleConversationIdInfoChange('')
setShowConfigPanelBeforeChat(true)
setShowNewConversationItemInList(true)
handleNewConversationInputsChange({})
}
}, [handleChangeConversation, currentConversationId, handleConversationIdInfoChange, setShowConfigPanelBeforeChat, setShowNewConversationItemInList, showNewConversationItemInList, handleNewConversationInputsChange])
}, [handleChangeConversation, currentConversationId, handleConversationIdInfoChange, setShowNewConversationItemInList, showNewConversationItemInList, handleNewConversationInputsChange])
const handleNewConversationCompleted = useCallback((newConversationId: string) => {
setNewConversationId(newConversationId)
@@ -336,8 +329,6 @@ export const useEmbeddedChatbot = () => {
appPrevChatList,
pinnedConversationList,
conversationList,
showConfigPanelBeforeChat,
setShowConfigPanelBeforeChat,
setShowNewConversationItemInList,
newConversationInputs,
newConversationInputsRef,

View File

@@ -4,7 +4,6 @@ import {
} from 'react'
import { useAsyncEffect } from 'ahooks'
import { useTranslation } from 'react-i18next'
import { RiLoopLeftLine } from '@remixicon/react'
import {
EmbeddedChatbotContext,
useEmbeddedChatbotContext,
@@ -12,32 +11,30 @@ import {
import { useEmbeddedChatbot } from './hooks'
import { isDify } from './utils'
import { useThemeContext } from './theme/theme-context'
import cn from '@/utils/classnames'
import { CssTransform } from './theme/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-embedded-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'
import Tooltip from '@/app/components/base/tooltip'
import LogoSite from '@/app/components/base/logo/logo-site'
import cn from '@/utils/classnames'
const Chatbot = () => {
const { t } = useTranslation()
const {
isMobile,
appInfoError,
appInfoLoading,
appData,
appPrevChatList,
showConfigPanelBeforeChat,
appChatListDataLoading,
chatShouldReloadKey,
handleNewConversation,
themeBuilder,
} = useEmbeddedChatbotContext()
const { t } = useTranslation()
const chatReady = (!showConfigPanelBeforeChat || !!appPrevChatList.length)
const customConfig = appData?.custom_config
const site = appData?.site
@@ -55,52 +52,76 @@ const Chatbot = () => {
if (appInfoLoading) {
return (
<Loading type='app' />
<>
{!isMobile && <Loading type='app' />}
{isMobile && (
<div className={cn('relative')}>
<div className={cn('flex flex-col h-[calc(100vh_-_60px)] border-[0.5px] border-components-panel-border rounded-2xl shadow-xs')}>
<Loading type='app' />
</div>
</div>
)}
</>
)
}
if (appInfoError) {
return (
<AppUnavailable />
<>
{!isMobile && <AppUnavailable />}
{isMobile && (
<div className={cn('relative')}>
<div className={cn('flex flex-col h-[calc(100vh_-_60px)] border-[0.5px] border-components-panel-border rounded-2xl shadow-xs')}>
<AppUnavailable />
</div>
</div>
)}
</>
)
}
return (
<div>
<Header
isMobile={isMobile}
title={site?.title || ''}
customerIcon={isDify() ? difyIcon : ''}
theme={themeBuilder?.theme}
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 && (
<div className='relative'>
<div
className={cn(
'flex flex-col border border-components-panel-border-subtle rounded-2xl',
isMobile ? 'h-[calc(100vh_-_60px)] border-[0.5px] border-components-panel-border shadow-xs' : 'h-[100vh] bg-chatbot-bg',
)}
style={isMobile ? Object.assign({}, CssTransform(themeBuilder?.theme?.backgroundHeaderColorStyle ?? '')) : {}}
>
<Header
isMobile={isMobile}
title={site?.title || ''}
customerIcon={isDify() ? difyIcon : ''}
theme={themeBuilder?.theme}
onCreateNewChat={handleNewConversation}
/>
<div className={cn('grow flex flex-col overflow-y-auto', isMobile && '!h-[calc(100vh_-_3rem)] bg-chatbot-bg rounded-2xl')}>
{appChatListDataLoading && (
<Loading type='app' />
)}
{chatReady && !appChatListDataLoading && (
<div className='relative h-full pt-8 mx-auto w-full max-w-[720px]'>
{!isMobile && (
<div className='absolute top-2.5 right-3 z-20'>
<Tooltip
popupContent={t('share.chat.resetChat')}
>
<div className='p-1.5 bg-white border-[0.5px] border-gray-100 rounded-lg shadow-md cursor-pointer' onClick={handleNewConversation}>
<RiLoopLeftLine className="h-4 w-4 text-gray-500"/>
</div>
</Tooltip>
</div>
)}
<ChatWrapper />
</div>
{!appChatListDataLoading && (
<ChatWrapper key={chatShouldReloadKey} />
)}
</div>
</div>
{/* powered by */}
{isMobile && (
<div className='shrink-0 h-[60px] pl-2 flex items-center'>
{!appData?.custom_config?.remove_webapp_brand && (
<div className={cn(
'shrink-0 px-2 flex items-center gap-1.5',
)}>
<div className='text-text-tertiary system-2xs-medium-uppercase'>{t('share.chat.poweredBy')}</div>
{appData?.custom_config?.replace_webapp_logo && (
<img src={appData?.custom_config?.replace_webapp_logo} alt='logo' className='block w-auto h-5' />
)}
{!appData?.custom_config?.replace_webapp_logo && (
<LogoSite className='!h-5' />
)}
</div>
)}
</div>
)}
</div>
)
}
@@ -122,7 +143,6 @@ const EmbeddedChatbotWrapper = () => {
appPrevChatList,
pinnedConversationList,
conversationList,
showConfigPanelBeforeChat,
newConversationInputs,
newConversationInputsRef,
handleNewConversationInputsChange,
@@ -150,7 +170,6 @@ const EmbeddedChatbotWrapper = () => {
appPrevChatList,
pinnedConversationList,
conversationList,
showConfigPanelBeforeChat,
newConversationInputs,
newConversationInputsRef,
handleNewConversationInputsChange,

View File

@@ -0,0 +1,118 @@
import React, { 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'
type Props = {
showTip?: boolean
}
const InputsFormContent = ({ showTip }: Props) => {
const { t } = useTranslation()
const {
appParams,
inputsForms,
currentConversationId,
currentConversationItem,
newConversationInputs,
newConversationInputsRef,
handleNewConversationInputsChange,
} = useEmbeddedChatbotContext()
const inputsFormValue = currentConversationId ? currentConversationItem?.inputs : newConversationInputs
const readonly = !!currentConversationId
const handleFormChange = useCallback((variable: string, value: any) => {
handleNewConversationInputsChange({
...newConversationInputsRef.current,
[variable]: value,
})
}, [newConversationInputsRef, handleNewConversationInputsChange])
return (
<div className='space-y-4'>
{inputsForms.map(form => (
<div key={form.variable} className='space-y-1'>
<div className='h-6 flex items-center gap-1'>
<div className='text-text-secondary system-md-semibold'>{form.label}</div>
{!form.required && (
<div className='text-text-tertiary system-xs-regular'>{t('appDebug.variableTable.optional')}</div>
)}
</div>
{form.type === InputVarType.textInput && (
<Input
value={inputsFormValue?.[form.variable] || ''}
onChange={e => handleFormChange(form.variable, e.target.value)}
placeholder={form.label}
readOnly={readonly}
disabled={readonly}
/>
)}
{form.type === InputVarType.number && (
<Input
type='number'
value={inputsFormValue?.[form.variable] || ''}
onChange={e => handleFormChange(form.variable, e.target.value)}
placeholder={form.label}
readOnly={readonly}
disabled={readonly}
/>
)}
{form.type === InputVarType.paragraph && (
<Textarea
value={inputsFormValue?.[form.variable] || ''}
onChange={e => handleFormChange(form.variable, e.target.value)}
placeholder={form.label}
readOnly={readonly}
disabled={readonly}
/>
)}
{form.type === InputVarType.select && (
<PortalSelect
popupClassName='w-[200px]'
value={inputsFormValue?.[form.variable]}
items={form.options.map((option: string) => ({ value: option, name: option }))}
onSelect={item => handleFormChange(form.variable, item.value as string)}
placeholder={form.label}
readonly={readonly}
/>
)}
{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,
}}
/>
)}
</div>
))}
{showTip && (
<div className='text-text-tertiary system-xs-regular'>{t('share.chat.chatFormTip')}</div>
)}
</div>
)
}
export default InputsFormContent

View File

@@ -0,0 +1,79 @@
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,
} = useEmbeddedChatbotContext()
return (
<div className={cn('mb-6 pt-6 px-4 flex flex-col items-center', isMobile && 'mb-4 pt-4')}>
<div className={cn(
'w-full max-w-[672px] bg-components-panel-bg rounded-2xl border-[0.5px] border-components-panel-border shadow-md',
collapsed && 'bg-components-card-bg border border-components-card-border shadow-none',
)}>
<div className={cn(
'flex items-center gap-3 px-6 py-4 rounded-t-2xl',
!collapsed && 'border-b border-divider-subtle',
isMobile && 'px-4 py-3',
)}>
<Message3Fill className='shrink-0 w-6 h-6' />
<div className='grow text-text-secondary system-xl-semibold'>{t('share.chat.chatSettingsTitle')}</div>
{collapsed && (
<Button className='text-text-tertiary uppercase' size='small' variant='ghost' onClick={() => setCollapsed(false)}>{currentConversationId ? t('common.operation.view') : t('common.operation.edit')}</Button>
)}
{!collapsed && currentConversationId && (
<Button className='text-text-tertiary uppercase' size='small' variant='ghost' onClick={() => setCollapsed(true)}>{t('common.operation.close')}</Button>
)}
</div>
{!collapsed && (
<div className={cn('p-6', isMobile && 'p-4')}>
<InputsFormContent showTip={!!currentConversationId} />
</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='py-4 flex items-center w-full max-w-[720px]'>
<Divider bgStyle='gradient' className='basis-1/2 h-px rotate-180' />
<Divider bgStyle='gradient' className='basis-1/2 h-px' />
</div>
)}
</div>
)
}
export default InputsFormNode

View File

@@ -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('w-[18px] h-[18px]', iconColor)} />
</ActionButton>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-50">
<div className='w-[400px] bg-components-panel-bg backdrop-blur-sm rounded-2xl border-[0.5px] border-components-panel-border shadow-lg'>
<div className='flex items-center gap-3 px-6 py-4 rounded-t-2xl border-b border-divider-subtle'>
<Message3Fill className='shrink-0 w-6 h-6' />
<div className='grow text-text-secondary system-xl-semibold'>{t('share.chat.chatSettingsTitle')}</div>
</div>
<div className='p-6'>
<InputsFormContent showTip />
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default ViewFormDropdown

View File

@@ -9,7 +9,7 @@ export class Theme {
public backgroundHeaderColorStyle = 'backgroundImage: linear-gradient(to right, #2563eb, #0ea5e9)'
public headerBorderBottomStyle = ''
public colorFontOnHeaderStyle = 'color: white'
public colorPathOnHeader = 'white'
public colorPathOnHeader = 'text-text-primary-on-surface'
public backgroundButtonDefaultColorStyle = 'backgroundColor: #1C64F2'
public roundedBackgroundColorStyle = 'backgroundColor: rgb(245 248 255)'
public chatBubbleColorStyle = 'backgroundColor: rgb(225 239 254)'