Feat/embedding (#553)

Co-authored-by: Gillian97 <jinling.sunshine@gmail.com>
Co-authored-by: Joel <iamjoel007@gmail.com>
This commit is contained in:
zxhlyh 2023-07-12 17:27:50 +08:00 committed by GitHub
parent 397a92f2ee
commit fec607db81
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 2208 additions and 41 deletions

View File

@ -0,0 +1,13 @@
import type { FC } from 'react'
import React from 'react'
import type { IMainProps } from '@/app/components/share/chat'
import Main from '@/app/components/share/chatbot'
const Chatbot: FC<IMainProps> = () => {
return (
<Main />
)
}
export default React.memo(Chatbot)

View File

@ -473,7 +473,7 @@ const Chat: FC<IChatProps> = ({
} }
} }
const haneleKeyDown = (e: any) => { const handleKeyDown = (e: any) => {
isUseInputMethod.current = e.nativeEvent.isComposing isUseInputMethod.current = e.nativeEvent.isComposing
if (e.code === 'Enter' && !e.shiftKey) { if (e.code === 'Enter' && !e.shiftKey) {
setQuery(query.replace(/\n$/, '')) setQuery(query.replace(/\n$/, ''))
@ -573,7 +573,7 @@ const Chat: FC<IChatProps> = ({
value={query} value={query}
onChange={handleContentChange} onChange={handleContentChange}
onKeyUp={handleKeyUp} onKeyUp={handleKeyUp}
onKeyDown={haneleKeyDown} onKeyDown={handleKeyDown}
minHeight={48} minHeight={48}
autoFocus autoFocus
controlFocus={controlFocus} controlFocus={controlFocus}

View File

@ -1,4 +1,5 @@
'use client' 'use client'
import type { FC } from 'react'
import React, { useState } from 'react' import React, { useState } from 'react'
import { import {
Cog8ToothIcon, Cog8ToothIcon,
@ -11,6 +12,7 @@ import { usePathname, useRouter } from 'next/navigation'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import SettingsModal from './settings' import SettingsModal from './settings'
import ShareLink from './share-link' import ShareLink from './share-link'
import EmbeddedModal from './embedded'
import CustomizeModal from './customize' import CustomizeModal from './customize'
import Tooltip from '@/app/components/base/tooltip' import Tooltip from '@/app/components/base/tooltip'
import AppBasic, { randomString } from '@/app/components/app-sidebar/basic' import AppBasic, { randomString } from '@/app/components/app-sidebar/basic'
@ -18,6 +20,8 @@ import Button from '@/app/components/base/button'
import Tag from '@/app/components/base/tag' import Tag from '@/app/components/base/tag'
import Switch from '@/app/components/base/switch' import Switch from '@/app/components/base/switch'
import type { AppDetailResponse } from '@/models/app' import type { AppDetailResponse } from '@/models/app'
import './style.css'
import { AppType } from '@/types/app'
export type IAppCardProps = { export type IAppCardProps = {
className?: string className?: string
@ -29,6 +33,10 @@ export type IAppCardProps = {
onGenerateCode?: () => Promise<any> onGenerateCode?: () => Promise<any>
} }
const EmbedIcon: FC<{ className?: string }> = ({ className = '' }) => {
return <div className={`codeBrowserIcon ${className}`}></div>
}
function AppCard({ function AppCard({
appInfo, appInfo,
cardType = 'app', cardType = 'app',
@ -42,6 +50,7 @@ function AppCard({
const pathname = usePathname() const pathname = usePathname()
const [showSettingsModal, setShowSettingsModal] = useState(false) const [showSettingsModal, setShowSettingsModal] = useState(false)
const [showShareModal, setShowShareModal] = useState(false) const [showShareModal, setShowShareModal] = useState(false)
const [showEmbedded, setShowEmbedded] = useState(false)
const [showCustomizeModal, setShowCustomizeModal] = useState(false) const [showCustomizeModal, setShowCustomizeModal] = useState(false)
const { t } = useTranslation() const { t } = useTranslation()
@ -49,8 +58,9 @@ function AppCard({
webapp: [ webapp: [
{ opName: t('appOverview.overview.appInfo.preview'), opIcon: RocketLaunchIcon }, { opName: t('appOverview.overview.appInfo.preview'), opIcon: RocketLaunchIcon },
{ opName: t('appOverview.overview.appInfo.share.entry'), opIcon: ShareIcon }, { opName: t('appOverview.overview.appInfo.share.entry'), opIcon: ShareIcon },
appInfo.mode === AppType.chat ? { opName: t('appOverview.overview.appInfo.embedded.entry'), opIcon: EmbedIcon } : false,
{ opName: t('appOverview.overview.appInfo.settings.entry'), opIcon: Cog8ToothIcon }, { opName: t('appOverview.overview.appInfo.settings.entry'), opIcon: Cog8ToothIcon },
], ].filter(item => !!item),
api: [{ opName: t('appOverview.overview.apiInfo.doc'), opIcon: DocumentTextIcon }], api: [{ opName: t('appOverview.overview.apiInfo.doc'), opIcon: DocumentTextIcon }],
app: [], app: [],
} }
@ -80,6 +90,10 @@ function AppCard({
return () => { return () => {
setShowSettingsModal(true) setShowSettingsModal(true)
} }
case t('appOverview.overview.appInfo.embedded.entry'):
return () => {
setShowEmbedded(true)
}
default: default:
// jump to page develop // jump to page develop
return () => { return () => {
@ -139,20 +153,20 @@ function AppCard({
key={op.opName} key={op.opName}
onClick={genClickFuncByName(op.opName)} onClick={genClickFuncByName(op.opName)}
disabled={ disabled={
[t('appOverview.overview.appInfo.preview'), t('appOverview.overview.appInfo.share.entry')].includes(op.opName) && !runningStatus [t('appOverview.overview.appInfo.preview'), t('appOverview.overview.appInfo.share.entry'), t('appOverview.overview.appInfo.embedded.entry')].includes(op.opName) && !runningStatus
} }
> >
<Tooltip <Tooltip
content={t('appOverview.overview.appInfo.preUseReminder') ?? ''} content={t('appOverview.overview.appInfo.preUseReminder') ?? ''}
selector={`op-btn-${randomString(16)}`} selector={`op-btn-${randomString(16)}`}
className={ className={
([t('appOverview.overview.appInfo.preview'), t('appOverview.overview.appInfo.share.entry')].includes(op.opName) && !runningStatus) ([t('appOverview.overview.appInfo.preview'), t('appOverview.overview.appInfo.share.entry'), t('appOverview.overview.appInfo.embedded.entry')].includes(op.opName) && !runningStatus)
? 'mt-[-8px]' ? 'mt-[-8px]'
: '!hidden' : '!hidden'
} }
> >
<div className="flex flex-row items-center"> <div className="flex flex-row items-center">
<op.opIcon className="h-4 w-4 mr-1.5" /> <op.opIcon className="h-4 w-4 mr-1.5 stroke-[1.8px]" />
<span className="text-xs">{op.opName}</span> <span className="text-xs">{op.opName}</span>
</div> </div>
</Tooltip> </Tooltip>
@ -193,6 +207,12 @@ function AppCard({
onClose={() => setShowSettingsModal(false)} onClose={() => setShowSettingsModal(false)}
onSave={onSaveSiteConfig} onSave={onSaveSiteConfig}
/> />
<EmbeddedModal
isShow={showEmbedded}
onClose={() => setShowEmbedded(false)}
appBaseUrl={app_base_url}
accessToken={access_token}
/>
<CustomizeModal <CustomizeModal
isShow={showCustomizeModal} isShow={showCustomizeModal}
linkUrl="" linkUrl=""

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.6667 6H1.33337M9.33337 11.6667L11 10L9.33337 8.33333M6.66671 8.33333L5.00004 10L6.66671 11.6667M1.33337 5.2L1.33337 10.8C1.33337 11.9201 1.33337 12.4802 1.55136 12.908C1.74311 13.2843 2.04907 13.5903 2.42539 13.782C2.85322 14 3.41327 14 4.53337 14H11.4667C12.5868 14 13.1469 14 13.5747 13.782C13.951 13.5903 14.257 13.2843 14.4487 12.908C14.6667 12.4802 14.6667 11.9201 14.6667 10.8V5.2C14.6667 4.0799 14.6667 3.51984 14.4487 3.09202C14.257 2.7157 13.951 2.40973 13.5747 2.21799C13.1469 2 12.5868 2 11.4667 2L4.53337 2C3.41327 2 2.85322 2 2.42539 2.21799C2.04907 2.40973 1.74311 2.71569 1.55136 3.09202C1.33337 3.51984 1.33337 4.0799 1.33337 5.2Z" stroke="#344054" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 850 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 43 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 47 KiB

View File

@ -0,0 +1,111 @@
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import cn from 'classnames'
import style from './style.module.css'
import Modal from '@/app/components/base/modal'
import useCopyToClipboard from '@/hooks/use-copy-to-clipboard'
import copyStyle from '@/app/components/app/chat/copy-btn/style.module.css'
import Tooltip from '@/app/components/base/tooltip'
import { useAppContext } from '@/context/app-context'
// const isDevelopment = process.env.NODE_ENV === 'development'
type Props = {
isShow: boolean
onClose: () => void
accessToken: string
appBaseUrl: string
}
const OPTION_MAP = {
iframe: {
getContent: (url: string, token: string) =>
`<iframe
src="${url}/chatbot/${token}"
style="width: 100%; height: 100%; min-height: 700px"
frameborder="0"
allow="microphone">
</iframe>`,
},
scripts: {
getContent: (url: string, token: string, isTestEnv?: boolean) =>
`<script>
window.difyChatbotConfig = { token: '${token}'${isTestEnv ? ', isDev: true' : ''} }
</script>
<script
src="${url}/embed.min.js"
id="${token}"
defer>
</script>`,
},
}
const prefixEmbedded = 'appOverview.overview.appInfo.embedded'
type Option = keyof typeof OPTION_MAP
const Embedded = ({ isShow, onClose, appBaseUrl, accessToken }: Props) => {
const { t } = useTranslation()
const [option, setOption] = useState<Option>('iframe')
const [isCopied, setIsCopied] = useState({ iframe: false, scripts: false })
const [_, copy] = useCopyToClipboard()
const { langeniusVersionInfo } = useAppContext()
const isTestEnv = langeniusVersionInfo.current_env === 'TESTING' || langeniusVersionInfo.current_env === 'DEVELOPMENT'
const onClickCopy = () => {
copy(OPTION_MAP[option].getContent(appBaseUrl, accessToken, isTestEnv))
setIsCopied({ ...isCopied, [option]: true })
}
return (
<Modal
title={t(`${prefixEmbedded}.title`)}
isShow={isShow}
onClose={onClose}
className="!max-w-2xl w-[640px]"
closable={true}
>
<div className="mb-4 mt-8 text-gray-900 text-[14px] font-medium leading-tight">
{t(`${prefixEmbedded}.explanation`)}
</div>
<div className="flex gap-4 items-center">
{Object.keys(OPTION_MAP).map((v, index) => {
return (
<div
key={index}
className={cn(
style.option,
style[`${v}Icon`],
option === v && style.active,
)}
onClick={() => setOption(v as Option)}
></div>
)
})}
</div>
<div className="mt-6 w-full bg-gray-100 rounded-lg flex-col justify-start items-start inline-flex">
<div className="self-stretch pl-3 pr-1 py-1 bg-gray-50 rounded-tl-lg rounded-tr-lg border border-black border-opacity-5 justify-start items-center gap-2 inline-flex">
<div className="grow shrink basis-0 text-slate-700 text-[13px] font-medium leading-none">
{t(`${prefixEmbedded}.${option}`)}
</div>
<div className="p-2 rounded-lg justify-center items-center gap-1 flex">
<Tooltip
selector={'code-copy-feedback'}
content={(isCopied[option] ? t(`${prefixEmbedded}.copied`) : t(`${prefixEmbedded}.copy`)) || ''}
>
<div className="w-8 h-8 cursor-pointer hover:bg-gray-100 rounded-lg">
<div onClick={onClickCopy} className={`w-full h-full ${copyStyle.copyIcon} ${isCopied[option] ? copyStyle.copied : ''}`}></div>
</div>
</Tooltip>
</div>
</div>
<div className="self-stretch p-3 justify-start items-start gap-2 inline-flex">
<div className="grow shrink basis-0 text-slate-700 text-[13px] leading-tight font-mono">
<pre className='select-text'>{OPTION_MAP[option].getContent(appBaseUrl, accessToken, isTestEnv)}</pre>
</div>
</div>
</div>
</Modal>
)
}
export default Embedded

View File

@ -0,0 +1,14 @@
.option {
width: 188px;
height: 128px;
@apply box-border cursor-pointer bg-auto bg-no-repeat bg-center rounded-md;
}
.active {
@apply border-[1.5px] border-[#2970FF];
}
.iframeIcon {
background-image: url(../assets/iframe-option.svg);
}
.scriptsIcon {
background-image: url(../assets/scripts-option.svg);
}

View File

@ -11,3 +11,8 @@
transform: rotate(360deg); transform: rotate(360deg);
} }
} }
.codeBrowserIcon {
@apply w-4 h-4 bg-center bg-no-repeat;
background-image: url(./assets/code-browser.svg);
}

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 ConfigScene: FC<IWelcomeProps> = (props) => {
return (
<div className='mb-5 antialiased font-sans shrink-0'>
<Welcome {...props} />
</div>
)
}
export default React.memo(ConfigScene)

View File

@ -0,0 +1,70 @@
import { useState } from 'react'
import produce from 'immer'
import type { ConversationItem } from '@/models/share'
const storageConversationIdKey = 'conversationIdInfo'
type ConversationInfoType = Omit<ConversationItem, 'inputs' | 'id'>
function useConversation() {
const [conversationList, setConversationList] = useState<ConversationItem[]>([])
const [pinnedConversationList, setPinnedConversationList] = 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,
pinnedConversationList,
setPinnedConversationList,
currConversationId,
setCurrConversationId,
getConversationIdFromStorage,
isNewConversation,
currInputs,
newConversationInputs,
existConversationInputs,
resetNewConversationInputs,
setCurrInputs,
currConversationInfo,
setNewConversationInfo,
setExistConversationInfo,
}
}
export default useConversation

View File

@ -0,0 +1,647 @@
/* eslint-disable @typescript-eslint/no-use-before-define */
'use client'
import type { FC } from 'react'
import React, { useEffect, useRef, useState } 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 { checkOrSetAccessToken } from '../utils'
import AppUnavailable from '../../base/app-unavailable'
import useConversation from './hooks/use-conversation'
import s from './style.module.css'
import { ToastContext } from '@/app/components/base/toast'
import Sidebar from '@/app/components/share/chatbot/sidebar'
import ConfigScene from '@/app/components/share/chatbot/config-scence'
import Header from '@/app/components/share/header'
import { /* delConversation, */ fetchAppInfo, fetchAppParams, fetchChatList, fetchConversations, fetchSuggestedQuestions, pinConversation, sendChatMessage, stopChatMessageResponding, unpinConversation, updateFeedback } from '@/service/share'
import type { ConversationItem, SiteInfo } from '@/models/share'
import type { PromptConfig, SuggestedQuestionsAfterAnswerConfig } 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 { userInputsFormToPromptVariables } from '@/utils/model-config'
import type { InstalledApp } from '@/models/explore'
// import Confirm from '@/app/components/base/confirm'
export type IMainProps = {
isInstalledApp?: boolean
installedAppInfo?: InstalledApp
}
const Main: FC<IMainProps> = ({
isInstalledApp = false,
installedAppInfo,
}) => {
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 [allConversationList, setAllConversationList] = useState<ConversationItem[]>([])
const [isClearConversationList, { setTrue: clearConversationListTrue, setFalse: clearConversationListFalse }] = useBoolean(false)
const [isClearPinnedConversationList, { setTrue: clearPinnedConversationListTrue, setFalse: clearPinnedConversationListFalse }] = useBoolean(false)
const {
conversationList,
setConversationList,
pinnedConversationList,
setPinnedConversationList,
currConversationId,
setCurrConversationId,
getConversationIdFromStorage,
isNewConversation,
currConversationInfo,
currInputs,
newConversationInputs,
// existConversationInputs,
resetNewConversationInputs,
setCurrInputs,
setNewConversationInfo,
setExistConversationInfo,
} = useConversation()
const [hasMore, setHasMore] = useState<boolean>(true)
const [hasPinnedMore, setHasPinnedMore] = useState<boolean>(true)
const onMoreLoaded = ({ data: conversations, has_more }: any) => {
setHasMore(has_more)
if (isClearConversationList) {
setConversationList(conversations)
clearConversationListFalse()
}
else {
setConversationList([...conversationList, ...conversations])
}
}
const onPinnedMoreLoaded = ({ data: conversations, has_more }: any) => {
setHasPinnedMore(has_more)
if (isClearPinnedConversationList) {
setPinnedConversationList(conversations)
clearPinnedConversationListFalse()
}
else {
setPinnedConversationList([...pinnedConversationList, ...conversations])
}
}
const [controlUpdateConversationList, setControlUpdateConversationList] = useState(0)
const noticeUpdateList = () => {
setHasMore(true)
clearConversationListTrue()
setHasPinnedMore(true)
clearPinnedConversationListTrue()
setControlUpdateConversationList(Date.now())
}
const handlePin = async (id: string) => {
await pinConversation(isInstalledApp, installedAppInfo?.id, id)
notify({ type: 'success', message: t('common.api.success') })
noticeUpdateList()
}
const handleUnpin = async (id: string) => {
await unpinConversation(isInstalledApp, installedAppInfo?.id, id)
notify({ type: 'success', message: t('common.api.success') })
noticeUpdateList()
}
const [isShowConfirm, { setTrue: showConfirm, setFalse: hideConfirm }] = useBoolean(false)
const [toDeleteConversationId, setToDeleteConversationId] = useState('')
const handleDelete = (id: string) => {
setToDeleteConversationId(id)
hideSidebar() // mobile
showConfirm()
}
// const didDelete = async () => {
// await delConversation(isInstalledApp, installedAppInfo?.id, toDeleteConversationId)
// notify({ type: 'success', message: t('common.api.success') })
// hideConfirm()
// if (currConversationId === toDeleteConversationId)
// handleConversationIdChange('-1')
// noticeUpdateList()
// }
const [suggestedQuestionsAfterAnswerConfig, setSuggestedQuestionsAfterAnswerConfig] = useState<SuggestedQuestionsAfterAnswerConfig | null>(null)
const [speechToTextConfig, setSpeechToTextConfig] = 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 = allConversationList.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, isInstalledApp, installedAppInfo?.id).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 canEditInputs = !chatList.some(item => item.isAnswer === false) && isNewConversation
const createNewChat = async () => {
// 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 []
}
const fetchAllConversations = () => {
return fetchConversations(isInstalledApp, installedAppInfo?.id, undefined, undefined, 100)
}
const fetchInitData = async () => {
if (!isInstalledApp)
await checkOrSetAccessToken()
return Promise.all([isInstalledApp
? {
app_id: installedAppInfo?.id,
site: {
title: installedAppInfo?.app.name,
prompt_public: false,
copyright: '',
},
plan: 'basic',
}
: fetchAppInfo(), fetchAllConversations(), fetchAppParams(isInstalledApp, installedAppInfo?.id)])
}
// init
useEffect(() => {
(async () => {
try {
const [appData, conversationData, appParams]: any = await fetchInitData()
const { app_id: appId, site: siteInfo, plan }: any = appData
setAppId(appId)
setPlan(plan)
const tempIsPublicVersion = siteInfo.prompt_public
setIsPublicVersion(tempIsPublicVersion)
const prompt_template = ''
// handle current conversation id
const { data: allConversations } = conversationData as { data: ConversationItem[]; has_more: boolean }
const _conversationId = getConversationIdFromStorage(appId)
const isNotNewConversation = allConversations.some(item => item.id === _conversationId)
setAllConversationList(allConversations)
// fetch new conversation info
const { user_input_form, opening_statement: introduction, suggested_questions_after_answer, speech_to_text }: any = appParams
const prompt_variables = userInputsFormToPromptVariables(user_input_form)
if (siteInfo.default_language)
changeLanguage(siteInfo.default_language)
setNewConversationInfo({
name: t('share.chat.newChatDefaultName'),
introduction,
})
setSiteInfo(siteInfo as SiteInfo)
setPromptConfig({
prompt_template,
prompt_variables,
} as PromptConfig)
setSuggestedQuestionsAfterAnswerConfig(suggested_questions_after_answer)
setSpeechToTextConfig(speech_to_text)
// 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 = () => {
if (currConversationId !== '-1')
return true
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 [messageTaskId, setMessageTaskId] = useState('')
const [hasStopResponded, setHasStopResponded, getHasStopResponded] = useGetState(false)
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 = ''
setHasStopResponded(false)
setResponsingTrue()
setIsShowSuggestion(false)
sendChatMessage(data, {
getAbortController: (abortController) => {
setAbortController(abortController)
},
onData: (message: string, isFirstMessage: boolean, { conversationId: newConversationId, messageId, taskId }: any) => {
responseItem.content = responseItem.content + message
responseItem.id = messageId
if (isFirstMessage && newConversationId)
tempNewConversationId = newConversationId
setMessageTaskId(taskId)
// 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
if (getConversationIdChangeBecauseOfNew()) {
const { data: allConversations }: any = await fetchAllConversations()
setAllConversationList(allConversations)
noticeUpdateList()
}
setConversationIdChangeBecauseOfNew(false)
resetNewConversationInputs()
setChatNotStarted()
setCurrConversationId(tempNewConversationId, appId, true)
if (suggestedQuestionsAfterAnswerConfig?.enabled && !getHasStopResponded()) {
const { data }: any = await fetchSuggestedQuestions(responseItem.id, isInstalledApp, installedAppInfo?.id)
setSuggestQuestions(data)
setIsShowSuggestion(true)
}
},
onError() {
setResponsingFalse()
// role back placeholder answer
setChatList(produce(getChatList(), (draft) => {
draft.splice(draft.findIndex(item => item.id === placeholderAnswerId), 1)
}))
},
}, isInstalledApp, installedAppInfo?.id)
}
const handleFeedback = async (messageId: string, feedback: Feedbacktype) => {
await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating } }, isInstalledApp, installedAppInfo?.id)
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}
isClearConversationList={isClearConversationList}
pinnedList={pinnedConversationList}
isClearPinnedConversationList={isClearPinnedConversationList}
onMoreLoaded={onMoreLoaded}
onPinnedMoreLoaded={onPinnedMoreLoaded}
isNoMore={!hasMore}
isPinnedNoMore={!hasPinnedMore}
onCurrentIdChange={handleConversationIdChange}
currentId={currConversationId}
copyRight={siteInfo.copyright || siteInfo.title}
isInstalledApp={isInstalledApp}
installedAppId={installedAppInfo?.id}
siteInfo={siteInfo}
onPin={handlePin}
onUnpin={handleUnpin}
controlUpdateList={controlUpdateConversationList}
onDelete={handleDelete}
/>
)
}
if (appUnavailable)
return <AppUnavailable isUnknwonReason={isUnknwonReason} />
if (!appId || !siteInfo || !promptConfig)
return <Loading type='app' />
return (
<div>
<Header
title={siteInfo.title}
icon={siteInfo.icon || ''}
icon_background={siteInfo.icon_background}
isEmbedScene={true}
isMobile={isMobile}
// onShowSideBar={showSidebar}
// onCreateNewChat={() => handleConversationIdChange('-1')}
/>
<div className={'flex 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={cn(
isInstalledApp ? s.installedApp : 'h-[calc(100vh_-_3rem)]',
'flex-grow flex flex-col overflow-y-auto',
)
}>
<ConfigScene
// conversationName={conversationName}
hasSetInputs={hasSetInputs}
isPublicVersion={isPublicVersion}
siteInfo={siteInfo}
promptConfig={promptConfig}
onStartChat={handleStartChat}
canEditInputs={canEditInputs}
savedInputs={currInputs as Record<string, any>}
onInputsChange={setCurrInputs}
plan={plan}
></ConfigScene>
{
hasSetInputs && (
<div className={cn(doShowSuggestion ? 'pb-[140px]' : (isResponsing ? 'pb-[113px]' : '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}
canStopResponsing={!!messageTaskId}
abortResponsing={async () => {
await stopChatMessageResponding(appId, messageTaskId, isInstalledApp, installedAppInfo?.id)
setHasStopResponded(true)
setResponsingFalse()
}}
checkCanSend={checkCanSend}
controlFocus={controlFocus}
isShowSuggestion={doShowSuggestion}
suggestionList={suggestQuestions}
displayScene='web'
isShowSpeechToText={speechToTextConfig?.enabled}
/>
</div>
</div>)
}
{/* {isShowConfirm && (
<Confirm
title={t('share.chat.deleteConversation.title')}
content={t('share.chat.deleteConversation.content')}
isShow={isShowConfirm}
onClose={hideConfirm}
onConfirm={didDelete}
onCancel={hideConfirm}
/>
)} */}
</div>
</div>
</div>
)
}
export default React.memo(Main)

View File

@ -0,0 +1,28 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import cn from 'classnames'
import { appDefaultIconBackground } from '@/config/index'
import AppIcon from '@/app/components/base/app-icon'
export type IAppInfoProps = {
className?: string
icon: string
icon_background?: string
name: string
}
const AppInfo: FC<IAppInfoProps> = ({
className,
icon,
icon_background,
name,
}) => {
return (
<div className={cn(className, 'flex items-center space-x-3')}>
<AppIcon size="small" icon={icon} background={icon_background || appDefaultIconBackground} />
<div className='w-0 grow text-sm font-semibold text-gray-800 overflow-hidden text-ellipsis whitespace-nowrap'>{name}</div>
</div>
)
}
export default React.memo(AppInfo)

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,151 @@
import React, { useEffect, useState } from 'react'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import {
PencilSquareIcon,
} from '@heroicons/react/24/outline'
import cn from 'classnames'
import Button from '../../../base/button'
import List from './list'
import AppInfo from '@/app/components/share/chat/sidebar/app-info'
// import Card from './card'
import type { ConversationItem, SiteInfo } from '@/models/share'
import { fetchConversations } from '@/service/share'
export type ISidebarProps = {
copyRight: string
currentId: string
onCurrentIdChange: (id: string) => void
list: ConversationItem[]
isClearConversationList: boolean
pinnedList: ConversationItem[]
isClearPinnedConversationList: boolean
isInstalledApp: boolean
installedAppId?: string
siteInfo: SiteInfo
onMoreLoaded: (res: { data: ConversationItem[]; has_more: boolean }) => void
onPinnedMoreLoaded: (res: { data: ConversationItem[]; has_more: boolean }) => void
isNoMore: boolean
isPinnedNoMore: boolean
onPin: (id: string) => void
onUnpin: (id: string) => void
controlUpdateList: number
onDelete: (id: string) => void
}
const Sidebar: FC<ISidebarProps> = ({
copyRight,
currentId,
onCurrentIdChange,
list,
isClearConversationList,
pinnedList,
isClearPinnedConversationList,
isInstalledApp,
installedAppId,
siteInfo,
onMoreLoaded,
onPinnedMoreLoaded,
isNoMore,
isPinnedNoMore,
onPin,
onUnpin,
controlUpdateList,
onDelete,
}) => {
const { t } = useTranslation()
const [hasPinned, setHasPinned] = useState(false)
const checkHasPinned = async () => {
const { data }: any = await fetchConversations(isInstalledApp, installedAppId, undefined, true)
setHasPinned(data.length > 0)
}
useEffect(() => {
checkHasPinned()
}, [])
useEffect(() => {
if (controlUpdateList !== 0)
checkHasPinned()
}, [controlUpdateList])
const maxListHeight = isInstalledApp ? 'max-h-[30vh]' : 'max-h-[40vh]'
return (
<div
className={
cn(
isInstalledApp ? 'tablet:h-[calc(100vh_-_74px)]' : 'tablet:h-[calc(100vh_-_3rem)]',
'shrink-0 flex flex-col bg-white pc:w-[244px] tablet:w-[192px] mobile:w-[240px] border-r border-gray-200 mobile:h-screen',
)
}
>
{isInstalledApp && (
<AppInfo
className='my-4 px-4'
name={siteInfo.title || ''}
icon={siteInfo.icon || ''}
icon_background={siteInfo.icon_background}
/>
)}
<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>
<div className={'flex-grow flex flex-col h-0 overflow-y-auto overflow-x-hidden'}>
{/* pinned list */}
{hasPinned && (
<div className={cn('mt-4 px-4', list.length === 0 && 'flex flex-col flex-grow')}>
<div className='mb-1.5 leading-[18px] text-xs text-gray-500 font-medium uppercase'>{t('share.chat.pinnedTitle')}</div>
<List
className={cn(list.length > 0 ? maxListHeight : 'flex-grow')}
currentId={currentId}
onCurrentIdChange={onCurrentIdChange}
list={pinnedList}
isClearConversationList={isClearPinnedConversationList}
isInstalledApp={isInstalledApp}
installedAppId={installedAppId}
onMoreLoaded={onPinnedMoreLoaded}
isNoMore={isPinnedNoMore}
isPinned={true}
onPinChanged={id => onUnpin(id)}
controlUpdate={controlUpdateList + 1}
onDelete={onDelete}
/>
</div>
)}
{/* unpinned list */}
<div className={cn('mt-4 px-4', !hasPinned && 'flex flex-col flex-grow')}>
{(hasPinned && list.length > 0) && (
<div className='mb-1.5 leading-[18px] text-xs text-gray-500 font-medium uppercase'>{t('share.chat.unpinnedTitle')}</div>
)}
<List
className={cn(hasPinned ? maxListHeight : 'flex-grow')}
currentId={currentId}
onCurrentIdChange={onCurrentIdChange}
list={list}
isClearConversationList={isClearConversationList}
isInstalledApp={isInstalledApp}
installedAppId={installedAppId}
onMoreLoaded={onMoreLoaded}
isNoMore={isNoMore}
isPinned={false}
onPinChanged={id => onPin(id)}
controlUpdate={controlUpdateList + 1}
onDelete={onDelete}
/>
</div>
</div>
<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,115 @@
'use client'
import type { FC } from 'react'
import React, { useRef } from 'react'
import {
ChatBubbleOvalLeftEllipsisIcon,
} from '@heroicons/react/24/outline'
import { useInfiniteScroll } from 'ahooks'
import { ChatBubbleOvalLeftEllipsisIcon as ChatBubbleOvalLeftEllipsisSolidIcon } from '@heroicons/react/24/solid'
import cn from 'classnames'
import s from './style.module.css'
import type { ConversationItem } from '@/models/share'
import { fetchConversations } from '@/service/share'
import ItemOperation from '@/app/components/explore/item-operation'
export type IListProps = {
className: string
currentId: string
onCurrentIdChange: (id: string) => void
list: ConversationItem[]
isClearConversationList: boolean
isInstalledApp: boolean
installedAppId?: string
onMoreLoaded: (res: { data: ConversationItem[]; has_more: boolean }) => void
isNoMore: boolean
isPinned: boolean
onPinChanged: (id: string) => void
controlUpdate: number
onDelete: (id: string) => void
}
const List: FC<IListProps> = ({
className,
currentId,
onCurrentIdChange,
list,
isClearConversationList,
isInstalledApp,
installedAppId,
onMoreLoaded,
isNoMore,
isPinned,
onPinChanged,
controlUpdate,
onDelete,
}) => {
const listRef = useRef<HTMLDivElement>(null)
useInfiniteScroll(
async () => {
if (!isNoMore) {
const lastId = !isClearConversationList ? list[list.length - 1]?.id : undefined
const { data: conversations, has_more }: any = await fetchConversations(isInstalledApp, installedAppId, lastId, isPinned)
onMoreLoaded({ data: conversations, has_more })
}
return { list: [] }
},
{
target: listRef,
isNoMore: () => {
return isNoMore
},
reloadDeps: [isNoMore, controlUpdate],
},
)
return (
<nav
ref={listRef}
className={cn(className, 'shrink-0 space-y-1 bg-white pb-[85px] overflow-y-auto')}
>
{list.map((item) => {
const isCurrent = item.id === currentId
const ItemIcon
= isCurrent ? ChatBubbleOvalLeftEllipsisSolidIcon : ChatBubbleOvalLeftEllipsisIcon
return (
<div
onClick={() => onCurrentIdChange(item.id)}
key={item.id}
className={cn(s.item,
isCurrent
? 'bg-primary-50 text-primary-600'
: 'text-gray-700 hover:bg-gray-200 hover:text-gray-700',
'group flex justify-between items-center rounded-md px-2 py-2 text-sm font-medium cursor-pointer',
)}
>
<div className='flex items-center w-0 grow'>
<ItemIcon
className={cn(
isCurrent
? 'text-primary-600'
: 'text-gray-400 group-hover:text-gray-500',
'mr-3 h-5 w-5 flex-shrink-0',
)}
aria-hidden="true"
/>
<span>{item.name}</span>
</div>
{item.id !== '-1' && (
<div className={cn(s.opBtn, 'shrink-0')} onClick={e => e.stopPropagation()}>
<ItemOperation
isPinned={isPinned}
togglePin={() => onPinChanged(item.id)}
isShowDelete
onDelete={() => onDelete(item.id)}
/>
</div>
)}
</div>
)
})}
</nav>
)
}
export default React.memo(List)

View File

@ -0,0 +1,7 @@
.opBtn {
visibility: hidden;
}
.item:hover .opBtn {
visibility: visible;
}

View File

@ -0,0 +1,3 @@
.installedApp {
height: calc(100vh - 74px);
}

View File

@ -0,0 +1,79 @@
'use client'
import type { FC, ReactNode } from 'react'
import React from 'react'
import cn from 'classnames'
import { useTranslation } from 'react-i18next'
import s from './style.module.css'
import { StarIcon } from '@/app/components/share/chatbot/welcome/massive-component'
import Button from '@/app/components/base/button'
export type 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,356 @@
'use client'
import type { FC } from 'react'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import TemplateVarPanel, { PanelTitle, VarOpBtnGroup } from '../value-panel'
import s from './style.module.css'
import { AppInfo, ChatBtn, EditBtn, FootLogo, PromptTemplate } from './massive-component'
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'
// 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
canEditInputs: boolean
savedInputs: Record<string, any>
onInputsChange: (inputs: Record<string, any>) => void
plan: string
}
const Welcome: FC<IWelcomeProps> = ({
// conversationName,
hasSetInputs,
isPublicVersion,
siteInfo,
plan,
promptConfig,
onStartChat,
canEditInputs,
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 (!canEditInputs) {
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 (!canEditInputs || !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 && !canEditInputs) || !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,74 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import cn from 'classnames'
import { useTranslation } from 'react-i18next'
import {
PencilIcon,
} from '@heroicons/react/24/solid'
import s from './style.module.css'
import type { SiteInfo } from '@/models/share'
import Button from '@/app/components/base/button'
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, `!p-0 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;
}

View File

@ -1,48 +1,42 @@
import type { FC } from 'react' import type { FC } from 'react'
import React from 'react' import React from 'react'
import AppIcon from '@/app/components/base/app-icon' import AppIcon from '@/app/components/base/app-icon'
import {
Bars3Icon,
PencilSquareIcon,
} from '@heroicons/react/24/solid'
export type IHeaderProps = { export type IHeaderProps = {
title: string title: string
icon: string icon: string
icon_background: string icon_background: string
isMobile?: boolean isMobile?: boolean
onShowSideBar?: () => void isEmbedScene?: boolean
onCreateNewChat?: () => void
} }
const Header: FC<IHeaderProps> = ({ const Header: FC<IHeaderProps> = ({
title, title,
isMobile, isMobile,
icon, icon,
icon_background, icon_background,
onShowSideBar, isEmbedScene = false,
onCreateNewChat,
}) => { }) => {
return ( return !isMobile
<div className="shrink-0 flex items-center justify-between h-12 px-3 bg-gray-100"> ? null
{isMobile ? ( : (
<div <div
className='flex items-center justify-center h-8 w-8 cursor-pointer' className={`shrink-0 flex items-center justify-between h-12 px-3 bg-gray-100 ${
onClick={() => onShowSideBar?.()} isEmbedScene ? 'bg-gradient-to-r from-blue-600 to-sky-500' : ''
> }`}
<Bars3Icon className="h-4 w-4 text-gray-500" /> >
<div></div>
<div className="flex items-center space-x-2">
<AppIcon size="small" icon={icon} background={icon_background} />
<div
className={`text-sm text-gray-800 font-bold ${
isEmbedScene ? 'text-white' : ''
}`}
>
{title}
</div>
</div> </div>
) : <div></div>} <div></div>
<div className='flex items-center space-x-2'>
<AppIcon size="small" icon={icon} background={icon_background} />
<div className=" text-sm text-gray-800 font-bold">{title}</div>
</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) export default React.memo(Header)

9
web/bin/uglify-embed.js Normal file
View File

@ -0,0 +1,9 @@
const fs = require('node:fs')
// https://www.npmjs.com/package/uglify-js
const UglifyJS = require('uglify-js')
const { readFileSync, writeFileSync } = fs
writeFileSync('public/embed.min.js', UglifyJS.minify({
'embed.js': readFileSync('public/embed.js', 'utf8'),
}).code, 'utf8')

View File

@ -6,10 +6,10 @@ const translation = {
addFeature: 'Add Feature', addFeature: 'Add Feature',
automatic: 'Automatic', automatic: 'Automatic',
stopResponding: 'Stop responding', stopResponding: 'Stop responding',
agree: 'agree', agree: 'like',
disagree: 'disagree', disagree: 'dislike',
cancelAgree: 'Cancel agree', cancelAgree: 'Cancel like',
cancelDisagree: 'Cancel disagree', cancelDisagree: 'Cancel dislike',
userAction: 'User ', userAction: 'User ',
}, },
notSetAPIKey: { notSetAPIKey: {

View File

@ -36,6 +36,15 @@ const translation = {
privacyPolicyTip: 'Helps visitors understand the data the application collects, see Dify\'s <privacyPolicyLink>Privacy Policy</privacyPolicyLink>.', privacyPolicyTip: 'Helps visitors understand the data the application collects, see Dify\'s <privacyPolicyLink>Privacy Policy</privacyPolicyLink>.',
}, },
}, },
embedded: {
entry: 'Embedded',
title: 'Embed on website',
explanation: 'Choose the way to embed chat app to your website',
iframe: 'To add the chat app any where on your website, add this iframe to your html code.',
scripts: 'To add a chat app to the bottom right of your website add this code to your html.',
copied: 'Copied',
copy: 'Copy',
},
customize: { customize: {
way: 'way', way: 'way',
entry: 'Want to customize your WebApp?', entry: 'Want to customize your WebApp?',

View File

@ -36,6 +36,15 @@ const translation = {
privacyPolicyTip: '帮助访问者了解该应用收集的数据,可参考 Dify 的<privacyPolicyLink>隐私政策</privacyPolicyLink>。', privacyPolicyTip: '帮助访问者了解该应用收集的数据,可参考 Dify 的<privacyPolicyLink>隐私政策</privacyPolicyLink>。',
}, },
}, },
embedded: {
entry: '嵌入',
title: '嵌入到网站中',
explanation: '选择一种方式将聊天应用嵌入到你的网站中',
iframe: '将以下 iframe 嵌入到你的网站中的目标位置',
scripts: '将以下代码嵌入到你的网站中',
copied: '已复制',
copy: '复制',
},
customize: { customize: {
way: '方法', way: '方法',
entry: '想要进一步自定义 WebApp', entry: '想要进一步自定义 WebApp',

View File

@ -10,7 +10,8 @@
"fix": "next lint --fix", "fix": "next lint --fix",
"eslint-fix": "eslint --fix", "eslint-fix": "eslint --fix",
"prepare": "cd ../ && husky install ./web/.husky", "prepare": "cd ../ && husky install ./web/.husky",
"gen-icons": "node ./app/components/base/icons/script.js" "gen-icons": "node ./app/components/base/icons/script.js",
"uglify-embed": "node ./bin/uglify-embed"
}, },
"dependencies": { "dependencies": {
"@babel/runtime": "^7.22.3", "@babel/runtime": "^7.22.3",
@ -97,7 +98,8 @@
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"lint-staged": "^13.2.2", "lint-staged": "^13.2.2",
"miragejs": "^0.1.47", "miragejs": "^0.1.47",
"postcss": "^8.4.21" "postcss": "^8.4.21",
"uglify-js": "^3.17.4"
}, },
"lint-staged": { "lint-staged": {
"**/*.js?(x)": [ "**/*.js?(x)": [
@ -107,4 +109,4 @@
"eslint --fix" "eslint --fix"
] ]
} }
} }

89
web/public/embed.js Normal file
View File

@ -0,0 +1,89 @@
/** this file is used to embed the chatbot in a website
* the difyChatbotConfig should be defined in the html file before this script is included
* the difyChatbotConfig should contain the token of the chatbot
* the token can be found in the chatbot settings page
*/
// attention: This JavaScript script must be placed after the <body> element. Otherwise, the script will not work.
document.body.onload = embedChatbot;
async function embedChatbot () {
const difyChatbotConfig = window.difyChatbotConfig;
if (!difyChatbotConfig || !difyChatbotConfig.token) {
console.error('difyChatbotConfig is empty or token is not provided')
return;
}
const isDev = !!difyChatbotConfig.isDev
const openIcon = `<svg
id="openIcon"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M7.7586 2L16.2412 2C17.0462 1.99999 17.7105 1.99998 18.2517 2.04419C18.8138 2.09012 19.3305 2.18868 19.8159 2.43598C20.5685 2.81947 21.1804 3.43139 21.5639 4.18404C21.8112 4.66937 21.9098 5.18608 21.9557 5.74818C21.9999 6.28937 21.9999 6.95373 21.9999 7.7587L22 14.1376C22.0004 14.933 22.0007 15.5236 21.8636 16.0353C21.4937 17.4156 20.4155 18.4938 19.0352 18.8637C18.7277 18.9461 18.3917 18.9789 17.9999 18.9918L17.9999 20.371C18 20.6062 18 20.846 17.9822 21.0425C17.9651 21.2305 17.9199 21.5852 17.6722 21.8955C17.3872 22.2525 16.9551 22.4602 16.4983 22.4597C16.1013 22.4593 15.7961 22.273 15.6386 22.1689C15.474 22.06 15.2868 21.9102 15.1031 21.7632L12.69 19.8327C12.1714 19.4178 12.0174 19.3007 11.8575 19.219C11.697 19.137 11.5262 19.0771 11.3496 19.0408C11.1737 19.0047 10.9803 19 10.3162 19H7.75858C6.95362 19 6.28927 19 5.74808 18.9558C5.18598 18.9099 4.66928 18.8113 4.18394 18.564C3.43129 18.1805 2.81937 17.5686 2.43588 16.816C2.18859 16.3306 2.09002 15.8139 2.0441 15.2518C1.99988 14.7106 1.99989 14.0463 1.9999 13.2413V7.75868C1.99989 6.95372 1.99988 6.28936 2.0441 5.74818C2.09002 5.18608 2.18859 4.66937 2.43588 4.18404C2.81937 3.43139 3.43129 2.81947 4.18394 2.43598C4.66928 2.18868 5.18598 2.09012 5.74808 2.04419C6.28927 1.99998 6.95364 1.99999 7.7586 2ZM10.5073 7.5C10.5073 6.67157 9.83575 6 9.00732 6C8.1789 6 7.50732 6.67157 7.50732 7.5C7.50732 8.32843 8.1789 9 9.00732 9C9.83575 9 10.5073 8.32843 10.5073 7.5ZM16.6073 11.7001C16.1669 11.3697 15.5426 11.4577 15.2105 11.8959C15.1488 11.9746 15.081 12.0486 15.0119 12.1207C14.8646 12.2744 14.6432 12.4829 14.3566 12.6913C13.7796 13.111 12.9818 13.5001 12.0073 13.5001C11.0328 13.5001 10.235 13.111 9.65799 12.6913C9.37138 12.4829 9.15004 12.2744 9.00274 12.1207C8.93366 12.0486 8.86581 11.9745 8.80418 11.8959C8.472 11.4577 7.84775 11.3697 7.40732 11.7001C6.96549 12.0314 6.87595 12.6582 7.20732 13.1001C7.20479 13.0968 7.21072 13.1043 7.22094 13.1171C7.24532 13.1478 7.29407 13.2091 7.31068 13.2289C7.36932 13.2987 7.45232 13.3934 7.55877 13.5045C7.77084 13.7258 8.08075 14.0172 8.48165 14.3088C9.27958 14.8891 10.4818 15.5001 12.0073 15.5001C13.5328 15.5001 14.735 14.8891 15.533 14.3088C15.9339 14.0172 16.2438 13.7258 16.4559 13.5045C16.5623 13.3934 16.6453 13.2987 16.704 13.2289C16.7333 13.1939 16.7567 13.165 16.7739 13.1432C17.1193 12.6969 17.0729 12.0493 16.6073 11.7001ZM15.0073 6C15.8358 6 16.5073 6.67157 16.5073 7.5C16.5073 8.32843 15.8358 9 15.0073 9C14.1789 9 13.5073 8.32843 13.5073 7.5C13.5073 6.67157 14.1789 6 15.0073 6Z"
fill="white"
/>
</svg>`
const closeIcon = `<svg
id="closeIcon"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18 18L6 6M6 18L18 6"
stroke="white"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>`
// create iframe
function createIframe () {
const iframe = document.createElement('iframe');
iframe.allow = "fullscreen;microphone"
iframe.title = "dify chatbot bubble window"
iframe.id = 'dify-chatbot-bubble-window'
iframe.src = `https://${isDev ? 'dev.' : ''}udify.app/chatbot/${difyChatbotConfig.token}`;
iframe.style.cssText = 'border: none; position: fixed; flex-direction: column; justify-content: space-between; box-shadow: rgba(150, 150, 150, 0.2) 0px 10px 30px 0px, rgba(150, 150, 150, 0.2) 0px 0px 0px 1px; bottom: 5rem; right: 1rem; width: 24rem; height: 40rem; border-radius: 0.75rem; display: flex; z-index: 2147483647; overflow: hidden; left: unset;'
document.body.appendChild(iframe);
}
const targetButton = document.getElementById('dify-chatbot-bubble-button')
if (!targetButton) {
// create button
const containerDiv = document.createElement("div");
containerDiv.id = 'dify-chatbot-bubble-button'
containerDiv.style.cssText = `position: fixed; bottom: 1rem; right: 1rem; width: 50px; height: 50px; border-radius: 25px; background-color: #155EEF; box-shadow: rgba(0, 0, 0, 0.2) 0px 4px 8px 0px; cursor: pointer; z-index: 2147483647; transition: all 0.2s ease-in-out 0s; left: unset; transform: scale(1); :hover {transform: scale(1.1);}`;
const displayDiv = document.createElement('div');
displayDiv.style.cssText = 'display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; z-index: 2147483647;';
displayDiv.innerHTML = openIcon
containerDiv.appendChild(displayDiv);
document.body.appendChild(containerDiv);
// add click event to control iframe display
containerDiv.addEventListener('click', function () {
const targetIframe = document.getElementById('dify-chatbot-bubble-window')
if (!targetIframe) {
createIframe()
displayDiv.innerHTML = closeIcon
return;
}
if (targetIframe.style.display === 'none') {
targetIframe.style.display = 'block';
displayDiv.innerHTML = closeIcon
} else {
targetIframe.style.display = 'none';
displayDiv.innerHTML = openIcon
}
});
}
}

30
web/public/embed.min.js vendored Normal file
View File

@ -0,0 +1,30 @@
async function embedChatbot(){const t=window.difyChatbotConfig;if(t&&t.token){const o=!!t.isDev,n=`<svg
id="openIcon"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M7.7586 2L16.2412 2C17.0462 1.99999 17.7105 1.99998 18.2517 2.04419C18.8138 2.09012 19.3305 2.18868 19.8159 2.43598C20.5685 2.81947 21.1804 3.43139 21.5639 4.18404C21.8112 4.66937 21.9098 5.18608 21.9557 5.74818C21.9999 6.28937 21.9999 6.95373 21.9999 7.7587L22 14.1376C22.0004 14.933 22.0007 15.5236 21.8636 16.0353C21.4937 17.4156 20.4155 18.4938 19.0352 18.8637C18.7277 18.9461 18.3917 18.9789 17.9999 18.9918L17.9999 20.371C18 20.6062 18 20.846 17.9822 21.0425C17.9651 21.2305 17.9199 21.5852 17.6722 21.8955C17.3872 22.2525 16.9551 22.4602 16.4983 22.4597C16.1013 22.4593 15.7961 22.273 15.6386 22.1689C15.474 22.06 15.2868 21.9102 15.1031 21.7632L12.69 19.8327C12.1714 19.4178 12.0174 19.3007 11.8575 19.219C11.697 19.137 11.5262 19.0771 11.3496 19.0408C11.1737 19.0047 10.9803 19 10.3162 19H7.75858C6.95362 19 6.28927 19 5.74808 18.9558C5.18598 18.9099 4.66928 18.8113 4.18394 18.564C3.43129 18.1805 2.81937 17.5686 2.43588 16.816C2.18859 16.3306 2.09002 15.8139 2.0441 15.2518C1.99988 14.7106 1.99989 14.0463 1.9999 13.2413V7.75868C1.99989 6.95372 1.99988 6.28936 2.0441 5.74818C2.09002 5.18608 2.18859 4.66937 2.43588 4.18404C2.81937 3.43139 3.43129 2.81947 4.18394 2.43598C4.66928 2.18868 5.18598 2.09012 5.74808 2.04419C6.28927 1.99998 6.95364 1.99999 7.7586 2ZM10.5073 7.5C10.5073 6.67157 9.83575 6 9.00732 6C8.1789 6 7.50732 6.67157 7.50732 7.5C7.50732 8.32843 8.1789 9 9.00732 9C9.83575 9 10.5073 8.32843 10.5073 7.5ZM16.6073 11.7001C16.1669 11.3697 15.5426 11.4577 15.2105 11.8959C15.1488 11.9746 15.081 12.0486 15.0119 12.1207C14.8646 12.2744 14.6432 12.4829 14.3566 12.6913C13.7796 13.111 12.9818 13.5001 12.0073 13.5001C11.0328 13.5001 10.235 13.111 9.65799 12.6913C9.37138 12.4829 9.15004 12.2744 9.00274 12.1207C8.93366 12.0486 8.86581 11.9745 8.80418 11.8959C8.472 11.4577 7.84775 11.3697 7.40732 11.7001C6.96549 12.0314 6.87595 12.6582 7.20732 13.1001C7.20479 13.0968 7.21072 13.1043 7.22094 13.1171C7.24532 13.1478 7.29407 13.2091 7.31068 13.2289C7.36932 13.2987 7.45232 13.3934 7.55877 13.5045C7.77084 13.7258 8.08075 14.0172 8.48165 14.3088C9.27958 14.8891 10.4818 15.5001 12.0073 15.5001C13.5328 15.5001 14.735 14.8891 15.533 14.3088C15.9339 14.0172 16.2438 13.7258 16.4559 13.5045C16.5623 13.3934 16.6453 13.2987 16.704 13.2289C16.7333 13.1939 16.7567 13.165 16.7739 13.1432C17.1193 12.6969 17.0729 12.0493 16.6073 11.7001ZM15.0073 6C15.8358 6 16.5073 6.67157 16.5073 7.5C16.5073 8.32843 15.8358 9 15.0073 9C14.1789 9 13.5073 8.32843 13.5073 7.5C13.5073 6.67157 14.1789 6 15.0073 6Z"
fill="white"
/>
</svg>`,i=`<svg
id="closeIcon"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18 18L6 6M6 18L18 6"
stroke="white"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>`;if(!document.getElementById("dify-chatbot-bubble-button")){var e=document.createElement("div");e.id="dify-chatbot-bubble-button",e.style.cssText="position: fixed; bottom: 1rem; right: 1rem; width: 50px; height: 50px; border-radius: 25px; background-color: #155EEF; box-shadow: rgba(0, 0, 0, 0.2) 0px 4px 8px 0px; cursor: pointer; z-index: 2147483647; transition: all 0.2s ease-in-out 0s; left: unset; transform: scale(1); :hover {transform: scale(1.1);}";const d=document.createElement("div");d.style.cssText="display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; z-index: 2147483647;",d.innerHTML=n,e.appendChild(d),document.body.appendChild(e),e.addEventListener("click",function(){var e=document.getElementById("dify-chatbot-bubble-window");e?"none"===e.style.display?(e.style.display="block",d.innerHTML=i):(e.style.display="none",d.innerHTML=n):((e=document.createElement("iframe")).allow="fullscreen;microphone",e.title="dify chatbot bubble window",e.id="dify-chatbot-bubble-window",e.src=`https://${o?"dev.":""}udify.app/chatbot/`+t.token,e.style.cssText="border: none; position: fixed; flex-direction: column; justify-content: space-between; box-shadow: rgba(150, 150, 150, 0.2) 0px 10px 30px 0px, rgba(150, 150, 150, 0.2) 0px 0px 0px 1px; bottom: 5rem; right: 1rem; width: 24rem; height: 40rem; border-radius: 0.75rem; display: flex; z-index: 2147483647; overflow: hidden; left: unset;",document.body.appendChild(e),d.innerHTML=i)})}}else console.error("difyChatbotConfig is empty or token is not provided")}document.body.onload=embedChatbot;