Procházet zdrojové kódy

Feat/embedding (#553)

Co-authored-by: Gillian97 <jinling.sunshine@gmail.com>
Co-authored-by: Joel <iamjoel007@gmail.com>
zxhlyh před 1 rokem
rodič
revize
fec607db81
33 změnil soubory, kde provedl 2208 přidání a 41 odebrání
  1. 13 0
      web/app/(shareLayout)/chatbot/[token]/page.tsx
  2. 2 2
      web/app/components/app/chat/index.tsx
  3. 24 4
      web/app/components/app/overview/appCard.tsx
  4. 3 0
      web/app/components/app/overview/assets/code-browser.svg
  5. 102 0
      web/app/components/app/overview/assets/iframe-option.svg
  6. 160 0
      web/app/components/app/overview/assets/scripts-option.svg
  7. 111 0
      web/app/components/app/overview/embedded/index.tsx
  8. 14 0
      web/app/components/app/overview/embedded/style.module.css
  9. 5 0
      web/app/components/app/overview/style.css
  10. 13 0
      web/app/components/share/chatbot/config-scence/index.tsx
  11. 70 0
      web/app/components/share/chatbot/hooks/use-conversation.ts
  12. 647 0
      web/app/components/share/chatbot/index.tsx
  13. 28 0
      web/app/components/share/chatbot/sidebar/app-info/index.tsx
  14. 3 0
      web/app/components/share/chatbot/sidebar/card.module.css
  15. 19 0
      web/app/components/share/chatbot/sidebar/card.tsx
  16. 151 0
      web/app/components/share/chatbot/sidebar/index.tsx
  17. 115 0
      web/app/components/share/chatbot/sidebar/list/index.tsx
  18. 7 0
      web/app/components/share/chatbot/sidebar/list/style.module.css
  19. 3 0
      web/app/components/share/chatbot/style.module.css
  20. 79 0
      web/app/components/share/chatbot/value-panel/index.tsx
  21. 3 0
      web/app/components/share/chatbot/value-panel/style.module.css
  22. binární
      web/app/components/share/chatbot/welcome/icons/logo.png
  23. 356 0
      web/app/components/share/chatbot/welcome/index.tsx
  24. 74 0
      web/app/components/share/chatbot/welcome/massive-component.tsx
  25. 29 0
      web/app/components/share/chatbot/welcome/style.module.css
  26. 22 28
      web/app/components/share/header.tsx
  27. 9 0
      web/bin/uglify-embed.js
  28. 4 4
      web/i18n/lang/app-debug.en.ts
  29. 9 0
      web/i18n/lang/app-overview.en.ts
  30. 9 0
      web/i18n/lang/app-overview.zh.ts
  31. 5 3
      web/package.json
  32. 89 0
      web/public/embed.js
  33. 30 0
      web/public/embed.min.js

+ 13 - 0
web/app/(shareLayout)/chatbot/[token]/page.tsx

@@ -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)

+ 2 - 2
web/app/components/app/chat/index.tsx

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

+ 24 - 4
web/app/components/app/overview/appCard.tsx

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

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 3 - 0
web/app/components/app/overview/assets/code-browser.svg


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 102 - 0
web/app/components/app/overview/assets/iframe-option.svg


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 160 - 0
web/app/components/app/overview/assets/scripts-option.svg


+ 111 - 0
web/app/components/app/overview/embedded/index.tsx

@@ -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

+ 14 - 0
web/app/components/app/overview/embedded/style.module.css

@@ -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);
+}

+ 5 - 0
web/app/components/app/overview/style.css

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

+ 13 - 0
web/app/components/share/chatbot/config-scence/index.tsx

@@ -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)

+ 70 - 0
web/app/components/share/chatbot/hooks/use-conversation.ts

@@ -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

+ 647 - 0
web/app/components/share/chatbot/index.tsx

@@ -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)

+ 28 - 0
web/app/components/share/chatbot/sidebar/app-info/index.tsx

@@ -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)

+ 3 - 0
web/app/components/share/chatbot/sidebar/card.module.css

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

+ 19 - 0
web/app/components/share/chatbot/sidebar/card.tsx

@@ -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

+ 151 - 0
web/app/components/share/chatbot/sidebar/index.tsx

@@ -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)

+ 115 - 0
web/app/components/share/chatbot/sidebar/list/index.tsx

@@ -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)

+ 7 - 0
web/app/components/share/chatbot/sidebar/list/style.module.css

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

+ 3 - 0
web/app/components/share/chatbot/style.module.css

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

+ 79 - 0
web/app/components/share/chatbot/value-panel/index.tsx

@@ -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)

+ 3 - 0
web/app/components/share/chatbot/value-panel/style.module.css

@@ -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ární
web/app/components/share/chatbot/welcome/icons/logo.png


+ 356 - 0
web/app/components/share/chatbot/welcome/index.tsx

@@ -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)

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 74 - 0
web/app/components/share/chatbot/welcome/massive-component.tsx


+ 29 - 0
web/app/components/share/chatbot/welcome/style.module.css

@@ -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;
+}

+ 22 - 28
web/app/components/share/header.tsx

@@ -1,48 +1,42 @@
 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
   icon: string
   icon_background: string
   isMobile?: boolean
-  onShowSideBar?: () => void
-  onCreateNewChat?: () => void
+  isEmbedScene?: boolean
 }
 const Header: FC<IHeaderProps> = ({
   title,
   isMobile,
   icon,
   icon_background,
-  onShowSideBar,
-  onCreateNewChat,
+  isEmbedScene = false,
 }) => {
-  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" />
+  return !isMobile
+    ? null
+    : (
+      <div
+        className={`shrink-0 flex items-center justify-between h-12 px-3 bg-gray-100 ${
+          isEmbedScene ? 'bg-gradient-to-r from-blue-600 to-sky-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 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>
       </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)

+ 9 - 0
web/bin/uglify-embed.js

@@ -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')

+ 4 - 4
web/i18n/lang/app-debug.en.ts

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

+ 9 - 0
web/i18n/lang/app-overview.en.ts

@@ -36,6 +36,15 @@ const translation = {
           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: {
         way: 'way',
         entry: 'Want to customize your WebApp?',

+ 9 - 0
web/i18n/lang/app-overview.zh.ts

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

+ 5 - 3
web/package.json

@@ -10,7 +10,8 @@
     "fix": "next lint --fix",
     "eslint-fix": "eslint --fix",
     "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": {
     "@babel/runtime": "^7.22.3",
@@ -97,7 +98,8 @@
     "eslint-plugin-react-hooks": "^4.6.0",
     "lint-staged": "^13.2.2",
     "miragejs": "^0.1.47",
-    "postcss": "^8.4.21"
+    "postcss": "^8.4.21",
+    "uglify-js": "^3.17.4"
   },
   "lint-staged": {
     "**/*.js?(x)": [
@@ -107,4 +109,4 @@
       "eslint --fix"
     ]
   }
-}
+}

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 89 - 0
web/public/embed.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 30 - 0
web/public/embed.min.js