Initial commit

This commit is contained in:
John Wang
2023-05-15 08:51:32 +08:00
commit db896255d6
744 changed files with 56028 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
import type { FC } from 'react'
import React from 'react'
import type { IWelcomeProps } from '../welcome'
import Welcome from '../welcome'
const ConfigSence: FC<IWelcomeProps> = (props) => {
return (
<div className='mb-5 antialiased font-sans overflow-hidden shrink-0'>
<Welcome {...props} />
</div>
)
}
export default React.memo(ConfigSence)

View File

@@ -0,0 +1,66 @@
import { useState } from 'react'
import type { ConversationItem } from '@/models/share'
import produce from 'immer'
const storageConversationIdKey = 'conversationIdInfo'
type ConversationInfoType = Omit<ConversationItem, 'inputs' | 'id'>
function useConversation() {
const [conversationList, setConversationList] = useState<ConversationItem[]>([])
const [currConversationId, doSetCurrConversationId] = useState<string>('-1')
// when set conversation id, we do not have set appId
const setCurrConversationId = (id: string, appId: string, isSetToLocalStroge = true, newConversationName = '') => {
doSetCurrConversationId(id)
if (isSetToLocalStroge && id !== '-1') {
// conversationIdInfo: {[appId1]: conversationId1, [appId2]: conversationId2}
const conversationIdInfo = globalThis.localStorage?.getItem(storageConversationIdKey) ? JSON.parse(globalThis.localStorage?.getItem(storageConversationIdKey) || '') : {}
conversationIdInfo[appId] = id
globalThis.localStorage?.setItem(storageConversationIdKey, JSON.stringify(conversationIdInfo))
}
}
const getConversationIdFromStorage = (appId: string) => {
const conversationIdInfo = globalThis.localStorage?.getItem(storageConversationIdKey) ? JSON.parse(globalThis.localStorage?.getItem(storageConversationIdKey) || '') : {}
const id = conversationIdInfo[appId]
return id
}
const isNewConversation = currConversationId === '-1'
// input can be updated by user
const [newConversationInputs, setNewConversationInputs] = useState<Record<string, any> | null>(null)
const resetNewConversationInputs = () => {
if (!newConversationInputs) return
setNewConversationInputs(produce(newConversationInputs, draft => {
Object.keys(draft).forEach(key => {
draft[key] = ''
})
}))
}
const [existConversationInputs, setExistConversationInputs] = useState<Record<string, any> | null>(null)
const currInputs = isNewConversation ? newConversationInputs : existConversationInputs
const setCurrInputs = isNewConversation ? setNewConversationInputs : setExistConversationInputs
// info is muted
const [newConversationInfo, setNewConversationInfo] = useState<ConversationInfoType | null>(null)
const [existConversationInfo, setExistConversationInfo] = useState<ConversationInfoType | null>(null)
const currConversationInfo = isNewConversation ? newConversationInfo : existConversationInfo
return {
conversationList,
setConversationList,
currConversationId,
setCurrConversationId,
getConversationIdFromStorage,
isNewConversation,
currInputs,
newConversationInputs,
existConversationInputs,
resetNewConversationInputs,
setCurrInputs,
currConversationInfo,
setNewConversationInfo,
setExistConversationInfo
}
}
export default useConversation;

View File

@@ -0,0 +1,506 @@
'use client'
import type { FC } from 'react'
import React, { useEffect, useState, useRef } from 'react'
import cn from 'classnames'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import produce from 'immer'
import { useBoolean, useGetState } from 'ahooks'
import useConversation from './hooks/use-conversation'
import { ToastContext } from '@/app/components/base/toast'
import Sidebar from '@/app/components/share/chat/sidebar'
import ConfigSence from '@/app/components/share/chat/config-scence'
import Header from '@/app/components/share/header'
import { fetchAppInfo, fetchAppParams, fetchChatList, fetchConversations, sendChatMessage, updateFeedback, fetchSuggestedQuestions } from '@/service/share'
import type { ConversationItem, SiteInfo } from '@/models/share'
import type { PromptConfig } from '@/models/debug'
import type { Feedbacktype, IChatItem } from '@/app/components/app/chat'
import Chat from '@/app/components/app/chat'
import { changeLanguage } from '@/i18n/i18next-config'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import Loading from '@/app/components/base/loading'
import { replaceStringWithValues } from '@/app/components/app/configuration/prompt-value-panel'
import AppUnavailable from '../../base/app-unavailable'
import { userInputsFormToPromptVariables } from '@/utils/model-config'
import { SuggestedQuestionsAfterAnswerConfig } from '@/models/debug'
export type IMainProps = {
params: {
locale: string
appId: string
conversationId: string
token: string
}
}
const Main: FC<IMainProps> = () => {
const { t } = useTranslation()
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
/*
* app info
*/
const [appUnavailable, setAppUnavailable] = useState<boolean>(false)
const [isUnknwonReason, setIsUnknwonReason] = useState<boolean>(false)
const [appId, setAppId] = useState<string>('')
const [isPublicVersion, setIsPublicVersion] = useState<boolean>(true)
const [siteInfo, setSiteInfo] = useState<SiteInfo | null>()
const [promptConfig, setPromptConfig] = useState<PromptConfig | null>(null)
const [inited, setInited] = useState<boolean>(false)
const [plan, setPlan] = useState<string>('basic') // basic/plus/pro
// in mobile, show sidebar by click button
const [isShowSidebar, { setTrue: showSidebar, setFalse: hideSidebar }] = useBoolean(false)
// Can Use metadata(https://beta.nextjs.org/docs/api-reference/metadata) to set title. But it only works in server side client.
useEffect(() => {
if (siteInfo?.title) {
if (plan !== 'basic')
document.title = `${siteInfo.title}`
else
document.title = `${siteInfo.title} - Powered by Dify`
}
}, [siteInfo?.title, plan])
/*
* conversation info
*/
const {
conversationList,
setConversationList,
currConversationId,
setCurrConversationId,
getConversationIdFromStorage,
isNewConversation,
currConversationInfo,
currInputs,
newConversationInputs,
// existConversationInputs,
resetNewConversationInputs,
setCurrInputs,
setNewConversationInfo,
setExistConversationInfo
} = useConversation()
const [suggestedQuestionsAfterAnswerConfig, setSuggestedQuestionsAfterAnswerConfig] = useState<SuggestedQuestionsAfterAnswerConfig | null>(null)
const [conversationIdChangeBecauseOfNew, setConversationIdChangeBecauseOfNew, getConversationIdChangeBecauseOfNew] = useGetState(false)
const [isChatStarted, { setTrue: setChatStarted, setFalse: setChatNotStarted }] = useBoolean(false)
const handleStartChat = (inputs: Record<string, any>) => {
createNewChat()
setConversationIdChangeBecauseOfNew(true)
setCurrInputs(inputs)
setChatStarted()
// parse variables in introduction
setChatList(generateNewChatListWithOpenstatement('', inputs))
}
const hasSetInputs = (() => {
if (!isNewConversation) {
return true
}
return isChatStarted
})()
const conversationName = currConversationInfo?.name || t('share.chat.newChatDefaultName') as string
const conversationIntroduction = currConversationInfo?.introduction || ''
const handleConversationSwitch = () => {
if (!inited) return
if (!appId) {
// wait for appId
setTimeout(handleConversationSwitch, 100)
return
}
// update inputs of current conversation
let notSyncToStateIntroduction = ''
let notSyncToStateInputs: Record<string, any> | undefined | null = {}
if (!isNewConversation) {
const item = conversationList.find(item => item.id === currConversationId)
notSyncToStateInputs = item?.inputs || {}
setCurrInputs(notSyncToStateInputs)
notSyncToStateIntroduction = item?.introduction || ''
setExistConversationInfo({
name: item?.name || '',
introduction: notSyncToStateIntroduction,
})
} else {
notSyncToStateInputs = newConversationInputs
setCurrInputs(notSyncToStateInputs)
}
// update chat list of current conversation
if (!isNewConversation && !conversationIdChangeBecauseOfNew && !isResponsing) {
fetchChatList(currConversationId).then((res: any) => {
const { data } = res
const newChatList: IChatItem[] = generateNewChatListWithOpenstatement(notSyncToStateIntroduction, notSyncToStateInputs)
data.forEach((item: any) => {
newChatList.push({
id: `question-${item.id}`,
content: item.query,
isAnswer: false,
})
newChatList.push({
id: item.id,
content: item.answer,
feedback: item.feedback,
isAnswer: true,
})
})
setChatList(newChatList)
})
}
if (isNewConversation && isChatStarted) {
setChatList(generateNewChatListWithOpenstatement())
}
setControlFocus(Date.now())
}
useEffect(handleConversationSwitch, [currConversationId, inited])
const handleConversationIdChange = (id: string) => {
if (id === '-1') {
createNewChat()
setConversationIdChangeBecauseOfNew(true)
} else {
setConversationIdChangeBecauseOfNew(false)
}
// trigger handleConversationSwitch
setCurrConversationId(id, appId)
setIsShowSuggestion(false)
hideSidebar()
}
/*
* chat info. chat is under conversation.
*/
const [chatList, setChatList, getChatList] = useGetState<IChatItem[]>([])
const chatListDomRef = useRef<HTMLDivElement>(null)
useEffect(() => {
// scroll to bottom
if (chatListDomRef.current) {
chatListDomRef.current.scrollTop = chatListDomRef.current.scrollHeight
}
}, [chatList, currConversationId])
// user can not edit inputs if user had send message
const canEditInpus = !chatList.some(item => item.isAnswer === false) && isNewConversation
const createNewChat = () => {
// if new chat is already exist, do not create new chat
abortController?.abort()
setResponsingFalse()
if (conversationList.some(item => item.id === '-1')) {
return
}
setConversationList(produce(conversationList, draft => {
draft.unshift({
id: '-1',
name: t('share.chat.newChatDefaultName'),
inputs: newConversationInputs,
introduction: conversationIntroduction
})
}))
}
// sometime introduction is not applied to state
const generateNewChatListWithOpenstatement = (introduction?: string, inputs?: Record<string, any> | null) => {
let caculatedIntroduction = introduction || conversationIntroduction || ''
const caculatedPromptVariables = inputs || currInputs || null
if (caculatedIntroduction && caculatedPromptVariables) {
caculatedIntroduction = replaceStringWithValues(caculatedIntroduction, promptConfig?.prompt_variables || [], caculatedPromptVariables)
}
// console.log(isPublicVersion)
const openstatement = {
id: `${Date.now()}`,
content: caculatedIntroduction,
isAnswer: true,
feedbackDisabled: true,
isOpeningStatement: isPublicVersion
}
if (caculatedIntroduction) {
return [openstatement]
}
return []
}
// init
useEffect(() => {
(async () => {
try {
const [appData, conversationData, appParams] = await Promise.all([fetchAppInfo(), fetchConversations(), fetchAppParams()])
const { app_id: appId, site: siteInfo, model_config, plan }: any = appData
setAppId(appId)
setPlan(plan)
const tempIsPublicVersion = siteInfo.prompt_public
setIsPublicVersion(tempIsPublicVersion)
const prompt_template = tempIsPublicVersion ? model_config.pre_prompt : ''
// handle current conversation id
const { data: conversations } = conversationData as { data: ConversationItem[] }
const _conversationId = getConversationIdFromStorage(appId)
const isNotNewConversation = conversations.some(item => item.id === _conversationId)
// fetch new conversation info
const { user_input_form, opening_statement: introduction, suggested_questions_after_answer }: any = appParams
const prompt_variables = userInputsFormToPromptVariables(user_input_form)
changeLanguage(siteInfo.default_language)
setNewConversationInfo({
name: t('share.chat.newChatDefaultName'),
introduction,
})
setSiteInfo(siteInfo as SiteInfo)
setPromptConfig({
prompt_template,
prompt_variables: prompt_variables,
} as PromptConfig)
setSuggestedQuestionsAfterAnswerConfig(suggested_questions_after_answer)
setConversationList(conversations as ConversationItem[])
if (isNotNewConversation) {
setCurrConversationId(_conversationId, appId, false)
}
setInited(true)
} catch (e: any) {
if (e.status === 404) {
setAppUnavailable(true)
} else {
setIsUnknwonReason(true)
setAppUnavailable(true)
}
}
})()
}, [])
const [isResponsing, { setTrue: setResponsingTrue, setFalse: setResponsingFalse }] = useBoolean(false)
const [abortController, setAbortController] = useState<AbortController | null>(null)
const { notify } = useContext(ToastContext)
const logError = (message: string) => {
notify({ type: 'error', message })
}
const checkCanSend = () => {
const prompt_variables = promptConfig?.prompt_variables
const inputs = currInputs
if (!inputs || !prompt_variables || prompt_variables?.length === 0) {
return true
}
let hasEmptyInput = false
const requiredVars = prompt_variables?.filter(({ key, name, required }) => {
const res = (!key || !key.trim()) || (!name || !name.trim()) || (required || required === undefined || required === null)
return res
}) || [] // compatible with old version
requiredVars.forEach(({ key }) => {
if (hasEmptyInput) {
return
}
if (!inputs?.[key]) {
hasEmptyInput = true
}
})
if (hasEmptyInput) {
logError(t('appDebug.errorMessage.valueOfVarRequired'))
return false
}
return !hasEmptyInput
}
const [controlFocus, setControlFocus] = useState(0)
const [isShowSuggestion, setIsShowSuggestion] = useState(false)
const doShowSuggestion = isShowSuggestion && !isResponsing
const [suggestQuestions, setSuggestQuestions] = useState<string[]>([])
const handleSend = async (message: string) => {
if (isResponsing) {
notify({ type: 'info', message: t('appDebug.errorMessage.waitForResponse') })
return
}
const data = {
inputs: currInputs,
query: message,
conversation_id: isNewConversation ? null : currConversationId,
}
// qustion
const questionId = `question-${Date.now()}`
const questionItem = {
id: questionId,
content: message,
isAnswer: false,
}
const placeholderAnswerId = `answer-placeholder-${Date.now()}`
const placeholderAnswerItem = {
id: placeholderAnswerId,
content: '',
isAnswer: true,
}
const newList = [...getChatList(), questionItem, placeholderAnswerItem]
setChatList(newList)
// answer
const responseItem = {
id: `${Date.now()}`,
content: '',
isAnswer: true,
}
let tempNewConversationId = ''
setResponsingTrue()
setIsShowSuggestion(false)
sendChatMessage(data, {
getAbortController: (abortController) => {
setAbortController(abortController)
},
onData: (message: string, isFirstMessage: boolean, { conversationId: newConversationId, messageId }: any) => {
responseItem.content = responseItem.content + message
responseItem.id = messageId
if (isFirstMessage && newConversationId) {
tempNewConversationId = newConversationId
}
// closesure new list is outdated.
const newListWithAnswer = produce(
getChatList().filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId),
(draft) => {
if (!draft.find(item => item.id === questionId))
draft.push({ ...questionItem })
draft.push({ ...responseItem })
})
setChatList(newListWithAnswer)
},
async onCompleted(hasError?: boolean) {
setResponsingFalse()
if (hasError) {
return
}
let currChatList = conversationList
if (getConversationIdChangeBecauseOfNew()) {
const { data: conversations }: any = await fetchConversations()
setConversationList(conversations as ConversationItem[])
currChatList = conversations
}
setConversationIdChangeBecauseOfNew(false)
resetNewConversationInputs()
setChatNotStarted()
setCurrConversationId(tempNewConversationId, appId, true)
if (suggestedQuestionsAfterAnswerConfig?.enabled) {
const { data }: any = await fetchSuggestedQuestions(responseItem.id)
setSuggestQuestions(data)
setIsShowSuggestion(true)
}
},
onError() {
setResponsingFalse()
// role back placeholder answer
setChatList(produce(getChatList(), draft => {
draft.splice(draft.findIndex(item => item.id === placeholderAnswerId), 1)
}))
},
})
}
const handleFeedback = async (messageId: string, feedback: Feedbacktype) => {
await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating } })
const newChatList = chatList.map((item) => {
if (item.id === messageId) {
return {
...item,
feedback,
}
}
return item
})
setChatList(newChatList)
notify({ type: 'success', message: t('common.api.success') })
}
const renderSidebar = () => {
if (!appId || !siteInfo || !promptConfig)
return null
return (
<Sidebar
list={conversationList}
onCurrentIdChange={handleConversationIdChange}
currentId={currConversationId}
copyRight={siteInfo.copyright || siteInfo.title}
/>
)
}
if (appUnavailable)
return <AppUnavailable isUnknwonReason={isUnknwonReason} />
if (!appId || !siteInfo || !promptConfig)
return <Loading type='app' />
return (
<div className='bg-gray-100'>
<Header
title={siteInfo.title}
isMobile={isMobile}
onShowSideBar={showSidebar}
onCreateNewChat={() => handleConversationIdChange('-1')}
/>
{/* {isNewConversation ? 'new' : 'exist'}
{JSON.stringify(newConversationInputs ? newConversationInputs : {})}
{JSON.stringify(existConversationInputs ? existConversationInputs : {})} */}
<div className="flex rounded-t-2xl bg-white overflow-hidden">
{/* sidebar */}
{!isMobile && renderSidebar()}
{isMobile && isShowSidebar && (
<div className='fixed inset-0 z-50'
style={{ backgroundColor: 'rgba(35, 56, 118, 0.2)' }}
onClick={hideSidebar}
>
<div className='inline-block' onClick={e => e.stopPropagation()}>
{renderSidebar()}
</div>
</div>
)}
{/* main */}
<div className='flex-grow flex flex-col h-[calc(100vh_-_3rem)] overflow-y-auto'>
<ConfigSence
conversationName={conversationName}
hasSetInputs={hasSetInputs}
isPublicVersion={isPublicVersion}
siteInfo={siteInfo}
promptConfig={promptConfig}
onStartChat={handleStartChat}
canEidtInpus={canEditInpus}
savedInputs={currInputs as Record<string, any>}
onInputsChange={setCurrInputs}
plan={plan}
></ConfigSence>
{
hasSetInputs && (
<div className={cn(doShowSuggestion ? 'pb-[140px]' : 'pb-[66px]', 'relative grow h-[200px] pc:w-[794px] max-w-full mobile:w-full mx-auto mb-3.5 overflow-hidden')}>
<div className='h-full overflow-y-auto' ref={chatListDomRef}>
<Chat
chatList={chatList}
onSend={handleSend}
isHideFeedbackEdit
onFeedback={handleFeedback}
isResponsing={isResponsing}
abortResponsing={() => {
abortController?.abort()
setResponsingFalse()
}}
checkCanSend={checkCanSend}
controlFocus={controlFocus}
isShowSuggestion={doShowSuggestion}
suggestionList={suggestQuestions}
/>
</div>
</div>)
}
</div>
</div>
</div>
)
}
export default React.memo(Main)

View File

@@ -0,0 +1,3 @@
.card:hover {
background: linear-gradient(0deg, rgba(235, 245, 255, 0.4), rgba(235, 245, 255, 0.4)), #FFFFFF;
}

View File

@@ -0,0 +1,19 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import s from './card.module.css'
type PropType = {
children: React.ReactNode
text?: string
}
function Card({ children, text }: PropType) {
const { t } = useTranslation()
return (
<div className={`${s.card} box-border w-full flex flex-col items-start px-4 py-3 rounded-lg border-solid border border-gray-200 cursor-pointer hover:border-primary-300`}>
<div className='text-gray-400 font-medium text-xs mb-2'>{text ?? t('share.chat.powerBy')}</div>
{children}
</div>
)
}
export default Card

View File

@@ -0,0 +1,83 @@
import React from 'react'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import {
ChatBubbleOvalLeftEllipsisIcon,
PencilSquareIcon
} from '@heroicons/react/24/outline'
import { ChatBubbleOvalLeftEllipsisIcon as ChatBubbleOvalLeftEllipsisSolidIcon, } from '@heroicons/react/24/solid'
import Button from '../../../base/button'
// import Card from './card'
import type { ConversationItem } from '@/models/share'
function classNames(...classes: any[]) {
return classes.filter(Boolean).join(' ')
}
const MAX_CONVERSATION_LENTH = 20
export type ISidebarProps = {
copyRight: string
currentId: string
onCurrentIdChange: (id: string) => void
list: ConversationItem[]
}
const Sidebar: FC<ISidebarProps> = ({
copyRight,
currentId,
onCurrentIdChange,
list }) => {
const { t } = useTranslation()
return (
<div
className="shrink-0 flex flex-col overflow-y-auto bg-white pc:w-[244px] tablet:w-[192px] mobile:w-[240px] border-r border-gray-200 tablet:h-[calc(100vh_-_3rem)] mobile:h-screen"
>
{list.length < MAX_CONVERSATION_LENTH && (
<div className="flex flex-shrink-0 p-4 !pb-0">
<Button
onClick={() => { onCurrentIdChange('-1') }}
className="group block w-full flex-shrink-0 !justify-start !h-9 text-primary-600 items-center text-sm">
<PencilSquareIcon className="mr-2 h-4 w-4" /> {t('share.chat.newChat')}
</Button>
</div>
)}
<nav className="mt-4 flex-1 space-y-1 bg-white p-4 !pt-0">
{list.map((item) => {
const isCurrent = item.id === currentId
const ItemIcon
= isCurrent ? ChatBubbleOvalLeftEllipsisSolidIcon : ChatBubbleOvalLeftEllipsisIcon
return (
<div
onClick={() => onCurrentIdChange(item.id)}
key={item.id}
className={classNames(
isCurrent
? 'bg-primary-50 text-primary-600'
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-700',
'group flex items-center rounded-md px-2 py-2 text-sm font-medium cursor-pointer',
)}
>
<ItemIcon
className={classNames(
isCurrent
? 'text-primary-600'
: 'text-gray-400 group-hover:text-gray-500',
'mr-3 h-5 w-5 flex-shrink-0',
)}
aria-hidden="true"
/>
{item.name}
</div>
)
})}
</nav>
<div className="flex flex-shrink-0 pr-4 pb-4 pl-4">
<div className="text-gray-400 font-normal text-xs">© {copyRight} {(new Date()).getFullYear()}</div>
</div>
</div>
)
}
export default React.memo(Sidebar)

View File

@@ -0,0 +1,79 @@
'use client'
import React, { FC, ReactNode } from 'react'
import cn from 'classnames'
import { StarIcon } from '@/app/components/share/chat/welcome/massive-component'
import Button from '@/app/components/base/button'
import { useTranslation } from 'react-i18next'
import s from './style.module.css'
export interface ITemplateVarPanelProps {
className?: string
header: ReactNode
children?: ReactNode | null
isFold: boolean
}
const TemplateVarPanel: FC<ITemplateVarPanelProps> = ({
className,
header,
children,
isFold
}) => {
return (
<div className={cn(isFold ? 'border border-indigo-100' : s.boxShodow, className, 'rounded-xl ')}>
{/* header */}
<div
className={cn(isFold && 'rounded-b-xl', 'rounded-t-xl px-6 py-4 bg-indigo-25 text-xs')}
>
{header}
</div>
{/* body */}
{!isFold && children && (
<div className='rounded-b-xl p-6'>
{children}
</div>
)}
</div>
)
}
export const PanelTitle: FC<{ title: string, className?: string }> = ({
title,
className
}) => {
return (
<div className={cn(className, 'flex items-center space-x-1 text-indigo-600')}>
<StarIcon />
<span className='text-xs'>{title}</span>
</div>
)
}
export const VarOpBtnGroup: FC<{ className?: string, onConfirm: () => void, onCancel: () => void }> = ({
className,
onConfirm,
onCancel
}) => {
const { t } = useTranslation()
return (
<div className={cn(className, 'flex mt-3 space-x-2 mobile:ml-0 tablet:ml-[128px] text-sm')}>
<Button
className='text-sm'
type='primary'
onClick={onConfirm}
>
{t('common.operation.save')}
</Button>
<Button
className='text-sm'
onClick={onCancel}
>
{t('common.operation.cancel')}
</Button>
</div >
)
}
export default React.memo(TemplateVarPanel)

View File

@@ -0,0 +1,3 @@
.boxShodow {
box-shadow: 0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1,352 @@
'use client'
import type { FC } from 'react'
import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import s from './style.module.css'
import type { SiteInfo } from '@/models/share'
import type { PromptConfig } from '@/models/debug'
import { ToastContext } from '@/app/components/base/toast'
import Select from '@/app/components/base/select'
import { DEFAULT_VALUE_MAX_LEN } from '@/config'
import TemplateVarPanel, { PanelTitle, VarOpBtnGroup } from '../value-panel'
import { AppInfo, PromptTemplate, ChatBtn, EditBtn, FootLogo } from './massive-component'
// regex to match the {{}} and replace it with a span
const regex = /\{\{([^}]+)\}\}/g
export type IWelcomeProps = {
conversationName: string
hasSetInputs: boolean
isPublicVersion: boolean
siteInfo: SiteInfo
promptConfig: PromptConfig
onStartChat: (inputs: Record<string, any>) => void
canEidtInpus: boolean
savedInputs: Record<string, any>
onInputsChange: (inputs: Record<string, any>) => void
plan: string
}
const Welcome: FC<IWelcomeProps> = ({
conversationName,
hasSetInputs,
isPublicVersion,
siteInfo,
plan,
promptConfig,
onStartChat,
canEidtInpus,
savedInputs,
onInputsChange
}) => {
const { t } = useTranslation()
const hasVar = promptConfig.prompt_variables.length > 0
const [isFold, setIsFold] = useState<boolean>(true)
const [inputs, setInputs] = useState<Record<string, any>>((() => {
if (hasSetInputs) {
return savedInputs
}
const res: Record<string, any> = {}
if (promptConfig) {
promptConfig.prompt_variables.forEach((item) => {
res[item.key] = ''
})
}
// debugger
return res
})())
useEffect(() => {
if (!savedInputs) {
const res: Record<string, any> = {}
if (promptConfig) {
promptConfig.prompt_variables.forEach((item) => {
res[item.key] = ''
})
}
setInputs(res)
} else {
setInputs(savedInputs)
}
}, [savedInputs])
const highLightPromoptTemplate = (() => {
if (!promptConfig)
return ''
const res = promptConfig.prompt_template.replace(regex, (match, p1) => {
return `<span class='text-gray-800 font-bold'>${inputs?.[p1] ? inputs?.[p1] : match}</span>`
})
return res
})()
const { notify } = useContext(ToastContext)
const logError = (message: string) => {
notify({ type: 'error', message, duration: 3000 })
}
const renderHeader = () => {
return (
<div className='absolute top-0 left-0 right-0 flex items-center justify-between border-b border-gray-100 mobile:h-12 tablet:h-16 px-8 bg-white'>
<div className='text-gray-900'>{conversationName}</div>
</div>
)
}
const renderInputs = () => {
return (
<div className='space-y-3'>
{promptConfig.prompt_variables.map(item => (
<div className='tablet:flex tablet:!h-9 mobile:space-y-2 tablet:space-y-0 mobile:text-xs tablet:text-sm' key={item.key}>
<label className={`flex-shrink-0 flex items-center mobile:text-gray-700 tablet:text-gray-900 mobile:font-medium pc:font-normal ${s.formLabel}`}>{item.name}</label>
{item.type === 'select' ? (
<Select
className='w-full'
defaultValue={inputs?.[item.key]}
onSelect={(i) => { setInputs({ ...inputs, [item.key]: i.value }) }}
items={(item.options || []).map(i => ({ name: i, value: i }))}
allowSearch={false}
bgClassName='bg-gray-50'
/>
) : (
<input
placeholder={`${item.name}${!item.required ? `(${t('appDebug.variableTable.optional')})` : ''}`}
value={inputs?.[item.key] || ''}
onChange={(e) => { setInputs({ ...inputs, [item.key]: e.target.value }) }}
className={`w-full flex-grow py-2 pl-3 pr-3 box-border rounded-lg bg-gray-50`}
maxLength={item.max_length || DEFAULT_VALUE_MAX_LEN}
/>
)}
</div>
))}
</div>
)
}
const canChat = () => {
const prompt_variables = promptConfig?.prompt_variables
if (!inputs || !prompt_variables || prompt_variables?.length === 0) {
return true
}
let hasEmptyInput = false
const requiredVars = prompt_variables?.filter(({ key, name, required }) => {
const res = (!key || !key.trim()) || (!name || !name.trim()) || (required || required === undefined || required === null)
return res
}) || [] // compatible with old version
requiredVars.forEach(({ key }) => {
if (hasEmptyInput) {
return
}
if (!inputs?.[key]) {
hasEmptyInput = true
}
})
if (hasEmptyInput) {
logError(t('appDebug.errorMessage.valueOfVarRequired'))
return false
}
return !hasEmptyInput
}
const handleChat = () => {
if (!canChat()) {
return
}
onStartChat(inputs)
}
const renderNoVarPanel = () => {
if (isPublicVersion) {
return (
<div>
<AppInfo siteInfo={siteInfo} />
<TemplateVarPanel
isFold={false}
header={
<>
<PanelTitle
title={t('share.chat.publicPromptConfigTitle')}
className='mb-1'
/>
<PromptTemplate html={highLightPromoptTemplate} />
</>
}
>
<ChatBtn onClick={handleChat} />
</TemplateVarPanel>
</div>
)
}
// private version
return (
<TemplateVarPanel
isFold={false}
header={
<AppInfo siteInfo={siteInfo} />
}
>
<ChatBtn onClick={handleChat} />
</TemplateVarPanel>
)
}
const renderVarPanel = () => {
return (
<TemplateVarPanel
isFold={false}
header={
<AppInfo siteInfo={siteInfo} />
}
>
{renderInputs()}
<ChatBtn
className='mt-3 mobile:ml-0 tablet:ml-[128px]'
onClick={handleChat}
/>
</TemplateVarPanel>
)
}
const renderVarOpBtnGroup = () => {
return (
<VarOpBtnGroup
onConfirm={() => {
if (!canChat()) {
return
}
onInputsChange(inputs)
setIsFold(true)
}}
onCancel={() => {
setInputs(savedInputs)
setIsFold(true)
}}
/>
)
}
const renderHasSetInputsPublic = () => {
if (!canEidtInpus) {
return (
<TemplateVarPanel
isFold={false}
header={
<>
<PanelTitle
title={t('share.chat.publicPromptConfigTitle')}
className='mb-1'
/>
<PromptTemplate html={highLightPromoptTemplate} />
</>
}
/>
)
}
return (
<TemplateVarPanel
isFold={isFold}
header={
<>
<PanelTitle
title={t('share.chat.publicPromptConfigTitle')}
className='mb-1'
/>
<PromptTemplate html={highLightPromoptTemplate} />
{isFold && (
<div className='flex items-center justify-between mt-3 border-t border-indigo-100 pt-4 text-xs text-indigo-600'>
<span className='text-gray-700'>{t('share.chat.configStatusDes')}</span>
<EditBtn onClick={() => setIsFold(false)} />
</div>
)}
</>
}
>
{renderInputs()}
{renderVarOpBtnGroup()}
</TemplateVarPanel>
)
}
const renderHasSetInputsPrivate = () => {
if (!canEidtInpus || !hasVar) {
return null
}
return (
<TemplateVarPanel
isFold={isFold}
header={
<div className='flex items-center justify-between text-indigo-600'>
<PanelTitle
title={!isFold ? t('share.chat.privatePromptConfigTitle') : t('share.chat.configStatusDes')}
/>
{isFold && (
<EditBtn onClick={() => setIsFold(false)} />
)}
</div>
}
>
{renderInputs()}
{renderVarOpBtnGroup()}
</TemplateVarPanel>
)
}
const renderHasSetInputs = () => {
if (!isPublicVersion && !canEidtInpus || !hasVar) {
return null
}
return (
<div
className='pt-[88px] mb-5'
>
{isPublicVersion ? renderHasSetInputsPublic() : renderHasSetInputsPrivate()}
</div>)
}
return (
<div className='relative mobile:min-h-[48px] tablet:min-h-[64px]'>
{hasSetInputs && renderHeader()}
<div className='mx-auto pc:w-[794px] max-w-full mobile:w-full px-3.5'>
{/* Has't set inputs */}
{
!hasSetInputs && (
<div className='mobile:pt-[72px] tablet:pt-[128px] pc:pt-[200px]'>
{hasVar ? (
renderVarPanel()
) : (
renderNoVarPanel()
)}
</div>
)
}
{/* Has set inputs */}
{hasSetInputs && renderHasSetInputs()}
{/* foot */}
{!hasSetInputs && (
<div className='mt-4 flex justify-between items-center h-8 text-xs text-gray-400'>
{siteInfo.privacy_policy
? <div>{t('share.chat.privacyPolicyLeft')}
<a
className='text-gray-500'
href={siteInfo.privacy_policy}
target='_blank'>{t('share.chat.privacyPolicyMiddle')}</a>
{t('share.chat.privacyPolicyRight')}
</div>
: <div>
</div>}
{plan === 'basic' && <a className='flex items-center pr-3 space-x-3' href="https://dify.ai/" target="_blank">
<span className='uppercase'>{t('share.chat.powerBy')}</span>
<FootLogo />
</a>}
</div>
)}
</div>
</div >
)
}
export default React.memo(Welcome)

View File

@@ -0,0 +1,75 @@
'use client'
import React, { FC } from 'react'
import cn from 'classnames'
import { useTranslation } from 'react-i18next'
import type { SiteInfo } from '@/models/share'
import Button from '@/app/components/base/button'
import {
PencilIcon
} from '@heroicons/react/24/solid'
import s from './style.module.css'
export const AppInfo: FC<{ siteInfo: SiteInfo }> = ({ siteInfo }) => {
const { t } = useTranslation()
return (
<div>
<div className='flex items-center py-2 text-xl font-medium text-gray-700 rounded-md'>👏 {t('share.common.welcome')} {siteInfo.title}</div>
<p className='text-sm text-gray-500'>{siteInfo.description}</p>
</div>
)
}
export const PromptTemplate: FC<{ html: string }> = ({ html }) => {
return (
<div
className={' box-border text-sm text-gray-700'}
dangerouslySetInnerHTML={{ __html: html }}
></div>
)
}
export const StarIcon = () => (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.75 1C2.75 0.723858 2.52614 0.5 2.25 0.5C1.97386 0.5 1.75 0.723858 1.75 1V1.75H1C0.723858 1.75 0.5 1.97386 0.5 2.25C0.5 2.52614 0.723858 2.75 1 2.75H1.75V3.5C1.75 3.77614 1.97386 4 2.25 4C2.52614 4 2.75 3.77614 2.75 3.5V2.75H3.5C3.77614 2.75 4 2.52614 4 2.25C4 1.97386 3.77614 1.75 3.5 1.75H2.75V1Z" fill="#444CE7" />
<path d="M2.75 8.5C2.75 8.22386 2.52614 8 2.25 8C1.97386 8 1.75 8.22386 1.75 8.5V9.25H1C0.723858 9.25 0.5 9.47386 0.5 9.75C0.5 10.0261 0.723858 10.25 1 10.25H1.75V11C1.75 11.2761 1.97386 11.5 2.25 11.5C2.52614 11.5 2.75 11.2761 2.75 11V10.25H3.5C3.77614 10.25 4 10.0261 4 9.75C4 9.47386 3.77614 9.25 3.5 9.25H2.75V8.5Z" fill="#444CE7" />
<path d="M6.96667 1.32051C6.8924 1.12741 6.70689 1 6.5 1C6.29311 1 6.10759 1.12741 6.03333 1.32051L5.16624 3.57494C5.01604 3.96546 4.96884 4.078 4.90428 4.1688C4.8395 4.2599 4.7599 4.3395 4.6688 4.40428C4.578 4.46884 4.46546 4.51604 4.07494 4.66624L1.82051 5.53333C1.62741 5.60759 1.5 5.79311 1.5 6C1.5 6.20689 1.62741 6.39241 1.82051 6.46667L4.07494 7.33376C4.46546 7.48396 4.578 7.53116 4.6688 7.59572C4.7599 7.6605 4.8395 7.7401 4.90428 7.8312C4.96884 7.922 5.01604 8.03454 5.16624 8.42506L6.03333 10.6795C6.1076 10.8726 6.29311 11 6.5 11C6.70689 11 6.89241 10.8726 6.96667 10.6795L7.83376 8.42506C7.98396 8.03454 8.03116 7.922 8.09572 7.8312C8.1605 7.7401 8.2401 7.6605 8.3312 7.59572C8.422 7.53116 8.53454 7.48396 8.92506 7.33376L11.1795 6.46667C11.3726 6.39241 11.5 6.20689 11.5 6C11.5 5.79311 11.3726 5.60759 11.1795 5.53333L8.92506 4.66624C8.53454 4.51604 8.422 4.46884 8.3312 4.40428C8.2401 4.3395 8.1605 4.2599 8.09572 4.1688C8.03116 4.078 7.98396 3.96546 7.83376 3.57494L6.96667 1.32051Z" fill="#444CE7" />
</svg>
)
export const ChatBtn: FC<{ onClick: () => void, className?: string }> = ({
className,
onClick
}) => {
const { t } = useTranslation()
return (
<Button
type='primary'
className={cn(className, `space-x-2 flex items-center ${s.customBtn}`)}
onClick={onClick}>
<svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" clipRule="evenodd" d="M18 10.5C18 14.366 14.418 17.5 10 17.5C8.58005 17.506 7.17955 17.1698 5.917 16.52L2 17.5L3.338 14.377C2.493 13.267 2 11.934 2 10.5C2 6.634 5.582 3.5 10 3.5C14.418 3.5 18 6.634 18 10.5ZM7 9.5H5V11.5H7V9.5ZM15 9.5H13V11.5H15V9.5ZM9 9.5H11V11.5H9V9.5Z" fill="white" />
</svg>
{t('share.chat.startChat')}
</Button>
)
}
export const EditBtn = ({ className, onClick }: { className?: string, onClick: () => void }) => {
const { t } = useTranslation()
return (
<div
className={cn('px-2 flex space-x-1 items-center rounded-md cursor-pointer', className)}
onClick={onClick}
>
<PencilIcon className='w-3 h-3' />
<span>{t('common.operation.edit')}</span>
</div>
)
}
export const FootLogo = () => (
<div className={s.logo} />
)

View File

@@ -0,0 +1,29 @@
.boxShodow {
box-shadow: 0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03);
}
.bgGrayColor {
background-color: #F9FAFB;
}
.headerBg {
height: 3.5rem;
padding-left: 1.5rem;
padding-right: 1.5rem;
}
.formLabel {
width: 120px;
margin-right: 8px;
}
.customBtn {
width: 136px;
}
.logo {
width: 48px;
height: 20px;
background: url(./icons/logo.png) center center no-repeat;
background-size: contain;
}