Browse Source

Feat/embedding (#553)

Co-authored-by: Gillian97 <jinling.sunshine@gmail.com>
Co-authored-by: Joel <iamjoel007@gmail.com>
zxhlyh 1 year ago
parent
commit
fec607db81
33 changed files with 2122 additions and 41 deletions
  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. 99 0
      web/app/components/app/overview/assets/iframe-option.svg
  6. 157 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
      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. 28 0
      web/public/embed.js
  33. 11 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=""

+ 3 - 0
web/app/components/app/overview/assets/code-browser.svg

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

File diff suppressed because it is too large
+ 99 - 0
web/app/components/app/overview/assets/iframe-option.svg


File diff suppressed because it is too large
+ 157 - 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
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)

+ 74 - 0
web/app/components/share/chatbot/welcome/massive-component.tsx

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

+ 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"
     ]
   }
-}
+}

File diff suppressed because it is too large
+ 28 - 0
web/public/embed.js


File diff suppressed because it is too large
+ 11 - 0
web/public/embed.min.js


Some files were not shown because too many files changed in this diff