mirror of
https://github.com/langgenius/dify.git
synced 2026-05-06 02:18:08 +08:00
Initial commit
This commit is contained in:
13
web/app/components/share/chat/config-scence/index.tsx
Normal file
13
web/app/components/share/chat/config-scence/index.tsx
Normal 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)
|
||||
66
web/app/components/share/chat/hooks/use-conversation.ts
Normal file
66
web/app/components/share/chat/hooks/use-conversation.ts
Normal 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;
|
||||
506
web/app/components/share/chat/index.tsx
Normal file
506
web/app/components/share/chat/index.tsx
Normal 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)
|
||||
3
web/app/components/share/chat/sidebar/card.module.css
Normal file
3
web/app/components/share/chat/sidebar/card.module.css
Normal file
@ -0,0 +1,3 @@
|
||||
.card:hover {
|
||||
background: linear-gradient(0deg, rgba(235, 245, 255, 0.4), rgba(235, 245, 255, 0.4)), #FFFFFF;
|
||||
}
|
||||
19
web/app/components/share/chat/sidebar/card.tsx
Normal file
19
web/app/components/share/chat/sidebar/card.tsx
Normal 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
|
||||
83
web/app/components/share/chat/sidebar/index.tsx
Normal file
83
web/app/components/share/chat/sidebar/index.tsx
Normal 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)
|
||||
79
web/app/components/share/chat/value-panel/index.tsx
Normal file
79
web/app/components/share/chat/value-panel/index.tsx
Normal 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)
|
||||
@ -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);
|
||||
}
|
||||
BIN
web/app/components/share/chat/welcome/icons/logo.png
Normal file
BIN
web/app/components/share/chat/welcome/icons/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
352
web/app/components/share/chat/welcome/index.tsx
Normal file
352
web/app/components/share/chat/welcome/index.tsx
Normal 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)
|
||||
75
web/app/components/share/chat/welcome/massive-component.tsx
Normal file
75
web/app/components/share/chat/welcome/massive-component.tsx
Normal 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} />
|
||||
)
|
||||
|
||||
29
web/app/components/share/chat/welcome/style.module.css
Normal file
29
web/app/components/share/chat/welcome/style.module.css
Normal 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;
|
||||
}
|
||||
44
web/app/components/share/header.tsx
Normal file
44
web/app/components/share/header.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import {
|
||||
Bars3Icon,
|
||||
PencilSquareIcon,
|
||||
} from '@heroicons/react/24/solid'
|
||||
export type IHeaderProps = {
|
||||
title: string
|
||||
isMobile?: boolean
|
||||
onShowSideBar?: () => void
|
||||
onCreateNewChat?: () => void
|
||||
}
|
||||
const Header: FC<IHeaderProps> = ({
|
||||
title,
|
||||
isMobile,
|
||||
onShowSideBar,
|
||||
onCreateNewChat,
|
||||
}) => {
|
||||
return (
|
||||
<div className="shrink-0 flex items-center justify-between h-12 px-3 bg-gray-100">
|
||||
{isMobile ? (
|
||||
<div
|
||||
className='flex items-center justify-center h-8 w-8 cursor-pointer'
|
||||
onClick={() => onShowSideBar?.()}
|
||||
>
|
||||
<Bars3Icon className="h-4 w-4 text-gray-500" />
|
||||
</div>
|
||||
) : <div></div>}
|
||||
<div className='flex items-center space-x-2'>
|
||||
<AppIcon size="small" />
|
||||
<div className=" text-sm text-gray-800 font-bold">{title}</div>
|
||||
</div>
|
||||
{isMobile ? (
|
||||
<div className='flex items-center justify-center h-8 w-8 cursor-pointer'
|
||||
onClick={() => onCreateNewChat?.()}
|
||||
>
|
||||
<PencilSquareIcon className="h-4 w-4 text-gray-500" />
|
||||
</div>) : <div></div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Header)
|
||||
100
web/app/components/share/text-generation/config-scence/index.tsx
Normal file
100
web/app/components/share/text-generation/config-scence/index.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PlayIcon,
|
||||
} from '@heroicons/react/24/solid'
|
||||
import Select from '@/app/components/base/select'
|
||||
import type { SiteInfo } from '@/models/share'
|
||||
import type { PromptConfig } from '@/models/debug'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { DEFAULT_VALUE_MAX_LEN } from '@/config'
|
||||
|
||||
export type IConfigSenceProps = {
|
||||
siteInfo: SiteInfo
|
||||
promptConfig: PromptConfig
|
||||
inputs: Record<string, any>
|
||||
onInputsChange: (inputs: Record<string, any>) => void
|
||||
query: string
|
||||
onQueryChange: (query: string) => void
|
||||
onSend: () => void
|
||||
}
|
||||
const ConfigSence: FC<IConfigSenceProps> = ({
|
||||
promptConfig,
|
||||
inputs,
|
||||
onInputsChange,
|
||||
query,
|
||||
onQueryChange,
|
||||
onSend,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<section>
|
||||
{/* input form */}
|
||||
<form>
|
||||
{promptConfig.prompt_variables.map(item => (
|
||||
<div className='w-full mt-4' key={item.key}>
|
||||
<label className='text-gray-900 text-sm font-medium'>{item.name}</label>
|
||||
<div className='mt-2'>
|
||||
{item.type === 'select' ? (
|
||||
<Select
|
||||
className='w-full'
|
||||
defaultValue={inputs[item.key]}
|
||||
onSelect={(i) => { onInputsChange({ ...inputs, [item.key]: i.value }) }}
|
||||
items={(item.options || []).map(i => ({ name: i, value: i }))}
|
||||
allowSearch={false}
|
||||
bgClassName='bg-gray-50'
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
className="block w-full p-2 text-gray-900 border border-gray-300 rounded-lg bg-gray-50 sm:text-xs focus:ring-blue-500 focus:border-blue-500 "
|
||||
placeholder={`${item.name}${!item.required ? `(${t('appDebug.variableTable.optional')})` : ''}`}
|
||||
value={inputs[item.key]}
|
||||
onChange={(e) => { onInputsChange({ ...inputs, [item.key]: e.target.value }) }}
|
||||
maxLength={item.max_length || DEFAULT_VALUE_MAX_LEN}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className='mt-6 h-[1px] bg-gray-100'></div>
|
||||
<div className='w-full mt-5'>
|
||||
<label className='text-gray-900 text-sm font-medium'>{t('share.generation.queryTitle')}</label>
|
||||
<div className="mt-2 overflow-hidden rounded-lg bg-gray-50 ">
|
||||
<div className="px-4 py-2 bg-gray-50 rounded-t-lg">
|
||||
<textarea
|
||||
value={query}
|
||||
onChange={(e) => { onQueryChange(e.target.value) }}
|
||||
rows={4}
|
||||
className="w-full px-0 text-sm text-gray-900 border-0 bg-gray-50 focus:outline-none placeholder:bg-gray-50"
|
||||
placeholder={t('share.generation.queryPlaceholder') as string}
|
||||
required
|
||||
>
|
||||
</textarea>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<div className="flex pl-0 space-x-1 sm:pl-2">
|
||||
<span className="bg-gray-100 text-gray-500 text-xs font-medium mr-2 px-2.5 py-0.5 rounded cursor-pointer">{query?.length}</span>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
className='w-[80px] !h-8'
|
||||
onClick={onSend}
|
||||
disabled={!query || query === ''}
|
||||
>
|
||||
<PlayIcon className="shrink-0 w-4 h-4 mr-1" aria-hidden="true" />
|
||||
<span className='uppercase text-[13px]'>{t('share.generation.run')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(ConfigSence)
|
||||
79
web/app/components/share/text-generation/history/index.tsx
Normal file
79
web/app/components/share/text-generation/history/index.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
'use client'
|
||||
import React, { useState } from 'react'
|
||||
import useSWR from 'swr'
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import { fetchHistories } from '@/models/history'
|
||||
import type { History as HistoryItem } from '@/models/history'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { mockAPI } from '@/test/test_util'
|
||||
|
||||
mockAPI()
|
||||
|
||||
export type IHistoryProps = {
|
||||
dictionary: any
|
||||
}
|
||||
|
||||
const HistoryCard = (
|
||||
{ history }: { history: HistoryItem },
|
||||
) => {
|
||||
return (
|
||||
<div className='p-4 h-32 bg-gray-50 border-gray-200 rounded-lg relative flex flex-col justify-between items-center cursor-pointer'>
|
||||
<div className='text-gray-700 text-sm'>
|
||||
{history.source}
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-center m-4" aria-hidden="true">
|
||||
<div className="w-full border-t border-gray-100" />
|
||||
</div>
|
||||
<div className='text-gray-700 text-sm'>
|
||||
{history.target}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const History = ({
|
||||
dictionary,
|
||||
}: IHistoryProps) => {
|
||||
const { data, error } = useSWR('http://localhost:3000/api/histories', fetchHistories)
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
|
||||
const DivideLine = () => {
|
||||
return <div className="mt-6 relative">
|
||||
{/* divider line */}
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="w-full border-t border-gray-300" />
|
||||
</div>
|
||||
<div className="relative flex justify-center flex-col items-center">
|
||||
{!showHistory ? <ChevronUpIcon className="h-3 w-3 text-gray-500" aria-hidden="true" /> : <div className='h-3 w-3' />}
|
||||
<span className="px-2 bg-white text-sm font-medium text-gray-600 cursor-pointer">{dictionary.app.textGeneration.history}</span>
|
||||
{!showHistory ? <div className='h-3 w-3' /> : <ChevronDownIcon className="h-3 w-3 text-gray-500" aria-hidden="true" />}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
if (error)
|
||||
return <div>failed to load</div>
|
||||
if (!data)
|
||||
return <Loading />
|
||||
return showHistory
|
||||
? <div className='w-1/2 block fixed bottom-0 right-0 px-10 py-4' onClick={
|
||||
() => setShowHistory(v => !v)
|
||||
}>
|
||||
<DivideLine />
|
||||
<div
|
||||
className='mt-4 grid grid-cols-3 space-x-4 h-[400px] overflow-auto'
|
||||
>
|
||||
{data.histories.map((item: HistoryItem) =>
|
||||
<HistoryCard key={item.id} history={item} />)}
|
||||
</div>
|
||||
</div>
|
||||
: <div className='w-1/2 block fixed bottom-0 right-0 px-10 py-4' onClick={
|
||||
() => setShowHistory(true)
|
||||
}>
|
||||
<DivideLine />
|
||||
</div>
|
||||
}
|
||||
export default History
|
||||
22
web/app/components/share/text-generation/icons/app-icon.svg
Normal file
22
web/app/components/share/text-generation/icons/app-icon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 26 KiB |
5
web/app/components/share/text-generation/icons/star.svg
Normal file
5
web/app/components/share/text-generation/icons/star.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.66699 1.33366C3.66699 0.965469 3.36852 0.666992 3.00033 0.666992C2.63214 0.666992 2.33366 0.965469 2.33366 1.33366V2.33366H1.33366C0.965469 2.33366 0.666992 2.63214 0.666992 3.00033C0.666992 3.36852 0.965469 3.66699 1.33366 3.66699H2.33366V4.66699C2.33366 5.03518 2.63214 5.33366 3.00033 5.33366C3.36852 5.33366 3.66699 5.03518 3.66699 4.66699V3.66699H4.66699C5.03518 3.66699 5.33366 3.36852 5.33366 3.00033C5.33366 2.63214 5.03518 2.33366 4.66699 2.33366H3.66699V1.33366Z" fill="#444CE7"/>
|
||||
<path d="M3.66699 11.3337C3.66699 10.9655 3.36852 10.667 3.00033 10.667C2.63214 10.667 2.33366 10.9655 2.33366 11.3337V12.3337H1.33366C0.965469 12.3337 0.666992 12.6321 0.666992 13.0003C0.666992 13.3685 0.965469 13.667 1.33366 13.667H2.33366V14.667C2.33366 15.0352 2.63214 15.3337 3.00033 15.3337C3.36852 15.3337 3.66699 15.0352 3.66699 14.667V13.667H4.66699C5.03518 13.667 5.33366 13.3685 5.33366 13.0003C5.33366 12.6321 5.03518 12.3337 4.66699 12.3337H3.66699V11.3337Z" fill="#444CE7"/>
|
||||
<path d="M9.28922 1.76101C9.1902 1.50354 8.94284 1.33366 8.66699 1.33366C8.39114 1.33366 8.14378 1.50354 8.04476 1.76101L6.88864 4.76691C6.68837 5.28761 6.62544 5.43766 6.53936 5.55872C6.45299 5.68019 6.34686 5.78632 6.22539 5.87269C6.10432 5.95878 5.95428 6.02171 5.43358 6.22198L2.42767 7.37809C2.17021 7.47712 2.00033 7.72448 2.00033 8.00033C2.00033 8.27617 2.17021 8.52353 2.42767 8.62256L5.43358 9.77867C5.95428 9.97894 6.10432 10.0419 6.22539 10.128C6.34686 10.2143 6.45299 10.3205 6.53936 10.4419C6.62544 10.563 6.68837 10.713 6.88864 11.2337L8.04476 14.2396C8.14379 14.4971 8.39114 14.667 8.66699 14.667C8.94284 14.667 9.1902 14.4971 9.28922 14.2396L10.4453 11.2337C10.6456 10.713 10.7085 10.563 10.7946 10.4419C10.881 10.3205 10.9871 10.2143 11.1086 10.128C11.2297 10.0419 11.3797 9.97894 11.9004 9.77867L14.9063 8.62256C15.1638 8.52353 15.3337 8.27617 15.3337 8.00033C15.3337 7.72448 15.1638 7.47712 14.9063 7.37809L11.9004 6.22198C11.3797 6.02171 11.2297 5.95878 11.1086 5.87269C10.9871 5.78632 10.881 5.68019 10.7946 5.55872C10.7085 5.43766 10.6456 5.28761 10.4453 4.76691L9.28922 1.76101Z" fill="#444CE7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
349
web/app/components/share/text-generation/index.tsx
Normal file
349
web/app/components/share/text-generation/index.tsx
Normal file
@ -0,0 +1,349 @@
|
||||
'use client'
|
||||
import React, { useEffect, useState, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import cn from 'classnames'
|
||||
import { useBoolean, useClickAway } from 'ahooks'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import ConfigScence from '@/app/components/share/text-generation/config-scence'
|
||||
import NoData from '@/app/components/share/text-generation/no-data'
|
||||
// import History from '@/app/components/share/text-generation/history'
|
||||
import { fetchAppInfo, fetchAppParams, sendCompletionMessage, updateFeedback, saveMessage, fetchSavedMessage as doFetchSavedMessage, removeMessage } from '@/service/share'
|
||||
import type { SiteInfo } from '@/models/share'
|
||||
import type { PromptConfig, MoreLikeThisConfig, SavedMessage } from '@/models/debug'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { Feedbacktype } from '@/app/components/app/chat'
|
||||
import { changeLanguage } from '@/i18n/i18next-config'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { userInputsFormToPromptVariables } from '@/utils/model-config'
|
||||
import TextGenerationRes from '@/app/components/app/text-generate/item'
|
||||
import SavedItems from '@/app/components/app/text-generate/saved-items'
|
||||
import TabHeader from '../../base/tab-header'
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||
import s from './style.module.css'
|
||||
import Button from '../../base/button'
|
||||
|
||||
const TextGeneration = () => {
|
||||
const { t } = useTranslation()
|
||||
const media = useBreakpoints()
|
||||
const isPC = media === MediaType.pc
|
||||
const isTablet = media === MediaType.tablet
|
||||
const isMoble = media === MediaType.mobile
|
||||
|
||||
const [currTab, setCurrTab] = useState<string>('create')
|
||||
|
||||
const [inputs, setInputs] = useState<Record<string, any>>({})
|
||||
const [appId, setAppId] = useState<string>('')
|
||||
const [siteInfo, setSiteInfo] = useState<SiteInfo | null>(null)
|
||||
const [promptConfig, setPromptConfig] = useState<PromptConfig | null>(null)
|
||||
const [moreLikeThisConifg, setMoreLikeThisConifg] = useState<MoreLikeThisConfig | null>(null)
|
||||
const [isResponsing, { setTrue: setResponsingTrue, setFalse: setResponsingFalse }] = useBoolean(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [completionRes, setCompletionRes] = useState('')
|
||||
const { notify } = Toast
|
||||
const isNoData = !completionRes
|
||||
|
||||
const [messageId, setMessageId] = useState<string | null>(null)
|
||||
const [feedback, setFeedback] = useState<Feedbacktype>({
|
||||
rating: null
|
||||
})
|
||||
|
||||
const handleFeedback = async (feedback: Feedbacktype) => {
|
||||
await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating } })
|
||||
setFeedback(feedback)
|
||||
}
|
||||
|
||||
const [savedMessages, setSavedMessages] = useState<SavedMessage[]>([])
|
||||
|
||||
const fetchSavedMessage = async () => {
|
||||
const res: any = await doFetchSavedMessage()
|
||||
setSavedMessages(res.data)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchSavedMessage()
|
||||
}, [])
|
||||
|
||||
const handleSaveMessage = async (messageId: string) => {
|
||||
await saveMessage(messageId)
|
||||
notify({ type: 'success', message: t('common.api.saved') })
|
||||
fetchSavedMessage()
|
||||
}
|
||||
|
||||
const handleRemoveSavedMessage = async (messageId: string) => {
|
||||
await removeMessage(messageId)
|
||||
notify({ type: 'success', message: t('common.api.remove') })
|
||||
fetchSavedMessage()
|
||||
}
|
||||
|
||||
const logError = (message: string) => {
|
||||
notify({ type: 'error', message })
|
||||
}
|
||||
|
||||
const checkCanSend = () => {
|
||||
const prompt_variables = promptConfig?.prompt_variables
|
||||
if (!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 handleSend = async () => {
|
||||
if (isResponsing) {
|
||||
notify({ type: 'info', message: t('appDebug.errorMessage.waitForResponse') })
|
||||
return false
|
||||
}
|
||||
|
||||
if (!checkCanSend())
|
||||
return
|
||||
|
||||
if (!query) {
|
||||
logError(t('appDebug.errorMessage.queryRequired'))
|
||||
return false
|
||||
}
|
||||
|
||||
const data = {
|
||||
inputs,
|
||||
query,
|
||||
}
|
||||
|
||||
setMessageId(null)
|
||||
setFeedback({
|
||||
rating: null
|
||||
})
|
||||
setCompletionRes('')
|
||||
|
||||
const res: string[] = []
|
||||
let tempMessageId = ''
|
||||
|
||||
if (!isPC) {
|
||||
showResSidebar()
|
||||
}
|
||||
setResponsingTrue()
|
||||
sendCompletionMessage(data, {
|
||||
onData: (data: string, _isFirstMessage: boolean, { messageId }: any) => {
|
||||
tempMessageId = messageId
|
||||
res.push(data)
|
||||
setCompletionRes(res.join(''))
|
||||
},
|
||||
onCompleted: () => {
|
||||
setResponsingFalse()
|
||||
setMessageId(tempMessageId)
|
||||
},
|
||||
onError() {
|
||||
setResponsingFalse()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const [appData, appParams]: any = await Promise.all([fetchAppInfo(), fetchAppParams()])
|
||||
const { app_id: appId, site: siteInfo } = appData
|
||||
setAppId(appId)
|
||||
setSiteInfo(siteInfo as SiteInfo)
|
||||
changeLanguage(siteInfo.default_language)
|
||||
|
||||
const { user_input_form, more_like_this }: any = appParams
|
||||
const prompt_variables = userInputsFormToPromptVariables(user_input_form)
|
||||
setPromptConfig({
|
||||
prompt_template: '', // placeholder for feture
|
||||
prompt_variables,
|
||||
} as PromptConfig)
|
||||
setMoreLikeThisConifg(more_like_this)
|
||||
})()
|
||||
}, [])
|
||||
|
||||
// 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)
|
||||
document.title = `${siteInfo.title} - Powered by Dify`
|
||||
}, [siteInfo?.title])
|
||||
|
||||
const [isShowResSidebar, { setTrue: showResSidebar, setFalse: hideResSidebar }] = useBoolean(false)
|
||||
const resRef = useRef<HTMLDivElement>(null)
|
||||
useClickAway(() => {
|
||||
hideResSidebar();
|
||||
}, resRef)
|
||||
|
||||
const renderRes = (
|
||||
<div
|
||||
ref={resRef}
|
||||
className={
|
||||
cn("flex flex-col h-full shrink-0",
|
||||
isPC ? 'px-10 py-8' : 'bg-gray-50',
|
||||
isTablet && 'p-6', isMoble && 'p-4')}
|
||||
>
|
||||
<>
|
||||
<div className='shrink-0 flex items-center justify-between'>
|
||||
<div className='flex items-center space-x-3'>
|
||||
<div className={s.starIcon}></div>
|
||||
<div className='text-lg text-gray-800 font-semibold'>{t('share.generation.title')}</div>
|
||||
</div>
|
||||
{!isPC && (
|
||||
<div
|
||||
className='flex items-center justify-center cursor-pointer'
|
||||
onClick={hideResSidebar}
|
||||
>
|
||||
<XMarkIcon className='w-4 h-4 text-gray-800' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='grow'>
|
||||
{(isResponsing && !completionRes) ? (
|
||||
<div className='flex h-full w-full justify-center items-center'>
|
||||
<Loading type='area' />
|
||||
</div>) : (
|
||||
<>
|
||||
{isNoData
|
||||
? <NoData />
|
||||
: (
|
||||
<TextGenerationRes
|
||||
className='mt-3'
|
||||
content={completionRes}
|
||||
messageId={messageId}
|
||||
isInWebApp
|
||||
moreLikeThis={moreLikeThisConifg?.enabled}
|
||||
onFeedback={handleFeedback}
|
||||
feedback={feedback}
|
||||
onSave={handleSaveMessage}
|
||||
isMobile={isMoble}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!appId || !siteInfo || !promptConfig)
|
||||
return <Loading type='app' />
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn(isPC && 'flex', 'h-screen bg-gray-50')}>
|
||||
{/* Left */}
|
||||
<div className={cn(isPC ? 'w-[600px] max-w-[50%] p-8' : 'p-4', "shrink-0 relative flex flex-col pb-10 h-full border-r border-gray-100 bg-white")}>
|
||||
<div className='mb-6'>
|
||||
<div className='flex justify-between items-center'>
|
||||
<div className='flex items-center space-x-3'>
|
||||
<div className={cn(s.appIcon, 'shrink-0')}></div>
|
||||
<div className='text-lg text-gray-800 font-semibold'>{siteInfo.title}</div>
|
||||
</div>
|
||||
{!isPC && (
|
||||
<Button
|
||||
className='shrink-0 !h-8 !px-3 ml-2'
|
||||
onClick={showResSidebar}
|
||||
>
|
||||
<div className='flex items-center space-x-2 text-primary-600 text-[13px] font-medium'>
|
||||
<div className={s.starIcon}></div>
|
||||
<span>{t('share.generation.title')}</span>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{siteInfo.description && (
|
||||
<div className='mt-2 text-xs text-gray-500'>{siteInfo.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<TabHeader
|
||||
items={[
|
||||
{ id: 'create', name: t('share.generation.tabs.create') },
|
||||
{
|
||||
id: 'saved', name: t('share.generation.tabs.saved'), extra: savedMessages.length > 0 ? (
|
||||
<div className='ml-1 flext items-center h-5 px-1.5 rounded-md border border-gray-200 text-gray-500 text-xs font-medium'>
|
||||
{savedMessages.length}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
]}
|
||||
value={currTab}
|
||||
onChange={setCurrTab}
|
||||
/>
|
||||
<div className='grow h-20 overflow-y-auto'>
|
||||
{currTab === 'create' && (
|
||||
<ConfigScence
|
||||
siteInfo={siteInfo}
|
||||
inputs={inputs}
|
||||
onInputsChange={setInputs}
|
||||
promptConfig={promptConfig}
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
onSend={handleSend}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currTab === 'saved' && (
|
||||
<SavedItems
|
||||
className='mt-4'
|
||||
list={savedMessages}
|
||||
onRemove={handleRemoveSavedMessage}
|
||||
onStartCreateContent={() => setCurrTab('create')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* copyright */}
|
||||
<div className='fixed left-8 bottom-4 flex space-x-2 text-gray-400 font-normal text-xs'>
|
||||
<div className="">© {siteInfo.copyright || siteInfo.title} {(new Date()).getFullYear()}</div>
|
||||
{siteInfo.privacy_policy && (
|
||||
<>
|
||||
<div>·</div>
|
||||
<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>
|
||||
|
||||
{/* Result */}
|
||||
{isPC && (
|
||||
<div className='grow h-full'>
|
||||
{renderRes}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!isPC && isShowResSidebar) && (
|
||||
<div
|
||||
className={cn('fixed z-50 inset-0', isTablet ? 'pl-[128px]' : 'pl-6')}
|
||||
style={{
|
||||
background: 'rgba(35, 56, 118, 0.2)'
|
||||
}}
|
||||
>
|
||||
{renderRes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default TextGeneration
|
||||
26
web/app/components/share/text-generation/no-data/index.tsx
Normal file
26
web/app/components/share/text-generation/no-data/index.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const StarIcon = (
|
||||
<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.50033 48.3337V36.667M7.50033 13.3337V1.66699M1.66699 7.50033H13.3337M1.66699 42.5003H13.3337M27.3337 4.00032L23.2872 14.521C22.6292 16.2319 22.3002 17.0873 21.7886 17.8069C21.3351 18.4446 20.7779 19.0018 20.1402 19.4552C19.4206 19.9669 18.5652 20.2959 16.8543 20.9539L6.33366 25.0003L16.8543 29.0467C18.5652 29.7048 19.4206 30.0338 20.1402 30.5454C20.7779 30.9989 21.3351 31.5561 21.7886 32.1938C22.3002 32.9133 22.6292 33.7688 23.2872 35.4796L27.3337 46.0003L31.3801 35.4796C32.0381 33.7688 32.3671 32.9133 32.8788 32.1938C33.3322 31.5561 33.8894 30.9989 34.5271 30.5454C35.2467 30.0338 36.1021 29.7048 37.813 29.0467L48.3337 25.0003L37.813 20.9539C36.1021 20.2959 35.2467 19.9669 34.5271 19.4552C33.8894 19.0018 33.3322 18.4446 32.8788 17.8069C32.3671 17.0873 32.0381 16.2319 31.3801 14.521L27.3337 4.00032Z" stroke="#EAECF0" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
|
||||
)
|
||||
|
||||
export type INoDataProps = {}
|
||||
const NoData: FC<INoDataProps> = () => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className='flex flex-col h-full w-full justify-center items-center'>
|
||||
{StarIcon}
|
||||
<div
|
||||
className='mt-3 text-gray-300 text-xs leading-3'
|
||||
>
|
||||
{t('share.generation.noData')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(NoData)
|
||||
116
web/app/components/share/text-generation/result/header.tsx
Normal file
116
web/app/components/share/text-generation/result/header.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ClipboardDocumentIcon, HandThumbDownIcon, HandThumbUpIcon } from '@heroicons/react/24/outline'
|
||||
import { Feedbacktype } from '@/app/components/app/chat'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
|
||||
// import useCopyToClipboard from '@/hooks/use-copy-to-clipboard'
|
||||
import copy from 'copy-to-clipboard'
|
||||
type IResultHeaderProps = {
|
||||
result: string
|
||||
showFeedback: boolean
|
||||
feedback: Feedbacktype
|
||||
onFeedback: (feedback: Feedbacktype) => void
|
||||
}
|
||||
|
||||
const Header: FC<IResultHeaderProps> = ({
|
||||
feedback,
|
||||
showFeedback,
|
||||
onFeedback,
|
||||
result,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className='flex w-full justify-between items-center '>
|
||||
<div className='text-gray-800 text-2xl leading-4 font-normal'>{t('share.generation.resultTitle')}</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Button
|
||||
className='flex items-center !h-7 !p-[2px] !pr-2'
|
||||
onClick={() => {
|
||||
copy(result)
|
||||
Toast.notify({ type: 'success', message: 'copied' })
|
||||
}}
|
||||
>
|
||||
<>
|
||||
<ClipboardDocumentIcon className='text-gray-500 w-4 h-3 mr-1' />
|
||||
<span className='text-gray-500 text-xs leading-3'>{t('share.generation.copy')}</span>
|
||||
</>
|
||||
</Button>
|
||||
|
||||
{showFeedback && feedback.rating && feedback.rating === 'like' && (
|
||||
<Tooltip
|
||||
selector="undo-feedback-like"
|
||||
content="Undo Great Rating"
|
||||
>
|
||||
<div
|
||||
onClick={() => {
|
||||
onFeedback({
|
||||
rating: null
|
||||
})
|
||||
}}
|
||||
className='flex w-7 h-7 items-center justify-center rounded-md cursor-pointer !text-primary-600 border border-primary-200 bg-primary-100 hover:border-primary-300 hover:bg-primary-200'>
|
||||
<HandThumbUpIcon width={16} height={16} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{showFeedback && feedback.rating && feedback.rating === 'dislike' && (
|
||||
<Tooltip
|
||||
selector="undo-feedback-dislike"
|
||||
content="Undo Undesirable Response"
|
||||
>
|
||||
<div
|
||||
onClick={() => {
|
||||
onFeedback({
|
||||
rating: null
|
||||
})
|
||||
}}
|
||||
className='flex w-7 h-7 items-center justify-center rounded-md cursor-pointer !text-red-600 border border-red-200 bg-red-100 hover:border-red-300 hover:bg-red-200'>
|
||||
<HandThumbDownIcon width={16} height={16} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{showFeedback && !feedback.rating && (
|
||||
<div className='flex rounded-lg border border-gray-200 p-[1px] space-x-1'>
|
||||
<Tooltip
|
||||
selector="feedback-like"
|
||||
content="Great Rating"
|
||||
>
|
||||
<div
|
||||
onClick={() => {
|
||||
onFeedback({
|
||||
rating: 'like'
|
||||
})
|
||||
}}
|
||||
className='flex w-6 h-6 items-center justify-center rounded-md cursor-pointer hover:bg-gray-100'>
|
||||
<HandThumbUpIcon width={16} height={16} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
selector="feedback-dislike"
|
||||
content="Undesirable Response"
|
||||
>
|
||||
<div
|
||||
onClick={() => {
|
||||
onFeedback({
|
||||
rating: 'dislike'
|
||||
})
|
||||
}}
|
||||
className='flex w-6 h-6 items-center justify-center rounded-md cursor-pointer hover:bg-gray-100'>
|
||||
<HandThumbDownIcon width={16} height={16} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Header)
|
||||
34
web/app/components/share/text-generation/result/index.tsx
Normal file
34
web/app/components/share/text-generation/result/index.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import Header from './header'
|
||||
import { Feedbacktype } from '@/app/components/app/chat'
|
||||
import { format } from '@/service/base'
|
||||
|
||||
export type IResultProps = {
|
||||
content: string
|
||||
showFeedback: boolean
|
||||
feedback: Feedbacktype
|
||||
onFeedback: (feedback: Feedbacktype) => void
|
||||
}
|
||||
const Result: FC<IResultProps> = ({
|
||||
content,
|
||||
showFeedback,
|
||||
feedback,
|
||||
onFeedback
|
||||
}) => {
|
||||
return (
|
||||
<div className='basis-3/4 h-max'>
|
||||
<Header result={content} showFeedback={showFeedback} feedback={feedback} onFeedback={onFeedback} />
|
||||
<div
|
||||
className='mt-4 w-full flex text-sm leading-5 overflow-scroll font-normal text-gray-900'
|
||||
style={{
|
||||
maxHeight: '70vh'
|
||||
}}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: format(content)
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(Result)
|
||||
13
web/app/components/share/text-generation/style.module.css
Normal file
13
web/app/components/share/text-generation/style.module.css
Normal file
@ -0,0 +1,13 @@
|
||||
.appIcon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: url(./icons/app-icon.svg) center center no-repeat;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
.starIcon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: url(./icons/star.svg) center center no-repeat;
|
||||
background-size: contain;
|
||||
}
|
||||
Reference in New Issue
Block a user