Browse Source

Feat/explore (#198)

Joel 1 year ago
parent
commit
33b3eaf324
38 changed files with 1199 additions and 97 deletions
  1. 8 0
      web/app/(commonLayout)/apps/Apps.tsx
  2. 8 0
      web/app/(commonLayout)/explore/apps/page.tsx
  3. 15 0
      web/app/(commonLayout)/explore/installed/[appId]/page.tsx
  4. 16 0
      web/app/(commonLayout)/explore/layout.tsx
  5. 2 4
      web/app/(shareLayout)/chat/[token]/page.tsx
  6. 11 5
      web/app/components/app/text-generate/item/index.tsx
  7. 65 0
      web/app/components/explore/app-card/index.tsx
  8. 20 0
      web/app/components/explore/app-card/style.module.css
  9. 130 0
      web/app/components/explore/app-list/index.tsx
  10. 17 0
      web/app/components/explore/app-list/style.module.css
  11. 48 0
      web/app/components/explore/category.tsx
  12. 86 0
      web/app/components/explore/create-app-modal/index.tsx
  13. 36 0
      web/app/components/explore/create-app-modal/style.module.css
  14. 51 0
      web/app/components/explore/index.tsx
  15. 37 0
      web/app/components/explore/installed-app/index.tsx
  16. 60 0
      web/app/components/explore/item-operation/index.tsx
  17. 31 0
      web/app/components/explore/item-operation/style.module.css
  18. 73 0
      web/app/components/explore/sidebar/app-nav-item/index.tsx
  19. 17 0
      web/app/components/explore/sidebar/app-nav-item/style.module.css
  20. 15 0
      web/app/components/explore/sidebar/index.tsx
  21. 18 3
      web/app/components/header/index.tsx
  22. 71 29
      web/app/components/share/chat/index.tsx
  23. 27 0
      web/app/components/share/chat/sidebar/app-info/index.tsx
  24. 61 15
      web/app/components/share/chat/sidebar/index.tsx
  25. 3 0
      web/app/components/share/chat/style.module.css
  26. 50 13
      web/app/components/share/text-generation/index.tsx
  27. 6 0
      web/app/components/share/text-generation/style.module.css
  28. 4 0
      web/config/index.ts
  29. 20 0
      web/context/explore-context.ts
  30. 4 0
      web/i18n/i18next-config.ts
  31. 3 1
      web/i18n/lang/common.en.ts
  32. 3 1
      web/i18n/lang/common.zh.ts
  33. 38 0
      web/i18n/lang/explore.en.ts
  34. 38 0
      web/i18n/lang/explore.zh.ts
  35. 30 0
      web/models/explore.ts
  36. 33 0
      web/service/explore.ts
  37. 43 26
      web/service/share.ts
  38. 1 0
      web/types/app.ts

+ 8 - 0
web/app/(commonLayout)/apps/Apps.tsx

@@ -8,6 +8,7 @@ import NewAppCard from './NewAppCard'
 import { AppListResponse } from '@/models/app'
 import { fetchAppList } from '@/service/apps'
 import { useSelector } from '@/context/app-context'
+import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
 
 const getKey = (pageIndex: number, previousPageData: AppListResponse) => {
   if (!pageIndex || previousPageData.has_more)
@@ -21,6 +22,13 @@ const Apps = () => {
   const pageContainerRef = useSelector(state => state.pageContainerRef)
   const anchorRef = useRef<HTMLAnchorElement>(null)
 
+  useEffect(() => {
+    if(localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') {
+      localStorage.removeItem(NEED_REFRESH_APP_LIST_KEY)
+      mutate()
+    }
+  }, [])
+
   useEffect(() => {
     loadingStateRef.current = isLoading
   }, [isLoading])

+ 8 - 0
web/app/(commonLayout)/explore/apps/page.tsx

@@ -0,0 +1,8 @@
+import AppList from "@/app/components/explore/app-list"
+import React from 'react'
+
+const Apps = ({ }) => {
+  return <AppList />
+}
+
+export default React.memo(Apps)

+ 15 - 0
web/app/(commonLayout)/explore/installed/[appId]/page.tsx

@@ -0,0 +1,15 @@
+import React, { FC } from 'react'
+import Main from '@/app/components/explore/installed-app'
+
+export interface IInstalledAppProps { 
+  params: {
+    appId: string
+  }
+}
+
+const InstalledApp: FC<IInstalledAppProps> = ({ params: {appId} }) => {
+  return (
+    <Main id={appId} />
+  )
+}
+export default React.memo(InstalledApp)

+ 16 - 0
web/app/(commonLayout)/explore/layout.tsx

@@ -0,0 +1,16 @@
+import type { FC } from 'react'
+import React from 'react'
+import ExploreClient from '@/app/components/explore'
+export type IAppDetail = {
+  children: React.ReactNode
+}
+
+const AppDetail: FC<IAppDetail> = ({ children }) => {
+  return (
+    <ExploreClient>
+      {children}
+    </ExploreClient>
+  )
+}
+
+export default React.memo(AppDetail)

+ 2 - 4
web/app/(shareLayout)/chat/[token]/page.tsx

@@ -4,12 +4,10 @@ import React from 'react'
 import type { IMainProps } from '@/app/components/share/chat'
 import Main from '@/app/components/share/chat'
 
-const Chat: FC<IMainProps> = ({
-  params,
-}: any) => {
+const Chat: FC<IMainProps> = () => {
 
   return (
-    <Main params={params} />
+    <Main />
   )
 }
 

+ 11 - 5
web/app/components/app/text-generate/item/index.tsx

@@ -9,7 +9,7 @@ import Toast from '@/app/components/base/toast'
 import { Feedbacktype } from '@/app/components/app/chat'
 import { HandThumbDownIcon, HandThumbUpIcon } from '@heroicons/react/24/outline'
 import { useBoolean } from 'ahooks'
-import { fetcMoreLikeThis, updateFeedback } from '@/service/share'
+import { fetchMoreLikeThis, updateFeedback } from '@/service/share'
 
 const MAX_DEPTH = 3
 export interface IGenerationItemProps {
@@ -24,6 +24,8 @@ export interface IGenerationItemProps {
   onFeedback?: (feedback: Feedbacktype) => void
   onSave?: (messageId: string) => void
   isMobile?: boolean
+  isInstalledApp: boolean,
+  installedAppId?: string,
 }
 
 export const SimpleBtn = ({ className, onClick, children }: {
@@ -75,7 +77,9 @@ const GenerationItem: FC<IGenerationItemProps> = ({
   onFeedback,
   onSave,
   depth = 1,
-  isMobile
+  isMobile,
+  isInstalledApp,
+  installedAppId,
 }) => {
   const { t } = useTranslation()
   const isTop = depth === 1
@@ -88,7 +92,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
   })
 
   const handleFeedback = async (childFeedback: Feedbacktype) => {
-    await updateFeedback({ url: `/messages/${childMessageId}/feedbacks`, body: { rating: childFeedback.rating } })
+    await updateFeedback({ url: `/messages/${childMessageId}/feedbacks`, body: { rating: childFeedback.rating } }, isInstalledApp, installedAppId)
     setChildFeedback(childFeedback)
   }
 
@@ -104,7 +108,9 @@ const GenerationItem: FC<IGenerationItemProps> = ({
     isLoading: isQuerying,
     feedback: childFeedback,
     onSave,
-    isMobile
+    isMobile,
+    isInstalledApp,
+    installedAppId,
   }
 
   const handleMoreLikeThis = async () => {
@@ -113,7 +119,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
       return
     }
     startQuerying()
-    const res: any = await fetcMoreLikeThis(messageId as string)
+    const res: any = await fetchMoreLikeThis(messageId as string, isInstalledApp, installedAppId)
     setCompletionRes(res.answer)
     setChildMessageId(res.id)
     stopQuerying()

+ 65 - 0
web/app/components/explore/app-card/index.tsx

@@ -0,0 +1,65 @@
+'use client'
+import cn from 'classnames'
+import { useTranslation } from 'react-i18next'
+import { App } from '@/models/explore'
+import AppModeLabel from '@/app/(commonLayout)/apps/AppModeLabel'
+import AppIcon from '@/app/components/base/app-icon'
+import { PlusIcon } from '@heroicons/react/20/solid'
+import Button from '../../base/button'
+
+import s from './style.module.css'
+
+const CustomizeBtn = (
+  <svg width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+    <path d="M7.5 2.33366C6.69458 2.33366 6.04167 2.98658 6.04167 3.79199C6.04167 4.59741 6.69458 5.25033 7.5 5.25033C8.30542 5.25033 8.95833 4.59741 8.95833 3.79199C8.95833 2.98658 8.30542 2.33366 7.5 2.33366ZM7.5 2.33366V1.16699M12.75 8.71385C11.4673 10.1671 9.59071 11.0837 7.5 11.0837C5.40929 11.0837 3.53265 10.1671 2.25 8.71385M6.76782 5.05298L2.25 12.8337M8.23218 5.05298L12.75 12.8337" stroke="#344054" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+  </svg>
+)
+
+export type AppCardProps = {
+  app: App,
+  canCreate: boolean,
+  onCreate: () => void,
+  onAddToWorkspace: (appId: string) => void,
+}
+
+const AppCard = ({
+  app,
+  canCreate,
+  onCreate,
+  onAddToWorkspace,
+}: AppCardProps) => {
+  const { t } = useTranslation()
+  const {app: appBasicInfo} = app
+  return (
+    <div className={s.wrap}>
+      <div className='col-span-1 bg-white border-2 border-solid border-transparent rounded-lg shadow-sm min-h-[160px] flex flex-col transition-all duration-200 ease-in-out cursor-pointer hover:shadow-lg'>
+        <div className='flex pt-[14px] px-[14px] pb-3 h-[66px] items-center gap-3 grow-0 shrink-0'>
+          <AppIcon size='small' icon={app.app.icon} background={app.app.icon_background} />
+          <div className='relative h-8 text-sm font-medium leading-8 grow'>
+            <div className='absolute top-0 left-0 w-full h-full overflow-hidden text-ellipsis whitespace-nowrap'>{appBasicInfo.name}</div>
+          </div>
+        </div>
+        <div className='mb-3 px-[14px] h-9 text-xs leading-normal text-gray-500 line-clamp-2'>{app.description}</div>
+        <div className='flex items-center flex-wrap min-h-[42px] px-[14px] pt-2 pb-[10px]'>
+          <div className={s.mode}>
+            <AppModeLabel mode={appBasicInfo.mode} />
+          </div>
+          <div className={cn(s.opWrap, 'flex items-center w-full space-x-2')}>
+            <Button type='primary' className='grow flex items-center !h-7' onClick={() => onAddToWorkspace(appBasicInfo.id)}>
+              <PlusIcon className='w-4 h-4 mr-1' />
+              <span className='text-xs'>{t('explore.appCard.addToWorkspace')}</span>
+            </Button>
+            {canCreate && (
+              <Button className='grow flex items-center !h-7 space-x-1' onClick={onCreate}>
+                {CustomizeBtn}
+                <span className='text-xs'>{t('explore.appCard.customize')}</span>
+              </Button>
+            )}
+          </div>
+        </div>
+      </div>
+    </div>
+  )
+}
+
+export default AppCard

+ 20 - 0
web/app/components/explore/app-card/style.module.css

@@ -0,0 +1,20 @@
+.wrap {
+  min-width: 312px;
+}
+
+.mode {
+  display: flex;
+  height: 28px;
+}
+
+.opWrap {
+  display: none;
+}
+
+.wrap:hover .mode {
+  display: none;
+}
+
+.wrap:hover .opWrap {
+  display: flex;
+}

+ 130 - 0
web/app/components/explore/app-list/index.tsx

@@ -0,0 +1,130 @@
+'use client'
+import React, { FC, useEffect } from 'react'
+import { useRouter } from 'next/navigation'
+import { useTranslation } from 'react-i18next'
+import { useContext } from 'use-context-selector'
+import ExploreContext from '@/context/explore-context'
+import { App } from '@/models/explore'
+import Category from '@/app/components/explore/category'
+import AppCard from '@/app/components/explore/app-card'
+import { fetchAppList, installApp, fetchAppDetail } from '@/service/explore'
+import { createApp } from '@/service/apps'
+import CreateAppModal from '@/app/components/explore/create-app-modal'
+import Loading from '@/app/components/base/loading'
+import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
+
+import s from './style.module.css'
+import Toast from '../../base/toast'
+
+const Apps: FC = ({ }) => {
+  const { t } = useTranslation()
+  const router = useRouter()
+  const { setControlUpdateInstalledApps, hasEditPermission } = useContext(ExploreContext)
+  const [currCategory, setCurrCategory] = React.useState('')
+  const [allList, setAllList] = React.useState<App[]>([])
+  const [isLoaded, setIsLoaded] = React.useState(false)
+
+  const currList = (() => {
+    if(currCategory === '') return allList
+    return allList.filter(item => item.category === currCategory)
+  })()
+  const [categories, setCategories] = React.useState([])
+  useEffect(() => {
+    (async () => {
+      const {categories, recommended_apps}:any = await fetchAppList()
+      setCategories(categories)
+      setAllList(recommended_apps)
+      setIsLoaded(true)
+    })()
+  }, [])
+
+  const handleAddToWorkspace = async (appId: string) => {
+    await installApp(appId)
+    Toast.notify({
+      type: 'success',
+      message: t('common.api.success'),
+    })
+    setControlUpdateInstalledApps(Date.now())
+  }
+
+  const [currApp, setCurrApp] = React.useState<App | null>(null)
+  const [isShowCreateModal, setIsShowCreateModal] = React.useState(false)
+  const onCreate = async ({name, icon, icon_background}: any) => {
+    const { app_model_config: model_config } = await fetchAppDetail(currApp?.app.id as string)
+    
+    try {
+      const app = await createApp({
+        name,
+        icon,
+        icon_background,
+        mode: currApp?.app.mode as any,
+        config: model_config,
+      })
+      setIsShowCreateModal(false)
+      Toast.notify({
+        type: 'success',
+        message: t('app.newApp.appCreated'),
+      })
+      localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
+      router.push(`/app/${app.id}/overview`)
+    } catch (e) {
+      Toast.notify({ type: 'error', message: t('app.newApp.appCreateFailed') })
+    }
+  }
+
+  if(!isLoaded) {
+    return (
+      <div className='flex h-full items-center'>
+        <Loading type='area' />
+      </div>
+    )
+  }
+
+  return (
+    <div className='h-full flex flex-col'>
+      <div className='shrink-0 pt-6 px-12'>
+        <div className='mb-1 text-primary-600 text-xl font-semibold'>{t('explore.apps.title')}</div>
+        <div className='text-gray-500 text-sm'>{t('explore.apps.description')}</div>
+      </div>
+      <Category
+        className='mt-6 px-12'
+        list={categories}
+        value={currCategory}
+        onChange={setCurrCategory}
+      />
+      <div 
+        className='flex mt-6 flex-col overflow-auto bg-gray-100 shrink-0 grow'
+        style={{
+          maxHeight: 'calc(100vh - 243px)'
+        }}
+      >
+        <nav
+          className={`${s.appList} grid content-start grid-cols-1 gap-4 px-12 pb-10grow shrink-0`}>
+          {currList.map(app => (
+            <AppCard 
+              key={app.app_id}
+              app={app}
+              canCreate={hasEditPermission}
+              onCreate={() => {
+                setCurrApp(app)
+                setIsShowCreateModal(true)
+              }}
+              onAddToWorkspace={handleAddToWorkspace}
+            />
+          ))}
+        </nav>
+      </div>
+
+      {isShowCreateModal && (
+          <CreateAppModal
+            appName={currApp?.app.name || ''}
+            show={isShowCreateModal}
+            onConfirm={onCreate}
+            onHide={() => setIsShowCreateModal(false)}
+          />
+        )}
+    </div>
+  )
+}
+
+export default React.memo(Apps)

+ 17 - 0
web/app/components/explore/app-list/style.module.css

@@ -0,0 +1,17 @@
+@media (min-width: 1624px) {
+  .appList {
+    grid-template-columns: repeat(4, minmax(0, 1fr))
+  }
+}
+
+@media (min-width: 1300px) and (max-width: 1624px) {
+  .appList {
+    grid-template-columns: repeat(3, minmax(0, 1fr))
+  }
+}
+
+@media (min-width: 1025px) and (max-width: 1300px) {
+  .appList {
+    grid-template-columns: repeat(2, minmax(0, 1fr))
+  }
+}

+ 48 - 0
web/app/components/explore/category.tsx

@@ -0,0 +1,48 @@
+'use client'
+import React, { FC } from 'react'
+import { useTranslation } from 'react-i18next'
+import exploreI18n from '@/i18n/lang/explore.en'
+import cn from 'classnames'
+
+const categoryI18n = exploreI18n.category
+
+export interface ICategoryProps {
+  className?: string
+  list: string[]
+  value: string
+  onChange: (value: string) => void
+}
+
+const Category: FC<ICategoryProps> = ({
+  className,
+  list,
+  value,
+  onChange
+}) => {
+  const { t } = useTranslation()
+
+  const itemClassName = (isSelected: boolean) => cn(isSelected ? 'bg-white text-primary-600 border-gray-200 font-semibold' : 'border-transparent font-medium','flex items-center h-7 px-3 border cursor-pointer rounded-lg')
+  const itemStyle = (isSelected: boolean) => isSelected ? {boxShadow: '0px 1px 2px rgba(16, 24, 40, 0.05)'} : {}
+  return (
+    <div className={cn(className, 'flex space-x-1 text-[13px]')}>
+      <div 
+          className={itemClassName('' === value)}
+          style={itemStyle('' === value)}
+          onClick={() => onChange('')}
+        >
+          {t('explore.apps.allCategories')}
+        </div>
+      {list.map(name => (
+        <div 
+          key={name}
+          className={itemClassName(name === value)}
+          style={itemStyle(name === value)}
+          onClick={() => onChange(name)}
+        >
+          {(categoryI18n as any)[name] ? t(`explore.category.${name}`) : name}
+        </div>
+      ))}
+    </div>
+  )
+}
+export default React.memo(Category)

+ 86 - 0
web/app/components/explore/create-app-modal/index.tsx

@@ -0,0 +1,86 @@
+'use client'
+import React, { useState } from 'react'
+import cn from 'classnames'
+import { useTranslation } from 'react-i18next'
+import Modal from '@/app/components/base/modal'
+import Button from '@/app/components/base/button'
+import Toast from '@/app/components/base/toast'
+import AppIcon from '@/app/components/base/app-icon'
+import EmojiPicker from '@/app/components/base/emoji-picker'
+
+import s from './style.module.css'
+
+type IProps = {
+  appName: string,
+  show: boolean,
+  onConfirm: (info: any) => void,
+  onHide: () => void,
+}
+
+const CreateAppModal = ({
+  appName,
+  show = false,
+  onConfirm,
+  onHide,
+}: IProps) => {
+  const { t } = useTranslation()
+
+  const [name, setName] = React.useState('')
+
+  const [showEmojiPicker, setShowEmojiPicker] = useState(false)
+  const [emoji, setEmoji] = useState({ icon: '🍌', icon_background: '#FFEAD5' })
+
+  const submit = () => {
+    if(!name.trim()) {
+      Toast.notify({ type: 'error', message: t('explore.appCustomize.nameRequired') })
+      return
+    }
+    onConfirm({
+      name,
+      ...emoji,
+    })
+    onHide()
+  }
+
+  return (
+    <>
+    <Modal
+      isShow={show}
+      onClose={onHide}
+      className={cn(s.modal, '!max-w-[480px]', 'px-8')}
+    >
+      <span className={s.close} onClick={onHide}/>
+      <div className={s.title}>{t('explore.appCustomize.title', {name: appName})}</div>
+      <div className={s.content}>
+        <div className={s.subTitle}>{t('explore.appCustomize.subTitle')}</div>
+        <div className='flex items-center justify-between space-x-3'>
+          <AppIcon size='large' onClick={() => { setShowEmojiPicker(true) }} className='cursor-pointer' icon={emoji.icon} background={emoji.icon_background} />
+          <input 
+            value={name}
+            onChange={e => setName(e.target.value)}
+            className='h-10 px-3 text-sm font-normal bg-gray-100 rounded-lg grow'
+          />
+        </div>
+      </div>      
+      <div className='flex flex-row-reverse'>
+        <Button className='w-24 ml-2' type='primary' onClick={submit}>{t('common.operation.create')}</Button>
+        <Button className='w-24' onClick={onHide}>{t('common.operation.cancel')}</Button>
+      </div>
+    </Modal>
+    {showEmojiPicker && <EmojiPicker
+      onSelect={(icon, icon_background) => {
+        console.log(icon, icon_background)
+        setEmoji({ icon, icon_background })
+        setShowEmojiPicker(false)
+      }}
+      onClose={() => {
+        setEmoji({ icon: '🍌', icon_background: '#FFEAD5' })
+        setShowEmojiPicker(false)
+      }}
+    />}
+    </>
+    
+  )
+}
+
+export default CreateAppModal

+ 36 - 0
web/app/components/explore/create-app-modal/style.module.css

@@ -0,0 +1,36 @@
+.modal {
+  position: relative;
+}
+
+.modal .close {
+  position: absolute;
+  right: 16px;
+  top: 25px;
+  width: 32px;
+  height: 32px;
+  border-radius: 8px;
+  background: center no-repeat url(~@/app/components/datasets/create/assets/close.svg);
+  background-size: 16px;
+  cursor: pointer;
+}
+
+.modal .title {
+  @apply mb-9;
+  font-weight: 600;
+  font-size: 20px;
+  line-height: 30px;
+  color: #101828;
+}
+
+.modal .content {
+  @apply mb-9;
+  font-weight: 400;
+  font-size: 14px;
+  line-height: 20px;
+  color: #101828;
+}
+
+.subTitle {
+  margin-bottom: 8px;
+  font-weight: 500;
+}

+ 51 - 0
web/app/components/explore/index.tsx

@@ -0,0 +1,51 @@
+'use client'
+import React, { FC, useEffect, useState } from 'react'
+import ExploreContext from '@/context/explore-context'
+import Sidebar from '@/app/components/explore/sidebar'
+import { useAppContext } from '@/context/app-context'
+import { fetchMembers } from '@/service/common'
+import { InstalledApp } from '@/models/explore'
+
+export interface IExploreProps {
+  children: React.ReactNode
+}
+
+const Explore: FC<IExploreProps> = ({
+  children
+}) => {
+  const [controlUpdateInstalledApps, setControlUpdateInstalledApps] = useState(0)
+  const { userProfile } = useAppContext()
+  const [hasEditPermission, setHasEditPermission] = useState(false)
+  const [installedApps, setInstalledApps] = useState<InstalledApp[]>([])
+
+  useEffect(() => {
+    (async () => {
+      const { accounts } = await fetchMembers({ url: '/workspaces/current/members', params: {}})
+      if(!accounts) return
+      const currUser = accounts.find(account => account.id === userProfile.id)
+      setHasEditPermission(currUser?.role !== 'normal')
+    })()
+  }, [])
+
+  return (
+    <div className='flex h-full bg-gray-100 border-t border-gray-200'>
+      <ExploreContext.Provider
+        value={
+          {
+            controlUpdateInstalledApps,
+            setControlUpdateInstalledApps,
+            hasEditPermission,
+            installedApps,
+            setInstalledApps
+          }
+        }
+      >
+        <Sidebar controlUpdateInstalledApps={controlUpdateInstalledApps} />
+        <div className='grow'>
+          {children}
+        </div>
+      </ExploreContext.Provider>
+    </div>
+  )
+}
+export default React.memo(Explore)

+ 37 - 0
web/app/components/explore/installed-app/index.tsx

@@ -0,0 +1,37 @@
+'use client'
+import React, { FC } from 'react'
+import { useContext } from 'use-context-selector'
+import ExploreContext from '@/context/explore-context'
+import ChatApp from '@/app/components/share/chat'
+import TextGenerationApp from '@/app/components/share/text-generation'
+import Loading from '@/app/components/base/loading'
+
+export interface IInstalledAppProps {
+  id: string
+}
+
+const InstalledApp: FC<IInstalledAppProps> = ({
+  id,
+}) => {
+  const { installedApps } = useContext(ExploreContext)
+  const installedApp  = installedApps.find(item => item.id === id)
+  
+  if(!installedApp) {
+    return (
+      <div className='flex h-full items-center'>
+        <Loading type='area' />
+      </div>
+    )
+  }
+  
+  return (
+    <div className='h-full p-2'>
+      {installedApp?.app.mode === 'chat' ? (
+        <ChatApp isInstalledApp installedAppInfo={installedApp}/>
+      ): (
+        <TextGenerationApp isInstalledApp installedAppInfo={installedApp}/>
+      )}
+    </div>
+  )
+}
+export default React.memo(InstalledApp)

+ 60 - 0
web/app/components/explore/item-operation/index.tsx

@@ -0,0 +1,60 @@
+'use client'
+import React, { FC } from 'react'
+import cn from 'classnames'
+import { useTranslation } from 'react-i18next'
+import Popover from '@/app/components/base/popover'
+import { TrashIcon } from '@heroicons/react/24/outline'
+
+import s from './style.module.css'
+
+const PinIcon = (
+  <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+    <path d="M8.00012 9.99967L8.00012 14.6663M5.33346 4.87176V6.29217C5.33346 6.43085 5.33346 6.50019 5.31985 6.56652C5.30777 6.62536 5.2878 6.6823 5.26047 6.73579C5.22966 6.79608 5.18635 6.85023 5.09972 6.95852L4.0532 8.26667C3.60937 8.82145 3.38746 9.09884 3.38721 9.33229C3.38699 9.53532 3.4793 9.72738 3.63797 9.85404C3.82042 9.99967 4.17566 9.99967 4.88612 9.99967H11.1141C11.8246 9.99967 12.1798 9.99967 12.3623 9.85404C12.5209 9.72738 12.6133 9.53532 12.613 9.33229C12.6128 9.09884 12.3909 8.82145 11.947 8.26667L10.9005 6.95852C10.8139 6.85023 10.7706 6.79608 10.7398 6.73579C10.7125 6.6823 10.6925 6.62536 10.6804 6.56652C10.6668 6.50019 10.6668 6.43085 10.6668 6.29217V4.87176C10.6668 4.79501 10.6668 4.75664 10.6711 4.71879C10.675 4.68517 10.6814 4.6519 10.6903 4.61925C10.7003 4.5825 10.7146 4.54687 10.7431 4.47561L11.415 2.79582C11.611 2.30577 11.709 2.06074 11.6682 1.86404C11.6324 1.69203 11.5302 1.54108 11.3838 1.44401C11.2163 1.33301 10.9524 1.33301 10.4246 1.33301H5.57563C5.04782 1.33301 4.78391 1.33301 4.61646 1.44401C4.47003 1.54108 4.36783 1.69203 4.33209 1.86404C4.29122 2.06074 4.38923 2.30577 4.58525 2.79583L5.25717 4.47561C5.28567 4.54687 5.29992 4.5825 5.30995 4.61925C5.31886 4.6519 5.32526 4.68517 5.32912 4.71879C5.33346 4.75664 5.33346 4.79501 5.33346 4.87176Z" stroke="#667085" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
+  </svg>
+)
+
+export interface IItemOperationProps {
+  className?: string
+  isPinned: boolean
+  isShowDelete: boolean
+  togglePin: () => void
+  onDelete: () => void
+}
+
+const ItemOperation: FC<IItemOperationProps> = ({
+  className,
+  isPinned,
+  isShowDelete,
+  togglePin,
+  onDelete
+}) => {
+  const { t } = useTranslation()
+
+  return (
+    <Popover
+      htmlContent={
+        <div className='w-full py-1' onClick={(e) => {
+          e.stopPropagation()
+        }}>
+          <div className={cn(s.actionItem, 'hover:bg-gray-50 group')} onClick={togglePin}>
+            {PinIcon}
+            <span className={s.actionName}>{isPinned ? t('explore.sidebar.action.unpin') : t('explore.sidebar.action.pin')}</span>
+          </div>
+          {isShowDelete && (
+            <div className={cn(s.actionItem, s.deleteActionItem, 'hover:bg-gray-50 group')} onClick={onDelete} >
+            <TrashIcon className={'w-4 h-4 stroke-current text-gray-500 stroke-2 group-hover:text-red-500'} />
+            <span className={cn(s.actionName, 'group-hover:text-red-500')}>{t('explore.sidebar.action.delete')}</span>
+          </div>
+          )}
+          
+        </div>
+      }
+      trigger='click'
+      position='br'
+      btnElement={<div />}
+      btnClassName={(open) => cn(className, s.btn, 'h-6 w-6 rounded-md border-none p-1', open && '!bg-gray-100 !shadow-none')}
+      className={`!w-[120px] h-fit !z-20`}
+    />
+  )
+}
+export default React.memo(ItemOperation)

+ 31 - 0
web/app/components/explore/item-operation/style.module.css

@@ -0,0 +1,31 @@
+.actionItem {
+  @apply h-9 py-2 px-3 mx-1 flex items-center gap-2 rounded-lg cursor-pointer;
+}
+
+
+.actionName {
+  @apply text-gray-700 text-sm;
+}
+
+.commonIcon {
+  @apply w-4 h-4 inline-block align-middle;
+  background-repeat: no-repeat;
+  background-position: center center;
+  background-size: contain;
+}
+
+.actionIcon {
+  @apply bg-gray-500;
+  mask-image: url(~@/app/components/datasets/documents/assets/action.svg);
+}
+
+body .btn {
+  background: url(~@/app/components/datasets/documents/assets/action.svg) center center no-repeat transparent;
+  background-size: 16px 16px;
+  /* mask-image: ; */
+}
+
+body .btn:hover {
+  /* background-image: ; */
+  background-color: #F2F4F7;
+}

+ 73 - 0
web/app/components/explore/sidebar/app-nav-item/index.tsx

@@ -0,0 +1,73 @@
+'use client'
+import cn from 'classnames'
+import { useRouter } from 'next/navigation'
+import ItemOperation from '@/app/components/explore/item-operation'
+import AppIcon from '@/app/components/base/app-icon'
+
+import s from './style.module.css'
+
+export interface IAppNavItemProps {
+  name: string
+  id: string
+  icon: string
+  icon_background: string
+  isSelected: boolean
+  isPinned: boolean
+  togglePin: () => void
+  uninstallable: boolean
+  onDelete: (id: string) => void
+}
+
+export default function AppNavItem({
+  name,
+  id,
+  icon,
+  icon_background,
+  isSelected,
+  isPinned,
+  togglePin,
+  uninstallable,
+  onDelete,
+}: IAppNavItemProps) {
+  const router = useRouter()
+  const url = `/explore/installed/${id}`
+  
+  return (
+    <div
+      key={id}
+      className={cn(
+        s.item,
+        isSelected ? s.active : 'hover:bg-gray-200',
+        'flex h-8 justify-between px-2 rounded-lg text-sm font-normal ',
+      )}
+      onClick={() => { 
+        router.push(url) // use Link causes popup item always trigger jump. Can not be solved by e.stopPropagation().
+      }}
+    >
+      <div className='flex items-center space-x-2 w-0 grow'>
+        {/* <div
+          className={cn(
+            'shrink-0 mr-2 h-6 w-6 rounded-md border bg-[#D5F5F6]',
+          )}
+          style={{
+            borderColor: '0.5px solid rgba(0, 0, 0, 0.05)'
+          }}
+        /> */}
+        <AppIcon size='tiny'  icon={icon} background={icon_background} />
+        <div className='overflow-hidden text-ellipsis whitespace-nowrap'>{name}</div>
+      </div>
+      {
+        !isSelected && (
+          <div className={cn(s.opBtn, 'shrink-0')} onClick={e => e.stopPropagation()}>
+            <ItemOperation
+              isPinned={isPinned}
+              togglePin={togglePin}
+              isShowDelete={!uninstallable}
+              onDelete={() => onDelete(id)}
+            />
+          </div>
+        )
+      }
+    </div>
+  )
+}

+ 17 - 0
web/app/components/explore/sidebar/app-nav-item/style.module.css

@@ -0,0 +1,17 @@
+/* .item:hover, */
+.item.active {
+  border: 0.5px solid #EAECF0;
+  box-shadow: 0px 1px 2px rgba(16, 24, 40, 0.05);
+  border-radius: 8px;
+  background: #FFFFFF;
+  color: #344054;
+  font-weight: 500;
+}
+
+.opBtn {
+  visibility: hidden;
+}
+
+.item:hover .opBtn {
+  visibility: visible;
+}

File diff suppressed because it is too large
+ 15 - 0
web/app/components/explore/sidebar/index.tsx


+ 18 - 3
web/app/components/header/index.tsx

@@ -15,6 +15,12 @@ import NewAppDialog from '@/app/(commonLayout)/apps/NewAppDialog'
 import { WorkspaceProvider } from '@/context/workspace-context'
 import { useDatasetsContext } from '@/context/datasets-context'
 
+const BuildAppsIcon = ({isSelected}: {isSelected: boolean}) => (
+  <svg className='mr-1' width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+    <path d="M13.6666 4.85221L7.99998 8.00036M7.99998 8.00036L2.33331 4.85221M7.99998 8.00036L8 14.3337M14 10.7061V5.29468C14 5.06625 14 4.95204 13.9663 4.85017C13.9366 4.76005 13.8879 4.67733 13.8236 4.60754C13.7509 4.52865 13.651 4.47318 13.4514 4.36224L8.51802 1.6215C8.32895 1.51646 8.23442 1.46395 8.1343 1.44336C8.0457 1.42513 7.95431 1.42513 7.8657 1.44336C7.76559 1.46395 7.67105 1.51646 7.48198 1.6215L2.54865 4.36225C2.34896 4.47318 2.24912 4.52865 2.17642 4.60754C2.11211 4.67733 2.06343 4.76005 2.03366 4.85017C2 4.95204 2 5.06625 2 5.29468V10.7061C2 10.9345 2 11.0487 2.03366 11.1506C2.06343 11.2407 2.11211 11.3234 2.17642 11.3932C2.24912 11.4721 2.34897 11.5276 2.54865 11.6385L7.48198 14.3793C7.67105 14.4843 7.76559 14.5368 7.8657 14.5574C7.95431 14.5756 8.0457 14.5756 8.1343 14.5574C8.23442 14.5368 8.32895 14.4843 8.51802 14.3793L13.4514 11.6385C13.651 11.5276 13.7509 11.4721 13.8236 11.3932C13.8879 11.3234 13.9366 11.2407 13.9663 11.1506C14 11.0487 14 10.9345 14 10.7061Z" stroke={isSelected ? '#155EEF': '#667085'} strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round"/>
+  </svg>
+)
+
 export type IHeaderProps = {
   appItems: AppDetailResponse[]
   curApp: AppDetailResponse
@@ -38,8 +44,9 @@ const Header: FC<IHeaderProps> = ({ appItems, curApp, userProfile, onLogout, lan
   const { datasets, currentDataset } = useDatasetsContext()
   const router = useRouter()
   const showEnvTag = langeniusVersionInfo.current_env === 'TESTING' || langeniusVersionInfo.current_env === 'DEVELOPMENT'
-  const isPluginsComingSoon = useSelectedLayoutSegment() === 'plugins-coming-soon'
-
+  const selectedSegment = useSelectedLayoutSegment()
+  const isPluginsComingSoon = selectedSegment === 'plugins-coming-soon'
+  const isExplore = selectedSegment === 'explore'
   return (
     <div className={classNames(
       'sticky top-0 left-0 right-0 z-20 flex bg-gray-100 grow-0 shrink-0 basis-auto h-14',
@@ -64,8 +71,16 @@ const Header: FC<IHeaderProps> = ({ appItems, curApp, userProfile, onLogout, lan
           </div>
         </div>
         <div className='flex items-center'>
+          {/* <Link href="/explore/apps" className={classNames(
+            navClassName, 'group',
+            isExplore && 'bg-white shadow-[0_2px_5px_-1px_rgba(0,0,0,0.05),0_2px_4px_-2px_rgba(0,0,0,0.05)]',
+            isExplore ? 'text-primary-600' : 'text-gray-500 hover:bg-gray-200 hover:text-gray-700'
+          )}>
+            <Squares2X2Icon className='mr-1 w-[18px] h-[18px]' />
+            {t('common.menus.explore')}
+          </Link> */}
           <Nav
-            icon={<Squares2X2Icon className='mr-1 w-[18px] h-[18px]' />}
+            icon={<BuildAppsIcon isSelected={['apps', 'app'].includes(selectedSegment || '')} />}
             text={t('common.menus.apps')}
             activeSegment={['apps', 'app']}
             link='/apps'

+ 71 - 29
web/app/components/share/chat/index.tsx

@@ -23,16 +23,19 @@ import { replaceStringWithValues } from '@/app/components/app/configuration/prom
 import AppUnavailable from '../../base/app-unavailable'
 import { userInputsFormToPromptVariables } from '@/utils/model-config'
 import { SuggestedQuestionsAfterAnswerConfig } from '@/models/debug'
+import { InstalledApp } from '@/models/explore'
+
+import s from './style.module.css'
+
 export type IMainProps = {
-  params: {
-    locale: string
-    appId: string
-    conversationId: string
-    token: string
-  }
+  isInstalledApp?: boolean,
+  installedAppInfo? : InstalledApp
 }
 
-const Main: FC<IMainProps> = () => {
+const Main: FC<IMainProps> = ({
+  isInstalledApp = false,
+  installedAppInfo
+}) => {
   const { t } = useTranslation()
   const media = useBreakpoints()
   const isMobile = media === MediaType.mobile
@@ -80,6 +83,11 @@ const Main: FC<IMainProps> = () => {
     setNewConversationInfo,
     setExistConversationInfo
   } = useConversation()
+  const [hasMore, setHasMore] = useState<boolean>(false)
+  const onMoreLoaded = ({ data: conversations, has_more }: any) => {
+    setHasMore(has_more)
+    setConversationList([...conversationList, ...conversations])
+  }
   const [suggestedQuestionsAfterAnswerConfig, setSuggestedQuestionsAfterAnswerConfig] = useState<SuggestedQuestionsAfterAnswerConfig | null>(null)
 
   const [conversationIdChangeBecauseOfNew, setConversationIdChangeBecauseOfNew, getConversationIdChangeBecauseOfNew] = useGetState(false)
@@ -129,7 +137,7 @@ const Main: FC<IMainProps> = () => {
 
     // update chat list of current conversation 
     if (!isNewConversation && !conversationIdChangeBecauseOfNew && !isResponsing) {
-      fetchChatList(currConversationId).then((res: any) => {
+      fetchChatList(currConversationId, isInstalledApp, installedAppInfo?.id).then((res: any) => {
         const { data } = res
         const newChatList: IChatItem[] = generateNewChatListWithOpenstatement(notSyncToStateIntroduction, notSyncToStateInputs)
 
@@ -222,28 +230,41 @@ const Main: FC<IMainProps> = () => {
     return []
   }
 
+  const fetchInitData = () => {
+    return Promise.all([isInstalledApp ? {
+      app_id: installedAppInfo?.id, 
+      site: {
+        title: installedAppInfo?.app.name,
+        prompt_public: false,
+        copyright: ''
+      },
+      plan: 'basic',
+    }: fetchAppInfo(), fetchConversations(isInstalledApp, installedAppInfo?.id), fetchAppParams(isInstalledApp, installedAppInfo?.id)])
+  }
+
 
   // init
   useEffect(() => {
     (async () => {
       try {
-        const [appData, conversationData, appParams] = await Promise.all([fetchAppInfo(), fetchConversations(), fetchAppParams()])
-        const { app_id: appId, site: siteInfo, model_config, plan }: any = appData
+        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 = tempIsPublicVersion ? model_config.pre_prompt : ''
-
+        const prompt_template = ''
         // handle current conversation id
-        const { data: conversations } = conversationData as { data: ConversationItem[] }
+        const { data: conversations, has_more } = conversationData as { data: ConversationItem[], has_more: boolean }
         const _conversationId = getConversationIdFromStorage(appId)
         const isNotNewConversation = conversations.some(item => item.id === _conversationId)
-
+        setHasMore(has_more)
         // fetch new conversation info
         const { user_input_form, opening_statement: introduction, suggested_questions_after_answer }: any = appParams
         const prompt_variables = userInputsFormToPromptVariables(user_input_form)
-        changeLanguage(siteInfo.default_language)
+        if(siteInfo.default_language) {
+          changeLanguage(siteInfo.default_language)
+        }
         setNewConversationInfo({
           name: t('share.chat.newChatDefaultName'),
           introduction,
@@ -379,7 +400,8 @@ const Main: FC<IMainProps> = () => {
         }
         let currChatList = conversationList
         if (getConversationIdChangeBecauseOfNew()) {
-          const { data: conversations }: any = await fetchConversations()
+          const { data: conversations, has_more }: any = await fetchConversations(isInstalledApp, installedAppInfo?.id)
+          setHasMore(has_more)
           setConversationList(conversations as ConversationItem[])
           currChatList = conversations
         }
@@ -388,7 +410,7 @@ const Main: FC<IMainProps> = () => {
         setChatNotStarted()
         setCurrConversationId(tempNewConversationId, appId, true)
         if (suggestedQuestionsAfterAnswerConfig?.enabled) {
-          const { data }: any = await fetchSuggestedQuestions(responseItem.id)
+          const { data }: any = await fetchSuggestedQuestions(responseItem.id, isInstalledApp, installedAppInfo?.id)
           setSuggestQuestions(data)
           setIsShowSuggestion(true)
         }
@@ -400,11 +422,11 @@ const Main: FC<IMainProps> = () => {
           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 } })
+    await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating } }, isInstalledApp, installedAppInfo?.id)
     const newChatList = chatList.map((item) => {
       if (item.id === messageId) {
         return {
@@ -424,9 +446,14 @@ const Main: FC<IMainProps> = () => {
     return (
       <Sidebar
         list={conversationList}
+        onMoreLoaded={onMoreLoaded}
+        isNoMore={!hasMore}
         onCurrentIdChange={handleConversationIdChange}
         currentId={currConversationId}
         copyRight={siteInfo.copyright || siteInfo.title}
+        isInstalledApp={isInstalledApp}
+        installedAppId={installedAppInfo?.id}
+        siteInfo={siteInfo}
       />
     )
   }
@@ -439,18 +466,29 @@ const Main: FC<IMainProps> = () => {
 
   return (
     <div className='bg-gray-100'>
-      <Header
-        title={siteInfo.title}
-        icon={siteInfo.icon || ''}
-        icon_background={siteInfo.icon_background || '#FFEAD5'}
-        isMobile={isMobile}
-        onShowSideBar={showSidebar}
-        onCreateNewChat={() => handleConversationIdChange('-1')}
-      />
+      {!isInstalledApp && (
+        <Header
+          title={siteInfo.title}
+          icon={siteInfo.icon || ''}
+          icon_background={siteInfo.icon_background}
+          isMobile={isMobile}
+          onShowSideBar={showSidebar}
+          onCreateNewChat={() => handleConversationIdChange('-1')}
+        />
+      )}
+      
       {/* {isNewConversation ? 'new' : 'exist'}
         {JSON.stringify(newConversationInputs ? newConversationInputs : {})}
         {JSON.stringify(existConversationInputs ? existConversationInputs : {})} */}
-      <div className="flex rounded-t-2xl bg-white overflow-hidden">
+      <div 
+        className={cn(
+          "flex rounded-t-2xl bg-white overflow-hidden",
+          isInstalledApp && 'rounded-b-2xl',
+        )}
+        style={isInstalledApp ? {
+          boxShadow: '0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03)'
+        } : {}}
+      >
         {/* sidebar */}
         {!isMobile && renderSidebar()}
         {isMobile && isShowSidebar && (
@@ -464,7 +502,11 @@ const Main: FC<IMainProps> = () => {
           </div>
         )}
         {/* main */}
-        <div className='flex-grow flex flex-col h-[calc(100vh_-_3rem)] overflow-y-auto'>
+        <div className={cn(
+          isInstalledApp ? s.installedApp : 'h-[calc(100vh_-_3rem)]',
+          'flex-grow flex flex-col overflow-y-auto'
+          )
+        }>
           <ConfigSence
             conversationName={conversationName}
             hasSetInputs={hasSetInputs}

+ 27 - 0
web/app/components/share/chat/sidebar/app-info/index.tsx

@@ -0,0 +1,27 @@
+'use client'
+import React, { FC } from 'react'
+import cn  from 'classnames'
+import { appDefaultIconBackground } from '@/config/index'
+import AppIcon from '@/app/components/base/app-icon'
+
+export interface 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)

+ 61 - 15
web/app/components/share/chat/sidebar/index.tsx

@@ -1,4 +1,4 @@
-import React from 'react'
+import React, { useEffect, useRef } from 'react'
 import type { FC } from 'react'
 import { useTranslation } from 'react-i18next'
 import {
@@ -7,43 +7,89 @@ import {
 } from '@heroicons/react/24/outline'
 import { ChatBubbleOvalLeftEllipsisIcon as ChatBubbleOvalLeftEllipsisSolidIcon, } from '@heroicons/react/24/solid'
 import Button from '../../../base/button'
+import AppInfo from '@/app/components/share/chat/sidebar/app-info'
 // import Card from './card'
-import type { ConversationItem } from '@/models/share'
+import type { ConversationItem, SiteInfo } from '@/models/share'
+import { useInfiniteScroll } from 'ahooks'
+import { fetchConversations } from '@/service/share'
 
 function classNames(...classes: any[]) {
   return classes.filter(Boolean).join(' ')
 }
 
-const MAX_CONVERSATION_LENTH = 20
-
 export type ISidebarProps = {
   copyRight: string
   currentId: string
   onCurrentIdChange: (id: string) => void
   list: ConversationItem[]
+  isInstalledApp: boolean
+  installedAppId?: string
+  siteInfo: SiteInfo
+  onMoreLoaded: (res: {data: ConversationItem[], has_more: boolean}) => void
+  isNoMore: boolean
 }
 
 const Sidebar: FC<ISidebarProps> = ({
   copyRight,
   currentId,
   onCurrentIdChange,
-  list }) => {
+  list,
+  isInstalledApp,
+  installedAppId,
+  siteInfo,
+  onMoreLoaded,
+  isNoMore,
+}) => {
   const { t } = useTranslation()
+  const listRef = useRef<HTMLDivElement>(null)
+
+  useInfiniteScroll(
+    async () => {
+      if(!isNoMore) {
+        const lastId = list[list.length - 1].id
+        const { data: conversations, has_more }: any = await fetchConversations(isInstalledApp, installedAppId, lastId)
+        onMoreLoaded({ data: conversations, has_more })
+      }
+      return {list: []}
+    },
+    {
+      target: listRef,
+      isNoMore: () => {
+        return isNoMore
+      },
+      reloadDeps: [isNoMore]
+    },
+  )
+
   return (
     <div
-      className="shrink-0 flex flex-col overflow-y-auto bg-white pc:w-[244px] tablet:w-[192px] mobile:w-[240px]  border-r border-gray-200 tablet:h-[calc(100vh_-_3rem)] mobile:h-screen"
+      className={
+        classNames(
+          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"
+        )
+      }
     >
-      {list.length < MAX_CONVERSATION_LENTH && (
-        <div className="flex flex-shrink-0 p-4 !pb-0">
-          <Button
-            onClick={() => { onCurrentIdChange('-1') }}
-            className="group block w-full flex-shrink-0 !justify-start !h-9 text-primary-600 items-center text-sm">
-            <PencilSquareIcon className="mr-2 h-4 w-4" /> {t('share.chat.newChat')}
-          </Button>
-        </div>
+      {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>
 
-      <nav className="mt-4 flex-1 space-y-1 bg-white p-4 !pt-0">
+      <nav
+        ref={listRef}
+        className="mt-4 flex-1 space-y-1 bg-white p-4 !pt-0 overflow-y-auto"
+      >
         {list.map((item) => {
           const isCurrent = item.id === currentId
           const ItemIcon

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

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

+ 50 - 13
web/app/components/share/text-generation/index.tsx

@@ -1,5 +1,5 @@
 'use client'
-import React, { useEffect, useState, useRef } from 'react'
+import React, { FC, useEffect, useState, useRef } from 'react'
 import { useTranslation } from 'react-i18next'
 import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
 import cn from 'classnames'
@@ -22,8 +22,18 @@ import TabHeader from '../../base/tab-header'
 import { XMarkIcon } from '@heroicons/react/24/outline'
 import s from './style.module.css'
 import Button from '../../base/button'
+import { App } from '@/types/app'
+import { InstalledApp } from '@/models/explore'
 
-const TextGeneration = () => {
+export type IMainProps = {
+  isInstalledApp?: boolean,
+  installedAppInfo? : InstalledApp
+}
+
+const TextGeneration: FC<IMainProps> = ({
+  isInstalledApp = false,
+  installedAppInfo
+}) => {
   const { t } = useTranslation()
   const media = useBreakpoints()
   const isPC = media === MediaType.pc
@@ -49,14 +59,14 @@ const TextGeneration = () => {
   })
 
   const handleFeedback = async (feedback: Feedbacktype) => {
-    await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating } })
+    await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating } }, isInstalledApp, installedAppInfo?.id)
     setFeedback(feedback)
   }
 
   const [savedMessages, setSavedMessages] = useState<SavedMessage[]>([])
 
   const fetchSavedMessage = async () => {
-    const res: any = await doFetchSavedMessage()
+    const res: any = await doFetchSavedMessage(isInstalledApp, installedAppInfo?.id)
     setSavedMessages(res.data)
   }
 
@@ -65,13 +75,13 @@ const TextGeneration = () => {
   }, [])
 
   const handleSaveMessage = async (messageId: string) => {
-    await saveMessage(messageId)
+    await saveMessage(messageId, isInstalledApp, installedAppInfo?.id)
     notify({ type: 'success', message: t('common.api.saved') })
     fetchSavedMessage()
   }
 
   const handleRemoveSavedMessage = async (messageId: string) => {
-    await removeMessage(messageId)
+    await removeMessage(messageId, isInstalledApp, installedAppInfo?.id)
     notify({ type: 'success', message: t('common.api.remove') })
     fetchSavedMessage()
   }
@@ -151,12 +161,24 @@ const TextGeneration = () => {
       onError() {
         setResponsingFalse()
       }
-    })
+    }, isInstalledApp, installedAppInfo?.id)
+  }
+
+  const fetchInitData = () => {
+    return Promise.all([isInstalledApp ? {
+      app_id: installedAppInfo?.id, 
+      site: {
+        title: installedAppInfo?.app.name,
+        prompt_public: false,
+        copyright: ''
+      },
+      plan: 'basic',
+    }: fetchAppInfo(), fetchAppParams(isInstalledApp, installedAppInfo?.id)])
   }
 
   useEffect(() => {
     (async () => {
-      const [appData, appParams]: any = await Promise.all([fetchAppInfo(), fetchAppParams()])
+      const [appData, appParams]: any = await fetchInitData()
       const { app_id: appId, site: siteInfo } = appData
       setAppId(appId)
       setSiteInfo(siteInfo as SiteInfo)
@@ -188,9 +210,11 @@ const TextGeneration = () => {
     <div
       ref={resRef}
       className={
-        cn("flex flex-col h-full shrink-0",
+        cn(
+          "flex flex-col h-full shrink-0",
           isPC ? 'px-10 py-8' : 'bg-gray-50',
-          isTablet && 'p-6', isMoble && 'p-4')}
+          isTablet && 'p-6', isMoble && 'p-4')
+        }
     >
       <>
         <div className='shrink-0 flex items-center justify-between'>
@@ -227,6 +251,8 @@ const TextGeneration = () => {
                     feedback={feedback}
                     onSave={handleSaveMessage}
                     isMobile={isMoble}
+                    isInstalledApp={isInstalledApp}
+                    installedAppId={installedAppInfo?.id}
                   />
                 )
               }
@@ -243,9 +269,17 @@ const TextGeneration = () => {
 
   return (
     <>
-      <div className={cn(isPC && 'flex', 'h-screen bg-gray-50')}>
+      <div className={cn(
+        isPC && 'flex',
+        isInstalledApp ? s.installedApp : 'h-screen',
+        'bg-gray-50'
+      )}>
         {/* Left */}
-        <div className={cn(isPC ? 'w-[600px] max-w-[50%] p-8' : 'p-4', "shrink-0 relative flex flex-col pb-10 h-full border-r border-gray-100 bg-white")}>
+        <div className={cn(
+          isPC ? 'w-[600px] max-w-[50%] p-8' : 'p-4',
+          isInstalledApp && 'rounded-l-2xl',
+          "shrink-0 relative flex flex-col pb-10 h-full border-r border-gray-100 bg-white"
+        )}>
           <div className='mb-6'>
             <div className='flex justify-between items-center'>
               <div className='flex items-center space-x-3'>
@@ -307,7 +341,10 @@ const TextGeneration = () => {
 
 
           {/* copyright */}
-          <div className='fixed left-8 bottom-4  flex space-x-2 text-gray-400 font-normal text-xs'>
+          <div className={cn(
+            isInstalledApp ? 'left-[248px]' : 'left-8',
+            'fixed  bottom-4  flex space-x-2 text-gray-400 font-normal text-xs'
+            )}>
             <div className="">© {siteInfo.copyright || siteInfo.title} {(new Date()).getFullYear()}</div>
             {siteInfo.privacy_policy && (
               <>

+ 6 - 0
web/app/components/share/text-generation/style.module.css

@@ -1,3 +1,9 @@
+.installedApp {
+  height: calc(100vh - 74px);
+  border-radius: 16px;
+  box-shadow: 0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03);
+}
+
 .appIcon {
   width: 32px;
   height: 32px;

+ 4 - 0
web/config/index.ts

@@ -97,5 +97,9 @@ export const VAR_ITEM_TEMPLATE = {
   required: true
 }
 
+export const appDefaultIconBackground = '#D5F5F6'
+
+export const NEED_REFRESH_APP_LIST_KEY = 'needRefreshAppList'
+
 
 

+ 20 - 0
web/context/explore-context.ts

@@ -0,0 +1,20 @@
+import { createContext } from 'use-context-selector'
+import { InstalledApp } from '@/models/explore'
+
+type IExplore = {
+  controlUpdateInstalledApps: number
+  setControlUpdateInstalledApps: (controlUpdateInstalledApps: number) => void
+  hasEditPermission: boolean
+  installedApps: InstalledApp[]
+  setInstalledApps: (installedApps: InstalledApp[]) => void
+}
+
+const ExploreContext = createContext<IExplore>({
+  controlUpdateInstalledApps: 0,
+  setControlUpdateInstalledApps: () => { },
+  hasEditPermission: false,
+  installedApps: [],
+  setInstalledApps: () => { },
+})
+
+export default ExploreContext

+ 4 - 0
web/i18n/i18next-config.ts

@@ -31,6 +31,8 @@ import datasetSettingsEn from './lang/dataset-settings.en'
 import datasetSettingsZh from './lang/dataset-settings.zh'
 import datasetCreationEn from './lang/dataset-creation.en'
 import datasetCreationZh from './lang/dataset-creation.zh'
+import exploreEn from './lang/explore.en'
+import exploreZh from './lang/explore.zh'
 import { getLocaleOnClient } from '@/i18n/client'
 
 const resources = {
@@ -53,6 +55,7 @@ const resources = {
       datasetHitTesting: datasetHitTestingEn,
       datasetSettings: datasetSettingsEn,
       datasetCreation: datasetCreationEn,
+      explore: exploreEn,
     },
   },
   'zh-Hans': {
@@ -74,6 +77,7 @@ const resources = {
       datasetHitTesting: datasetHitTestingZh,
       datasetSettings: datasetSettingsZh,
       datasetCreation: datasetCreationZh,
+      explore: exploreZh,
     },
   },
 }

+ 3 - 1
web/i18n/lang/common.en.ts

@@ -6,6 +6,7 @@ const translation = {
     remove: 'Removed',
   },
   operation: {
+    create: 'Create',
     confirm: 'Confirm',
     cancel: 'Cancel',
     clear: 'Clear',
@@ -61,7 +62,8 @@ const translation = {
   },
   menus: {
     status: 'beta',
-    apps: 'Apps',
+    explore: 'Explore',
+    apps: 'Build Apps',
     plugins: 'Plugins',
     pluginsTips: 'Integrate third-party plugins or create ChatGPT-compatible AI-Plugins.',
     datasets: 'Datasets',

+ 3 - 1
web/i18n/lang/common.zh.ts

@@ -6,6 +6,7 @@ const translation = {
     remove: '已移除',
   },
   operation: {
+    create: '创建',
     confirm: '确认',
     cancel: '取消',
     clear: '清空',
@@ -61,7 +62,8 @@ const translation = {
   },
   menus: {
     status: 'beta',
-    apps: '应用',
+    explore: '探索',
+    apps: '构建应用',
     plugins: '插件',
     pluginsTips: '集成第三方插件或创建与 ChatGPT 兼容的 AI 插件。',
     datasets: '数据集',

+ 38 - 0
web/i18n/lang/explore.en.ts

@@ -0,0 +1,38 @@
+const translation = {
+  sidebar: {
+    discovery: 'Discovery',
+    workspace: 'Workspace',
+    action: {
+      pin: 'Pin',
+      unpin: 'Unpin',
+      delete: 'Delete',
+    },
+    delete: {
+      title: 'Delete app',
+      content: 'Are you sure you want to delete this app?',
+    }
+  },
+  apps: {
+    title: 'Explore Apps by Dify',
+    description: 'Use these template apps instantly or customize your own apps based on the templates.',
+    allCategories: 'All Categories',
+  },
+  appCard: {
+    addToWorkspace: 'Add to Workspace',
+    customize: 'Customize',
+  },
+  appCustomize: {
+    title: 'Create app from {{name}}',
+    subTitle: 'App icon & name',
+    nameRequired: 'App name is required',
+  },
+  category: {
+    'Assistant': 'Assistant',
+    'Writing': 'Writing',
+    'Translate': 'Translate',
+    'Programming': 'Programming',
+    'HR': 'HR',
+  }
+}
+
+export default translation

+ 38 - 0
web/i18n/lang/explore.zh.ts

@@ -0,0 +1,38 @@
+const translation = {
+  sidebar: {
+    discovery: '发现',
+    workspace: '工作区',
+    action: {
+      pin: '置顶',
+      unpin: '取消置顶',
+      delete: '删除',
+    },
+    delete: {
+      title: '删除程序',
+      content: '您确定要删除此程序吗?',
+    }
+  },
+  apps: {
+    title: '探索 Dify 的应用',
+    description: '使用这些模板应用程序,或根据模板自定义您自己的应用程序。',
+    allCategories: '所有类别',
+  },
+  appCard: {
+    addToWorkspace: '添加到工作区',
+    customize: '自定义',
+  },
+  appCustomize: {
+    title: '从 {{name}} 创建应用程序',
+    subTitle: '应用程序图标和名称',
+    nameRequired: '应用程序名称不能为空',
+  },
+  category: {
+    'Assistant': '助手',
+    'Writing': '写作',
+    'Translate': '翻译',
+    'Programming': '编程',
+    'HR': '人力资源',
+  }
+}
+
+export default translation

+ 30 - 0
web/models/explore.ts

@@ -0,0 +1,30 @@
+import { AppMode } from "./app";
+
+export type AppBasicInfo = {
+  id: string;
+  name: string;
+  mode: AppMode;
+  icon: string;
+  icon_background: string;
+}
+
+export type App = {
+  app: AppBasicInfo;
+  app_id: string;
+  description: string;
+  copyright: string;
+  privacy_policy: string;
+  category: string;
+  position: number;
+  is_listed: boolean;
+  install_count: number;
+  installed: boolean;
+  editable: boolean;
+}
+
+export type InstalledApp = {
+  app: AppBasicInfo;
+  id: string;
+  uninstallable: boolean
+  is_pinned: boolean
+}

+ 33 - 0
web/service/explore.ts

@@ -0,0 +1,33 @@
+import { get, post, del, patch } from './base'
+
+export const fetchAppList = () => {
+  return get('/explore/apps')
+}
+
+export const fetchAppDetail = (id: string) : Promise<any> => {
+  return get(`/explore/apps/${id}`)
+}
+
+export const fetchInstalledAppList = () => {
+  return get('/installed-apps')
+}
+
+export const installApp = (id: string) => {
+  return post('/installed-apps', {
+    body: {
+      app_id: id
+    }
+  })
+}
+
+export const uninstallApp = (id: string) => {
+  return del(`/installed-apps/${id}`)
+}
+
+export const updatePinStatus = (id: string, isPinned: boolean) => {
+  return patch(`/installed-apps/${id}`, {
+    body: {
+      is_pinned: isPinned
+    }
+  })
+}

+ 43 - 26
web/service/share.ts

@@ -1,44 +1,62 @@
 import type { IOnCompleted, IOnData, IOnError } from './base'
-import { getPublic as get, postPublic as post, ssePost, delPublic as del } from './base'
+import { 
+  get as consoleGet, post as consolePost, del as consoleDel,
+  getPublic as get, postPublic as post, ssePost, delPublic as del 
+} from './base'
 import type { Feedbacktype } from '@/app/components/app/chat'
 
+function getAction(action: 'get' | 'post' | 'del', isInstalledApp: boolean) {
+  switch (action) {
+    case 'get':
+      return isInstalledApp ? consoleGet : get
+    case 'post':
+      return isInstalledApp ? consolePost : post
+    case 'del':
+      return isInstalledApp ? consoleDel : del
+  }
+}
+
+function getUrl(url: string, isInstalledApp: boolean, installedAppId: string) {
+  return isInstalledApp ? `installed-apps/${installedAppId}/${url.startsWith('/') ? url.slice(1) : url}` : url
+}
+
 export const sendChatMessage = async (body: Record<string, any>, { onData, onCompleted, onError, getAbortController }: {
   onData: IOnData
   onCompleted: IOnCompleted
   onError: IOnError,
   getAbortController?: (abortController: AbortController) => void
-}) => {
-  return ssePost('chat-messages', {
+}, isInstalledApp: boolean, installedAppId = '') => {
+  return ssePost(getUrl('chat-messages', isInstalledApp, installedAppId), {
     body: {
       ...body,
       response_mode: 'streaming',
     },
-  }, { onData, onCompleted, isPublicAPI: true, onError, getAbortController })
+  }, { onData, onCompleted, isPublicAPI: !isInstalledApp, onError, getAbortController })
 }
 
 export const sendCompletionMessage = async (body: Record<string, any>, { onData, onCompleted, onError }: {
   onData: IOnData
   onCompleted: IOnCompleted
   onError: IOnError
-}) => {
-  return ssePost('completion-messages', {
+}, isInstalledApp: boolean, installedAppId = '') => {
+  return ssePost(getUrl('completion-messages', isInstalledApp, installedAppId), {
     body: {
       ...body,
       response_mode: 'streaming',
     },
-  }, { onData, onCompleted, isPublicAPI: true, onError })
+  }, { onData, onCompleted, isPublicAPI: !isInstalledApp, onError })
 }
 
 export const fetchAppInfo = async () => {
   return get('/site')
 }
 
-export const fetchConversations = async () => {
-  return get('conversations', { params: { limit: 20, first_id: '' } })
+export const fetchConversations = async (isInstalledApp: boolean, installedAppId='', last_id?: string) => {
+  return getAction('get', isInstalledApp)(getUrl('conversations', isInstalledApp, installedAppId), { params: {...{ limit: 20 }, ...(last_id ? { last_id } : {}) } })
 }
 
-export const fetchChatList = async (conversationId: string) => {
-  return get('messages', { params: { conversation_id: conversationId, limit: 20, last_id: '' } })
+export const fetchChatList = async (conversationId: string, isInstalledApp: boolean, installedAppId='') => {
+  return getAction('get', isInstalledApp)(getUrl('messages', isInstalledApp, installedAppId), { params: { conversation_id: conversationId, limit: 20, last_id: '' } })
 }
 
 // Abandoned API interface
@@ -47,35 +65,34 @@ export const fetchChatList = async (conversationId: string) => {
 // }
 
 // init value. wait for server update
-export const fetchAppParams = async () => {
-  return get('parameters')
+export const fetchAppParams = async (isInstalledApp: boolean, installedAppId = '') => {
+  return (getAction('get', isInstalledApp))(getUrl('parameters', isInstalledApp, installedAppId))
 }
 
-export const updateFeedback = async ({ url, body }: { url: string; body: Feedbacktype }) => {
-  return post(url, { body })
+export const updateFeedback = async ({ url, body }: { url: string; body: Feedbacktype }, isInstalledApp: boolean, installedAppId = '') => {
+  return (getAction('post', isInstalledApp))(getUrl(url, isInstalledApp, installedAppId), { body })
 }
 
-export const fetcMoreLikeThis = async (messageId: string) => {
-  return get(`/messages/${messageId}/more-like-this`, {
+export const fetchMoreLikeThis = async (messageId: string, isInstalledApp: boolean, installedAppId = '') => {
+  return (getAction('get', isInstalledApp))(getUrl(`/messages/${messageId}/more-like-this`, isInstalledApp, installedAppId), {
     params: {
       response_mode: 'blocking',
     }
   })
 }
 
-export const saveMessage = (messageId: string) => {
-  return post('/saved-messages', { body: { message_id: messageId } })
+export const saveMessage = (messageId: string, isInstalledApp: boolean, installedAppId = '') => {
+  return (getAction('post', isInstalledApp))(getUrl('/saved-messages', isInstalledApp, installedAppId), { body: { message_id: messageId } })
 }
 
-export const fetchSavedMessage = async () => {
-  return get(`/saved-messages`)
+export const fetchSavedMessage = async (isInstalledApp: boolean, installedAppId = '') => {
+  return (getAction('get', isInstalledApp))(getUrl(`/saved-messages`, isInstalledApp, installedAppId))
 }
 
-
-export const removeMessage = (messageId: string) => {
-  return del(`/saved-messages/${messageId}`)
+export const removeMessage = (messageId: string, isInstalledApp: boolean, installedAppId = '') => {
+  return (getAction('del', isInstalledApp))(getUrl(`/saved-messages/${messageId}`, isInstalledApp, installedAppId))
 }
 
-export const fetchSuggestedQuestions = (messageId: string) => {
-  return get(`/messages/${messageId}/suggested-questions`)
+export const fetchSuggestedQuestions = (messageId: string, isInstalledApp: boolean, installedAppId = '') => {
+  return (getAction('get', isInstalledApp))(getUrl(`/messages/${messageId}/suggested-questions`, isInstalledApp, installedAppId))
 }

+ 1 - 0
web/types/app.ts

@@ -210,6 +210,7 @@ export type App = {
   is_demo: boolean
   /** Model configuration */
   model_config: ModelConfig
+  app_model_config: ModelConfig
   /** Timestamp of creation */
   created_at: number
   /** Web Application Configuration */

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