|
'use client' |
|
|
|
import { useCallback, useEffect, useRef, useState } from 'react' |
|
import { useRouter } from 'next/navigation' |
|
import useSWRInfinite from 'swr/infinite' |
|
import { useTranslation } from 'react-i18next' |
|
import { useDebounceFn } from 'ahooks' |
|
import { |
|
RiApps2Line, |
|
RiExchange2Line, |
|
RiMessage3Line, |
|
RiRobot3Line, |
|
} from '@remixicon/react' |
|
import AppCard from './AppCard' |
|
import NewAppCard from './NewAppCard' |
|
import useAppsQueryState from './hooks/useAppsQueryState' |
|
import type { AppListResponse } from '@/models/app' |
|
import { fetchAppList } from '@/service/apps' |
|
import { useAppContext } from '@/context/app-context' |
|
import { NEED_REFRESH_APP_LIST_KEY } from '@/config' |
|
import { CheckModal } from '@/hooks/use-pay' |
|
import TabSliderNew from '@/app/components/base/tab-slider-new' |
|
import { useTabSearchParams } from '@/hooks/use-tab-searchparams' |
|
import Input from '@/app/components/base/input' |
|
import { useStore as useTagStore } from '@/app/components/base/tag-management/store' |
|
import TagManagementModal from '@/app/components/base/tag-management' |
|
import TagFilter from '@/app/components/base/tag-management/filter' |
|
|
|
const getKey = ( |
|
pageIndex: number, |
|
previousPageData: AppListResponse, |
|
activeTab: string, |
|
tags: string[], |
|
keywords: string, |
|
) => { |
|
if (!pageIndex || previousPageData.has_more) { |
|
const params: any = { url: 'apps', params: { page: pageIndex + 1, limit: 30, name: keywords } } |
|
|
|
if (activeTab !== 'all') |
|
params.params.mode = activeTab |
|
else |
|
delete params.params.mode |
|
|
|
if (tags.length) |
|
params.params.tag_ids = tags |
|
|
|
return params |
|
} |
|
return null |
|
} |
|
|
|
const Apps = () => { |
|
const { t } = useTranslation() |
|
const router = useRouter() |
|
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator } = useAppContext() |
|
const showTagManagementModal = useTagStore(s => s.showTagManagementModal) |
|
const [activeTab, setActiveTab] = useTabSearchParams({ |
|
defaultTab: 'all', |
|
}) |
|
const { query: { tagIDs = [], keywords = '' }, setQuery } = useAppsQueryState() |
|
const [tagFilterValue, setTagFilterValue] = useState<string[]>(tagIDs) |
|
const [searchKeywords, setSearchKeywords] = useState(keywords) |
|
const setKeywords = useCallback((keywords: string) => { |
|
setQuery(prev => ({ ...prev, keywords })) |
|
}, [setQuery]) |
|
const setTagIDs = useCallback((tagIDs: string[]) => { |
|
setQuery(prev => ({ ...prev, tagIDs })) |
|
}, [setQuery]) |
|
|
|
const { data, isLoading, setSize, mutate } = useSWRInfinite( |
|
(pageIndex: number, previousPageData: AppListResponse) => getKey(pageIndex, previousPageData, activeTab, tagIDs, searchKeywords), |
|
fetchAppList, |
|
{ revalidateFirstPage: true }, |
|
) |
|
|
|
const anchorRef = useRef<HTMLDivElement>(null) |
|
const options = [ |
|
{ value: 'all', text: t('app.types.all'), icon: <RiApps2Line className='w-[14px] h-[14px] mr-1' /> }, |
|
{ value: 'chat', text: t('app.types.chatbot'), icon: <RiMessage3Line className='w-[14px] h-[14px] mr-1' /> }, |
|
{ value: 'agent-chat', text: t('app.types.agent'), icon: <RiRobot3Line className='w-[14px] h-[14px] mr-1' /> }, |
|
{ value: 'workflow', text: t('app.types.workflow'), icon: <RiExchange2Line className='w-[14px] h-[14px] mr-1' /> }, |
|
] |
|
|
|
useEffect(() => { |
|
document.title = `${t('common.menus.apps')} - Dify` |
|
if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') { |
|
localStorage.removeItem(NEED_REFRESH_APP_LIST_KEY) |
|
mutate() |
|
} |
|
}, [mutate, t]) |
|
|
|
useEffect(() => { |
|
if (isCurrentWorkspaceDatasetOperator) |
|
return router.replace('/datasets') |
|
}, [router, isCurrentWorkspaceDatasetOperator]) |
|
|
|
useEffect(() => { |
|
const hasMore = data?.at(-1)?.has_more ?? true |
|
let observer: IntersectionObserver | undefined |
|
if (anchorRef.current) { |
|
observer = new IntersectionObserver((entries) => { |
|
if (entries[0].isIntersecting && !isLoading && hasMore) |
|
setSize((size: number) => size + 1) |
|
}, { rootMargin: '100px' }) |
|
observer.observe(anchorRef.current) |
|
} |
|
return () => observer?.disconnect() |
|
}, [isLoading, setSize, anchorRef, mutate, data]) |
|
|
|
const { run: handleSearch } = useDebounceFn(() => { |
|
setSearchKeywords(keywords) |
|
}, { wait: 500 }) |
|
const handleKeywordsChange = (value: string) => { |
|
setKeywords(value) |
|
handleSearch() |
|
} |
|
|
|
const { run: handleTagsUpdate } = useDebounceFn(() => { |
|
setTagIDs(tagFilterValue) |
|
}, { wait: 500 }) |
|
const handleTagsChange = (value: string[]) => { |
|
setTagFilterValue(value) |
|
handleTagsUpdate() |
|
} |
|
|
|
return ( |
|
<> |
|
<div className='sticky top-0 flex justify-between items-center pt-4 px-12 pb-2 leading-[56px] bg-gray-100 z-10 flex-wrap gap-y-2'> |
|
<TabSliderNew |
|
value={activeTab} |
|
onChange={setActiveTab} |
|
options={options} |
|
/> |
|
<div className='flex items-center gap-2'> |
|
<TagFilter type='app' value={tagFilterValue} onChange={handleTagsChange} /> |
|
<Input |
|
showLeftIcon |
|
showClearIcon |
|
wrapperClassName='w-[200px]' |
|
value={keywords} |
|
onChange={e => handleKeywordsChange(e.target.value)} |
|
onClear={() => handleKeywordsChange('')} |
|
/> |
|
</div> |
|
</div> |
|
<nav className='grid content-start grid-cols-1 gap-4 px-12 pt-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 grow shrink-0'> |
|
{isCurrentWorkspaceEditor |
|
&& <NewAppCard onSuccess={mutate} />} |
|
{data?.map(({ data: apps }) => apps.map(app => ( |
|
<AppCard key={app.id} app={app} onRefresh={mutate} /> |
|
)))} |
|
<CheckModal /> |
|
</nav> |
|
<div ref={anchorRef} className='h-0'> </div> |
|
{showTagManagementModal && ( |
|
<TagManagementModal type='app' show={showTagManagementModal} /> |
|
)} |
|
</> |
|
) |
|
} |
|
|
|
export default Apps |
|
|