Initial commit

This commit is contained in:
饺子大魔王
2025-04-11 08:14:27 +08:00
commit 626af60456
1540 changed files with 110511 additions and 0 deletions
@@ -0,0 +1,182 @@
import { useCallback, useEffect, useMemo } from 'react'
import Chat from '../chat'
import type {
ChatConfig,
ChatItem,
ChatItemInTree,
OnSend,
} from '../types'
import { useChat } from '../chat/hooks'
import { getLastAnswer, isValidGeneratedAnswer } from '../utils'
import { useChatWithHistoryContext } from './context'
import Header from './header'
import ConfigPanel from './config-panel'
// TODO mars
import { fetchSuggestedQuestions, getUrl, stopChatMessageResponding } from '@/service/index'
import AnswerIcon from '@/app/components/base/answer-icon'
const ChatWrapper = () => {
const {
appParams,
appPrevChatTree,
currentConversationId,
currentConversationItem,
inputsForms,
newConversationInputs,
handleNewConversationCompleted,
isMobile,
isInstalledApp,
appId,
appMeta,
handleFeedback,
currentChatInstanceRef,
appData,
themeBuilder,
} = useChatWithHistoryContext()
const appConfig = useMemo(() => {
const config = appParams || {}
return {
...config,
file_upload: {
...(config as any).file_upload,
fileUploadConfig: (config as any).system_parameters,
},
supportFeedback: true,
opening_statement: currentConversationId ? currentConversationItem?.introduction : (config as any).opening_statement,
} as ChatConfig
}, [appParams, currentConversationItem?.introduction, currentConversationId])
const {
chatList,
setTargetMessageId,
handleSend,
handleStop,
isResponding,
suggestedQuestions,
} = useChat(
appConfig,
{
inputs: (currentConversationId ? currentConversationItem?.inputs : newConversationInputs) as any,
inputsForm: inputsForms,
},
appPrevChatTree,
taskId => stopChatMessageResponding(taskId),
)
useEffect(() => {
if (currentChatInstanceRef.current)
currentChatInstanceRef.current.handleStop = handleStop
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const doSend: OnSend = useCallback((message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => {
const data: any = {
query: message,
files,
inputs: currentConversationId ? currentConversationItem?.inputs : newConversationInputs,
conversation_id: currentConversationId,
parent_message_id: (isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || null,
}
handleSend(
'chat-messages',
data,
{
// onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId), // client 流式请求完成后基础请求异常
onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
isPublicAPI: !isInstalledApp,
},
)
}, [
chatList,
handleNewConversationCompleted,
handleSend,
currentConversationId,
currentConversationItem,
newConversationInputs,
isInstalledApp,
appId,
])
const doRegenerate = useCallback((chatItem: ChatItemInTree) => {
const question = chatList.find(item => item.id === chatItem.parentMessageId)!
const parentAnswer = chatList.find(item => item.id === question.parentMessageId)
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>
)
}
</>
)
}
return (
<Header
isMobile={isMobile}
title={currentConversationItem?.name || ''}
/>
)
}, [
currentConversationId,
inputsForms,
currentConversationItem,
isMobile,
])
const answerIcon = (appData?.site && appData.site.use_icon_as_answer_icon)
? <AnswerIcon
iconType={appData.site.icon_type}
icon={appData.site.icon}
background={appData.site.icon_background}
imageUrl={appData.site.icon_url}
/>
: null
return (
<div
className='h-full bg-chatbot-bg overflow-hidden'
>
<Chat
appData={appData}
config={appConfig}
chatList={chatList}
isResponding={isResponding}
chatContainerInnerClassName={`mx-auto pt-6 w-full max-w-[720px] ${isMobile && 'px-4'}`}
chatFooterClassName='pb-4'
chatFooterInnerClassName={`mx-auto w-full max-w-[720px] ${isMobile && 'px-4'}`}
onSend={doSend}
inputs={currentConversationId ? currentConversationItem?.inputs as any : newConversationInputs}
inputsForm={inputsForms}
onRegenerate={doRegenerate}
onStopResponding={handleStop}
chatNode={chatNode}
allToolIcons={appMeta?.tool_icons || {}}
onFeedback={handleFeedback}
suggestedQuestions={suggestedQuestions}
answerIcon={answerIcon}
hideProcessDetail
themeBuilder={themeBuilder}
switchSibling={siblingMessageId => setTargetMessageId(siblingMessageId)}
/>
</div>
)
}
export default ChatWrapper
@@ -0,0 +1,47 @@
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)
@@ -0,0 +1,117 @@
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
@@ -0,0 +1,172 @@
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
@@ -0,0 +1,79 @@
'use client'
import type { RefObject } from 'react'
import { createContext, useContext } from 'use-context-selector'
import type {
Callback,
ChatConfig,
ChatItemInTree,
Feedback,
} from '../types'
import type { ThemeBuilder } from '../chat/embedded-chatbot/theme/theme-context'
import type {
AppConversationData,
AppData,
AppMeta,
ConversationItem,
} from '@/models/share'
export type ChatWithHistoryContextValue = {
appInfoError?: any
appInfoLoading?: boolean
appMeta?: AppMeta
appData?: AppData
appParams?: ChatConfig
appChatListDataLoading?: boolean
currentConversationId: string
currentConversationItem?: ConversationItem
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
handleChangeConversation: (conversationId: string) => void
handlePinConversation: (conversationId: string) => void
handleUnpinConversation: (conversationId: string) => void
handleDeleteConversation: (conversationId: string, callback: Callback) => void
conversationRenaming: boolean
handleRenameConversation: (conversationId: string, newName: string, callback: Callback) => void
handleNewConversationCompleted: (newConversationId: string) => void
chatShouldReloadKey: string
isMobile: boolean
isInstalledApp: boolean
appId?: string
handleFeedback: (messageId: string, feedback: Feedback) => void
currentChatInstanceRef: RefObject<{ handleStop: () => void }>
themeBuilder?: ThemeBuilder
}
export const ChatWithHistoryContext = createContext<ChatWithHistoryContextValue>({
currentConversationId: '',
appPrevChatTree: [],
pinnedConversationList: [],
conversationList: [],
showConfigPanelBeforeChat: false,
newConversationInputs: {},
newConversationInputsRef: { current: {} },
handleNewConversationInputsChange: () => { },
inputsForms: [],
handleNewConversation: () => { },
handleStartChat: () => { },
handleChangeConversation: () => { },
handlePinConversation: () => { },
handleUnpinConversation: () => { },
handleDeleteConversation: () => { },
conversationRenaming: false,
handleRenameConversation: () => { },
handleNewConversationCompleted: () => { },
chatShouldReloadKey: '',
isMobile: false,
isInstalledApp: false,
handleFeedback: () => { },
currentChatInstanceRef: { current: { handleStop: () => { } } },
})
export const useChatWithHistoryContext = () => useContext(ChatWithHistoryContext)
@@ -0,0 +1,62 @@
import { useState } from 'react'
import { useChatWithHistoryContext } from './context'
import Sidebar from './sidebar'
import AppIcon from '@/app/components/base/app-icon'
import {
Edit05,
Menu01,
} from '@/app/components/base/icons/src/vender/line/general'
const HeaderInMobile = () => {
const {
appData,
handleNewConversation,
} = useChatWithHistoryContext()
const [showSidebar, setShowSidebar] = 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>
<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}
</div>
</div>
<div
className='shrink-0 flex items-center justify-center w-8 h-8 rounded-lg'
onClick={handleNewConversation}
>
<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>
</div>
)
}
</>
)
}
export default HeaderInMobile
@@ -0,0 +1,25 @@
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)
+462
View File
@@ -0,0 +1,462 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import { useLocalStorageState } from 'ahooks'
import { produce } from 'immer'
import type {
Callback,
ChatConfig,
ChatItem,
Feedback,
} from '../types'
import { CONVERSATION_ID_INFO } from '../constants'
import { buildChatItemTree } from '../utils'
import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils'
import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
// TODO mars
import {
delConversation,
fetchAppInfo,
fetchAppMeta,
fetchAppParams,
fetchChatList,
fetchConversations,
generationConversationName,
pinConversation,
renameConversation,
unpinConversation,
updateFeedback,
} from '@/service/index'
import type { InstalledApp } from '@/models/explore'
import type {
AppData,
ConversationItem,
} from '@/models/share'
import { useToastContext } from '@/app/components/base/toast'
import { changeLanguage } from '@/i18n/i18next-config'
import { useAppFavicon } from '@/hooks/use-app-favicon'
import { InputVarType } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
function getFormattedChatList(messages: any[]) {
const newChatList: ChatItem[] = []
messages.forEach((item) => {
const questionFiles = item.message_files?.filter((file: any) => file.belongs_to === 'user') || []
newChatList.push({
id: `question-${item.id}`,
content: item.query,
isAnswer: false,
message_files: getProcessedFilesFromResponse(questionFiles.map((item: any) => ({ ...item, related_id: item.id }))),
parentMessageId: item.parent_message_id || undefined,
})
const answerFiles = item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || []
if (answerFiles.length > 0 && questionFiles.length > 0)
// 获取本地文件名
answerFiles[0].filename = questionFiles[0]?.filename
newChatList.push({
id: item.id,
content: item.answer,
agent_thoughts: addFileInfos(item.agent_thoughts ? sortAgentSorts(item.agent_thoughts) : item.agent_thoughts, item.message_files),
feedback: item.feedback,
isAnswer: true,
citation: item.retriever_resources,
message_files: getProcessedFilesFromResponse(answerFiles.map((item: any) => ({ ...item, related_id: item.id }))),
parentMessageId: `question-${item.id}`,
})
})
return newChatList
}
export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
const isInstalledApp = useMemo(() => !!installedAppInfo, [installedAppInfo])
const { data: appInfo, isLoading: appInfoLoading, error: appInfoError } = useSWR(installedAppInfo ? null : 'appInfo', fetchAppInfo)
useAppFavicon({
enable: !installedAppInfo,
icon_type: appInfo?.site?.icon_type,
icon: appInfo?.site?.icon,
icon_background: appInfo?.site?.icon_background,
icon_url: appInfo?.site?.icon_url,
})
const appData = useMemo(() => {
if (isInstalledApp) {
const { id, app } = installedAppInfo!
return {
app_id: id,
site: {
title: app.name,
icon_type: app.icon_type,
icon: app.icon,
icon_background: app.icon_background,
icon_url: app.icon_url,
prompt_public: false,
copyright: '',
show_workflow_steps: true,
use_icon_as_answer_icon: app.use_icon_as_answer_icon,
},
plan: 'basic',
} as AppData
}
return appInfo
}, [isInstalledApp, installedAppInfo, appInfo])
const appId = useMemo(() => appData?.app_id, [appData])
useEffect(() => {
if (appData?.site?.default_language)
changeLanguage(appData.site?.default_language)
}, [appData])
const [conversationIdInfo, setConversationIdInfo] = useLocalStorageState<Record<string, string>>(CONVERSATION_ID_INFO, {
defaultValue: {},
})
const currentConversationId = useMemo(() => conversationIdInfo?.[appId || ''] || '', [appId, conversationIdInfo])
const handleConversationIdInfoChange = useCallback((changeConversationId: string) => {
if (appId) {
setConversationIdInfo({
...conversationIdInfo,
[appId || '']: changeConversationId,
})
}
}, [appId, conversationIdInfo, setConversationIdInfo])
const [showConfigPanelBeforeChat, setShowConfigPanelBeforeChat] = useState(true)
const [newConversationId, setNewConversationId] = useState('')
const chatShouldReloadKey = useMemo(() => {
if (currentConversationId === newConversationId)
return ''
return currentConversationId
}, [currentConversationId, newConversationId])
const { data: appParams } = useSWR(['appParams', isInstalledApp, appId], () => fetchAppParams())
const { data: appMeta } = useSWR(['appMeta', isInstalledApp, appId], () => fetchAppMeta())
const { data: appPinnedConversationData, mutate: mutateAppPinnedConversationData } = useSWR(['appConversationData', isInstalledApp, appId, true], () => fetchConversations())
const { data: appConversationData, isLoading: appConversationDataLoading, mutate: mutateAppConversationData } = useSWR(['appConversationData', isInstalledApp, appId, false], () => fetchConversations())
const { data: appChatListData, isLoading: appChatListDataLoading } = useSWR(chatShouldReloadKey ? ['appChatList', chatShouldReloadKey, isInstalledApp, appId] : null, () => fetchChatList(chatShouldReloadKey))
const appPrevChatTree = useMemo(
() => (currentConversationId && appChatListData?.data.length)
? buildChatItemTree(getFormattedChatList(appChatListData.data))
: [],
[appChatListData, currentConversationId],
)
const [showNewConversationItemInList, setShowNewConversationItemInList] = useState(false)
const pinnedConversationList = useMemo(() => {
return appPinnedConversationData?.data || []
}, [appPinnedConversationData])
const { t } = useTranslation()
const newConversationInputsRef = useRef<Record<string, any>>({})
const [newConversationInputs, setNewConversationInputs] = useState<Record<string, any>>({})
const handleNewConversationInputsChange = useCallback((newInputs: Record<string, any>) => {
newConversationInputsRef.current = newInputs
setNewConversationInputs(newInputs)
}, [])
const inputsForms = useMemo(() => {
return (appParams?.user_input_form || []).filter((item: any) => !item.external_data_tool).map((item: any) => {
if (item.paragraph) {
return {
...item.paragraph,
type: 'paragraph',
}
}
if (item.number) {
return {
...item.number,
type: 'number',
}
}
if (item.select) {
return {
...item.select,
type: 'select',
}
}
if (item['file-list']) {
return {
...item['file-list'],
type: 'file-list',
}
}
if (item.file) {
return {
...item.file,
type: 'file',
}
}
return {
...item['text-input'],
type: 'text-input',
}
})
}, [appParams])
useEffect(() => {
const conversationInputs: Record<string, any> = {}
inputsForms.forEach((item: any) => {
conversationInputs[item.variable] = item.default || null
})
handleNewConversationInputsChange(conversationInputs)
}, [handleNewConversationInputsChange, inputsForms])
const { data: newConversation } = useSWR(newConversationId ? [isInstalledApp, appId, newConversationId] : null, () => generationConversationName(newConversationId))
const [originConversationList, setOriginConversationList] = useState<ConversationItem[]>([])
useEffect(() => {
if (appConversationData?.data && !appConversationDataLoading)
setOriginConversationList(appConversationData?.data)
}, [appConversationData, appConversationDataLoading])
const conversationList = useMemo(() => {
const data = originConversationList.slice()
if (showNewConversationItemInList && data[0]?.id !== '') {
data.unshift({
id: '',
name: t('share.chat.newChatDefaultName'),
inputs: {},
introduction: '',
})
}
return data
}, [originConversationList, showNewConversationItemInList, t])
useEffect(() => {
if (newConversation) {
setOriginConversationList(produce((draft) => {
const index = draft.findIndex(item => item.id === newConversation.id)
if (index > -1)
draft[index] = newConversation
else
draft.unshift(newConversation)
}))
}
}, [newConversation])
const currentConversationItem = useMemo(() => {
let conversationItem = conversationList.find(item => item.id === currentConversationId)
if (!conversationItem && pinnedConversationList.length)
conversationItem = pinnedConversationList.find(item => item.id === currentConversationId)
return conversationItem
}, [conversationList, currentConversationId, pinnedConversationList])
const { notify } = useToastContext()
const checkInputsRequired = useCallback((silent?: boolean) => {
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 (!newConversationInputsRef.current[variable] && !silent)
hasEmptyInput = label as string
if ((type === InputVarType.singleFile || type === InputVarType.multiFiles) && newConversationInputsRef.current[variable] && !silent) {
const files = newConversationInputsRef.current[variable]
if (Array.isArray(files))
fileIsUploading = files.find(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId)
else
fileIsUploading = files.transferMethod === TransferMethod.local_file && !files.uploadedId
}
})
}
if (hasEmptyInput) {
notify({ type: 'error', message: t('appDebug.errorMessage.valueOfVarRequired', { key: hasEmptyInput }) })
return false
}
if (fileIsUploading) {
notify({ type: 'info', message: t('appDebug.errorMessage.waitForFileUpload') })
return
}
return true
}, [inputsForms, notify, t])
const handleStartChat = useCallback(() => {
if (checkInputsRequired()) {
setShowConfigPanelBeforeChat(false)
setShowNewConversationItemInList(true)
}
}, [setShowConfigPanelBeforeChat, setShowNewConversationItemInList, checkInputsRequired])
const currentChatInstanceRef = useRef<{ handleStop: () => void }>({ handleStop: () => { } })
const handleChangeConversation = useCallback((conversationId: string) => {
currentChatInstanceRef.current.handleStop()
setNewConversationId('')
handleConversationIdInfoChange(conversationId)
if (conversationId === '' && !checkInputsRequired(true))
setShowConfigPanelBeforeChat(true)
else
setShowConfigPanelBeforeChat(false)
}, [handleConversationIdInfoChange, setShowConfigPanelBeforeChat, checkInputsRequired])
const handleNewConversation = useCallback(() => {
currentChatInstanceRef.current.handleStop()
setNewConversationId('')
if (showNewConversationItemInList) {
handleChangeConversation('')
}
else if (currentConversationId) {
handleConversationIdInfoChange('')
setShowConfigPanelBeforeChat(true)
setShowNewConversationItemInList(true)
handleNewConversationInputsChange({})
}
}, [handleChangeConversation, currentConversationId, handleConversationIdInfoChange, setShowConfigPanelBeforeChat, setShowNewConversationItemInList, showNewConversationItemInList, handleNewConversationInputsChange])
const handleUpdateConversationList = useCallback(() => {
mutateAppConversationData()
mutateAppPinnedConversationData()
}, [mutateAppConversationData, mutateAppPinnedConversationData])
const handlePinConversation = useCallback(async (conversationId: string) => {
await pinConversation(isInstalledApp, appId, conversationId)
notify({ type: 'success', message: t('common.api.success') })
handleUpdateConversationList()
}, [isInstalledApp, appId, notify, t, handleUpdateConversationList])
const handleUnpinConversation = useCallback(async (conversationId: string) => {
await unpinConversation(isInstalledApp, appId, conversationId)
notify({ type: 'success', message: t('common.api.success') })
handleUpdateConversationList()
}, [isInstalledApp, appId, notify, t, handleUpdateConversationList])
const [conversationDeleting, setConversationDeleting] = useState(false)
const handleDeleteConversation = useCallback(async (
conversationId: string,
{
onSuccess,
}: Callback,
) => {
if (conversationDeleting)
return
try {
setConversationDeleting(true)
await delConversation(conversationId)
notify({ type: 'success', message: t('common.api.success') })
onSuccess()
}
finally {
setConversationDeleting(false)
}
if (conversationId === currentConversationId)
handleNewConversation()
handleUpdateConversationList()
}, [isInstalledApp, appId, notify, t, handleUpdateConversationList, handleNewConversation, currentConversationId, conversationDeleting])
const [conversationRenaming, setConversationRenaming] = useState(false)
const handleRenameConversation = useCallback(async (
conversationId: string,
newName: string,
{
onSuccess,
}: Callback,
) => {
if (conversationRenaming)
return
if (!newName.trim()) {
notify({
type: 'error',
message: t('common.chat.conversationNameCanNotEmpty'),
})
return
}
setConversationRenaming(true)
try {
await renameConversation(conversationId, newName)
notify({
type: 'success',
message: t('common.actionMsg.modifiedSuccessfully'),
})
setOriginConversationList(produce((draft) => {
const index = originConversationList.findIndex(item => item.id === conversationId)
const item = draft[index]
draft[index] = {
...item,
name: newName,
}
}))
onSuccess()
}
finally {
setConversationRenaming(false)
}
}, [isInstalledApp, appId, notify, t, conversationRenaming, originConversationList])
const handleNewConversationCompleted = useCallback((newConversationId: string) => {
setNewConversationId(newConversationId)
handleConversationIdInfoChange(newConversationId)
setShowNewConversationItemInList(false)
mutateAppConversationData()
}, [mutateAppConversationData, handleConversationIdInfoChange])
const handleFeedback = useCallback(async (messageId: string, feedback: Feedback) => {
await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating } }, isInstalledApp, appId)
notify({ type: 'success', message: t('common.api.success') })
}, [isInstalledApp, appId, t, notify])
return {
appInfoError,
appInfoLoading,
isInstalledApp,
appId,
currentConversationId,
currentConversationItem,
handleConversationIdInfoChange,
appData,
appParams: appParams || {} as ChatConfig,
appMeta,
appPinnedConversationData,
appConversationData,
appConversationDataLoading,
appChatListData,
appChatListDataLoading,
appPrevChatTree,
pinnedConversationList,
conversationList,
showConfigPanelBeforeChat,
setShowConfigPanelBeforeChat,
setShowNewConversationItemInList,
newConversationInputs,
newConversationInputsRef,
handleNewConversationInputsChange,
inputsForms,
handleNewConversation,
handleStartChat,
handleChangeConversation,
handlePinConversation,
handleUnpinConversation,
conversationDeleting,
handleDeleteConversation,
conversationRenaming,
handleRenameConversation,
handleNewConversationCompleted,
newConversationId,
chatShouldReloadKey,
handleFeedback,
currentChatInstanceRef,
}
}
+246
View File
@@ -0,0 +1,246 @@
import type { FC } from 'react'
import {
useEffect,
useState,
} from 'react'
import { useAsyncEffect } from 'ahooks'
import { useThemeContext } from '@/app/components/chat/embedded-chatbot/theme/theme-context'
import {
ChatWithHistoryContext,
useChatWithHistoryContext,
} from './context'
import { useChatWithHistory } from './hooks'
import Sidebar from './sidebar'
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 { useRouter } from 'next/navigation'
import AppUnavailable from '@/app/components/base/app-unavailable'
type ChatWithHistoryProps = {
className?: string
userStr?: string
}
const ChatWithHistory: FC<ChatWithHistoryProps> = ({
className,
userStr,
}) => {
const {
appInfoError,
appData,
appInfoLoading,
appPrevChatTree,
showConfigPanelBeforeChat,
appChatListDataLoading,
chatShouldReloadKey,
isMobile,
themeBuilder,
} = useChatWithHistoryContext()
const chatReady = (!showConfigPanelBeforeChat || !!appPrevChatTree.length)
const customConfig = appData?.custom_config
const site = appData?.site
useEffect(() => {
themeBuilder?.buildTheme(site?.chat_color_theme, site?.chat_color_theme_inverted)
if (site) {
if (customConfig)
document.title = `${site.title}`
else
document.title = `${site.title} - Powered by Dify`
}
}, [site, customConfig, themeBuilder])
if (appInfoLoading) {
return (
<Loading type='app' />
)
}
if (appInfoError) {
return (
<AppUnavailable />
)
}
return (
<div className={`h-full flex bg-white ${className} ${isMobile && 'flex-col'}`}>
{
!isMobile && (
<Sidebar userStr={userStr} />
)
}
{
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 && (
<Loading type='app' />
)
}
{
chatReady && !appChatListDataLoading && (
<ChatWrapper key={chatShouldReloadKey} />
)
}
</div>
</div>
)
}
export type ChatWithHistoryWrapProps = {
installedAppInfo?: InstalledApp
className?: string
userStr?: string
}
const ChatWithHistoryWrap: FC<ChatWithHistoryWrapProps> = ({
installedAppInfo,
className,
userStr,
}) => {
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const themeBuilder = useThemeContext()
const {
appInfoError,
appInfoLoading,
appData,
appParams,
appMeta,
appChatListDataLoading,
currentConversationId,
currentConversationItem,
appPrevChatTree,
pinnedConversationList,
conversationList,
showConfigPanelBeforeChat,
newConversationInputs,
newConversationInputsRef,
handleNewConversationInputsChange,
inputsForms,
handleNewConversation,
handleStartChat,
handleChangeConversation,
handlePinConversation,
handleUnpinConversation,
handleDeleteConversation,
conversationRenaming,
handleRenameConversation,
handleNewConversationCompleted,
chatShouldReloadKey,
isInstalledApp,
appId,
handleFeedback,
currentChatInstanceRef,
} = useChatWithHistory(installedAppInfo)
return (
<ChatWithHistoryContext.Provider value={{
appInfoError,
appInfoLoading,
appData,
appParams,
appMeta,
appChatListDataLoading,
currentConversationId,
currentConversationItem,
appPrevChatTree,
pinnedConversationList,
conversationList,
showConfigPanelBeforeChat,
newConversationInputs,
newConversationInputsRef,
handleNewConversationInputsChange,
inputsForms,
handleNewConversation,
handleStartChat,
handleChangeConversation,
handlePinConversation,
handleUnpinConversation,
handleDeleteConversation,
conversationRenaming,
handleRenameConversation,
handleNewConversationCompleted,
chatShouldReloadKey,
isMobile,
isInstalledApp,
appId,
handleFeedback,
currentChatInstanceRef,
themeBuilder,
}}>
<ChatWithHistory className={className} userStr={userStr} />
</ChatWithHistoryContext.Provider>
)
}
const ChatWithHistoryWrapWithCheckToken: FC<ChatWithHistoryWrapProps> = ({
installedAppInfo,
className,
}) => {
const [initialized, setInitialized] = useState(false)
const [appUnavailable, setAppUnavailable] = useState<boolean>(false)
const [resCode, setResCode] = useState<number>(200)
const router = useRouter()
const [userName, setUserName] = useState('')
const [nickName, setNickName] = useState('')
useAsyncEffect(async () => {
if (!initialized) {
if (!installedAppInfo) {
const accessToken = localStorage.getItem('token')
if (!accessToken) {
router.replace('/login')
} else {
fetch(`${process.env.NEXT_PUBLIC_BASE_API_URL}/getInfo`, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
},
}).then(res => res.json()).then(data => {
if (data.code !== 200) {
localStorage.removeItem('token')
router.replace('/login')
} else {
setUserName(data.user.userName)
setNickName(data.user.nickName)
}
}).catch(() => {
setResCode(500)
setAppUnavailable(true)
})
}
}
setInitialized(true)
}
}, [])
if (!initialized)
return null
if (appUnavailable)
return <AppUnavailable code={resCode} />
return (
<ChatWithHistoryWrap
installedAppInfo={installedAppInfo}
className={className}
userStr={`${nickName}[${userName}]`}
/>
)
}
export default ChatWithHistoryWrapWithCheckToken
@@ -0,0 +1,176 @@
import {
useCallback,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { useChatWithHistoryContext } from '../context'
import List from './list'
import AppIcon from '@/app/components/base/app-icon'
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 Confirm from '@/app/components/base/confirm'
import RenameModal from './rename-modal'
import { useRouter } from 'next/navigation'
const Sidebar = ({ userStr = 'no-user' }: any) => {
const router = useRouter()
const { t } = useTranslation()
const {
appData,
pinnedConversationList,
conversationList,
handleNewConversation,
currentConversationId,
handleChangeConversation,
handlePinConversation,
handleUnpinConversation,
conversationRenaming,
handleRenameConversation,
handleDeleteConversation,
isMobile,
} = useChatWithHistoryContext()
const [showConfirm, setShowConfirm] = useState<ConversationItem | null>(null)
const [showRename, setShowRename] = useState<ConversationItem | null>(null)
const [showLogoutConfirm, setShowLogoutConfirm] = useState(false)
const handleOperate = useCallback((type: string, item: ConversationItem) => {
if (type === 'pin')
handlePinConversation(item.id)
if (type === 'unpin')
handleUnpinConversation(item.id)
if (type === 'delete')
setShowConfirm(item)
if (type === 'rename')
setShowRename(item)
}, [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 handleLogoutConfirm = useCallback(() => {
localStorage.removeItem('token')
router.replace('/login')
location.reload()
}, [router])
const handleLogout = useCallback(() => {
setShowLogoutConfirm(true)
}, [])
const handleCancelLogout = useCallback(() => {
setShowLogoutConfirm(false)
}, [])
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' />
{t('share.chat.newChat')}
</Button>
</div>
<div className='grow px-4 py-2 overflow-y-auto'>
{/* TODO 置顶接口暂不支持 */}
{/* {
!!pinnedConversationList.length && (
<div className='mb-4'>
<List
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>
<div className='p-4 curr-user'>
<div
className="mt-1 text-xs text-gray-500 cursor-pointer hover:text-gray-800 float-right"
onClick={handleLogout}
>
{userStr}
</div>
</div>
{appData?.site?.copyright && (
<div className='px-4 pb-4 text-xs text-gray-400'>
© {(new Date()).getFullYear()} {appData?.site?.copyright}
</div>
)}
{!!showConfirm && (
<Confirm
title={t('share.chat.deleteConversation.title')}
content={t('share.chat.deleteConversation.content') || ''}
isShow
onCancel={handleCancelConfirm}
onConfirm={handleDelete}
/>
)}
{!!showLogoutConfirm && (
<Confirm
title={t('share.chat.logout.title')}
content={t('share.chat.logout.content') || ''}
isShow
onCancel={handleCancelLogout}
onConfirm={handleLogoutConfirm}
/>
)}
{showRename && (
<RenameModal
isShow
onClose={handleCancelRename}
saveLoading={conversationRenaming}
name={showRename?.name || ''}
onSave={handleRename}
/>
)}
</div>
)
}
export default Sidebar
@@ -0,0 +1,58 @@
import type { FC } from 'react'
import {
memo,
useRef,
} 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'
type ItemProps = {
isPin?: boolean
item: ConversationItem
onOperate: (type: string, item: ConversationItem) => void
onChangeConversation: (conversationId: string) => void
currentConversationId: string
}
const Item: FC<ItemProps> = ({
isPin,
item,
onOperate,
onChangeConversation,
currentConversationId,
}) => {
const ref = useRef(null)
const isHovering = useHover(ref)
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'}
`}
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>
{item.id !== '' && (
<div className='shrink-0 h-6' onClick={e => e.stopPropagation()}>
<ItemOperation
isPinned={!!isPin}
isItemHovering={isHovering}
togglePin={() => onOperate(isPin ? 'unpin' : 'pin', item)}
isShowDelete
isShowRenameConversation
onRenameConversation={() => onOperate('rename', item)}
onDelete={() => onOperate('delete', item)}
/>
</div>
)}
</div>
)
}
export default memo(Item)
@@ -0,0 +1,46 @@
import type { FC } from 'react'
import Item from './item'
import type { ConversationItem } from '@/models/share'
type ListProps = {
isPin?: boolean
title?: string
list: ConversationItem[]
onOperate: (type: string, item: ConversationItem) => void
onChangeConversation: (conversationId: string) => void
currentConversationId: string
}
const List: FC<ListProps> = ({
isPin,
title,
list,
onOperate,
onChangeConversation,
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>
)
}
export default List
@@ -0,0 +1,46 @@
'use client'
import type { FC } from 'react'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
export type IRenameModalProps = {
isShow: boolean
saveLoading: boolean
name: string
onClose: () => void
onSave: (name: string) => void
}
const RenameModal: FC<IRenameModalProps> = ({
isShow,
saveLoading,
name,
onClose,
onSave,
}) => {
const { t } = useTranslation()
const [tempName, setTempName] = useState(name)
return (
<Modal
title={t('common.chat.renameConversation')}
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'}
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>
</div>
</Modal>
)
}
export default React.memo(RenameModal)