Files
dify/web/app/components/apps/list.tsx
2026-05-18 11:19:12 -07:00

351 lines
13 KiB
TypeScript

'use client'
import type { FC } from 'react'
import type { AppListQuery } from '@/contract/console/apps'
import { cn } from '@langgenius/dify-ui/cn'
import { keepPreviousData, useInfiniteQuery, useSuspenseQuery } from '@tanstack/react-query'
import { useDebounce } from 'ahooks'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { CheckModal } from '@/hooks/use-pay'
import dynamic from '@/next/dynamic'
import { consoleQuery } from '@/service/client'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { AppModeEnum } from '@/types/app'
import AppCard from './app-card'
import { AppCardSkeleton } from './app-card-skeleton'
import AppListHeaderFilters from './app-list-header-filters'
import { APP_LIST_SEARCH_DEBOUNCE_MS } from './constants'
import Empty from './empty'
import FirstEmptyState from './first-empty-state'
import Footer from './footer'
import { isAppListCategory, useAppsQueryState } from './hooks/use-apps-query-state'
import { useDSLDragDrop } from './hooks/use-dsl-drag-drop'
import { useWorkflowOnlineUsers } from './hooks/use-workflow-online-users'
import NewAppCard from './new-app-card'
const TagManagementModal = dynamic(() => import('@/features/tag-management/components/tag-management-modal').then(mod => mod.TagManagementModal), {
ssr: false,
})
const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-from-dsl-modal'), {
ssr: false,
})
const CreateAppModal = dynamic(() => import('@/app/components/app/create-app-modal'), {
ssr: false,
})
const CreateAppTemplateDialog = dynamic(() => import('@/app/components/app/create-app-dialog'), {
ssr: false,
})
type Props = {
controlRefreshList?: number
}
const List: FC<Props> = ({
controlRefreshList = 0,
}) => {
const { t } = useTranslation()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
const { onPlanInfoChanged } = useProviderContext()
// eslint-disable-next-line react/use-state -- custom URL query hook, not React.useState
const {
query: { category, tagIDs, keywords, isCreatedByMe, emptyAppList },
setCategory,
setKeywords,
setTagIDs,
setIsCreatedByMe,
} = useAppsQueryState()
const debouncedKeywords = useDebounce(keywords, { wait: APP_LIST_SEARCH_DEBOUNCE_MS })
const newAppCardRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [showTagManagementModal, setShowTagManagementModal] = useState(false)
const [showNewAppTemplateDialog, setShowNewAppTemplateDialog] = useState(false)
const [showNewAppModal, setShowNewAppModal] = useState(false)
const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false)
const [droppedDSLFile, setDroppedDSLFile] = useState<File | undefined>()
const handleDSLFileDropped = useCallback((file: File) => {
setDroppedDSLFile(file)
setShowCreateFromDSLModal(true)
}, [])
const { dragging } = useDSLDragDrop({
onDSLFileDropped: handleDSLFileDropped,
containerRef,
enabled: isCurrentWorkspaceEditor,
})
const appListQuery = useMemo<AppListQuery>(() => ({
page: 1,
limit: 30,
name: debouncedKeywords,
...(tagIDs.length ? { tag_ids: tagIDs } : {}),
...(isCreatedByMe ? { is_created_by_me: isCreatedByMe } : {}),
...(category !== 'all' ? { mode: category } : {}),
}), [category, debouncedKeywords, isCreatedByMe, tagIDs])
const {
data,
isLoading,
isFetching,
isFetchingNextPage,
fetchNextPage,
hasNextPage,
error,
refetch,
} = useInfiniteQuery({
...consoleQuery.apps.list.infiniteOptions({
input: pageParam => ({
query: {
...appListQuery,
page: Number(pageParam),
},
}),
getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined,
initialPageParam: 1,
placeholderData: keepPreviousData,
}),
enabled: !isCurrentWorkspaceDatasetOperator,
refetchInterval: systemFeatures.enable_collaboration_mode ? 10000 : false,
})
useEffect(() => {
if (controlRefreshList > 0) {
refetch()
}
}, [controlRefreshList, refetch])
const anchorRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') {
localStorage.removeItem(NEED_REFRESH_APP_LIST_KEY)
refetch()
}
}, [refetch])
useEffect(() => {
if (isCurrentWorkspaceDatasetOperator)
return
const hasMore = hasNextPage ?? true
let observer: IntersectionObserver | undefined
if (error) {
if (observer)
observer.disconnect()
return
}
if (anchorRef.current && containerRef.current) {
// Calculate dynamic rootMargin: clamps to 100-200px range, using 20% of container height as the base value for better responsiveness
const containerHeight = containerRef.current.clientHeight
const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200)) // Clamps to 100-200px range, using 20% of container height as the base value
observer = new IntersectionObserver((entries) => {
if (entries[0]!.isIntersecting && !isLoading && !isFetchingNextPage && !error && hasMore)
fetchNextPage()
}, {
root: containerRef.current,
rootMargin: `${dynamicMargin}px`,
threshold: 0.1, // Trigger when 10% of the anchor element is visible
})
observer.observe(anchorRef.current)
}
return () => observer?.disconnect()
}, [isLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage, isCurrentWorkspaceDatasetOperator])
const handleCreatedByMeChange = useCallback((checked: boolean) => {
setIsCreatedByMe(checked)
}, [setIsCreatedByMe])
const categoryRef = useRef(category)
useEffect(() => {
categoryRef.current = category
}, [category])
const handleCategoryChange = useCallback((nextValue: string | null) => {
if (!nextValue || !isAppListCategory(nextValue) || nextValue === categoryRef.current)
return
categoryRef.current = nextValue
setCategory(nextValue)
}, [setCategory])
const pages = useMemo(() => emptyAppList ? [{ data: [], total: 0 }] : data?.pages ?? [], [data?.pages, emptyAppList])
const apps = useMemo(() => pages.flatMap(({ data: pageApps }) => pageApps), [pages])
const workflowOnlineUserAppIds = useMemo(() => {
const appIds = new Set<string>()
apps.forEach((app) => {
if (app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT)
appIds.add(app.id)
})
return Array.from(appIds)
}, [apps])
const {
onlineUsersMap: workflowOnlineUsersMap,
} = useWorkflowOnlineUsers({
appIds: workflowOnlineUserAppIds,
enabled: systemFeatures.enable_collaboration_mode,
})
const hasResolvedFirstPage = pages.length > 0
const hasAnyApp = (pages[0]?.total ?? 0) > 0
const hasActiveFilters = category !== 'all' || tagIDs.length > 0 || keywords.trim().length > 0 || debouncedKeywords.trim().length > 0 || isCreatedByMe
// Show skeleton during initial load or when refetching with no previous data
const showSkeleton = !emptyAppList && (isLoading || (isFetching && pages.length === 0))
const showFirstEmptyState = !showSkeleton && !hasAnyApp && isCurrentWorkspaceEditor && (emptyAppList || (hasResolvedFirstPage && !hasActiveFilters))
return (
<>
<div ref={containerRef} className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
{dragging && (
<div className="absolute inset-0 z-50 m-0.5 rounded-2xl border-2 border-dashed border-components-dropzone-border-accent bg-[rgba(21,90,239,0.14)] p-2">
</div>
)}
<div className="sticky top-0 z-10 flex flex-col bg-background-body px-6 pt-2 pb-2">
<div className="flex min-h-14 items-start pt-2">
<div className="flex flex-col gap-0.5">
<h1 className="text-xl/6 font-semibold text-dify-logo-black">{t('menus.apps', { ns: 'common' })}</h1>
<p className="system-sm-regular text-text-tertiary">{t('studioDescription', { ns: 'app' })}</p>
</div>
</div>
{!showFirstEmptyState && (
<AppListHeaderFilters
category={category}
tagIDs={tagIDs}
keywords={keywords}
isCreatedByMe={isCreatedByMe}
onCategoryChange={handleCategoryChange}
onTagIDsChange={setTagIDs}
onKeywordsChange={setKeywords}
onCreatedByMeChange={handleCreatedByMeChange}
onCreateBlank={() => setShowNewAppModal(true)}
onCreateTemplate={() => setShowNewAppTemplateDialog(true)}
onImportDSL={() => setShowCreateFromDSLModal(true)}
onOpenTagManagement={() => setShowTagManagementModal(true)}
showCreateButton={isCurrentWorkspaceEditor}
/>
)}
</div>
{showFirstEmptyState
? (
<FirstEmptyState
onCreateBlank={() => setShowNewAppModal(true)}
onCreateTemplate={() => setShowNewAppTemplateDialog(true)}
onImportDSL={() => setShowCreateFromDSLModal(true)}
/>
)
: (
<div className={cn(
'relative grid grow grid-cols-1 content-start gap-3 px-6 pt-2 2k:grid-cols-6 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5',
!hasAnyApp && 'overflow-hidden',
)}
>
{(isCurrentWorkspaceEditor || isLoadingCurrentWorkspace) && hasAnyApp && (
<NewAppCard
ref={newAppCardRef}
isLoading={isLoadingCurrentWorkspace}
onSuccess={refetch}
selectedAppType={category}
className={cn(!hasAnyApp && 'z-10')}
/>
)}
{showSkeleton
? <AppCardSkeleton count={6} />
: hasAnyApp
? apps.map(app => (
<AppCard
key={app.id}
app={app}
onlineUsers={workflowOnlineUsersMap[app.id] ?? []}
onRefresh={refetch}
onOpenTagManagement={() => setShowTagManagementModal(true)}
/>
))
: <Empty />}
{isFetchingNextPage && (
<AppCardSkeleton count={3} />
)}
</div>
)}
{isCurrentWorkspaceEditor && !showFirstEmptyState && (
<div
className={`flex items-center justify-center gap-2 py-4 ${dragging ? 'text-text-accent' : 'text-text-quaternary'}`}
role="region"
aria-label={t('newApp.dropDSLToCreateApp', { ns: 'app' })}
>
<span className="i-ri-drag-drop-line h-4 w-4" />
<span className="system-xs-regular">{t('newApp.dropDSLToCreateApp', { ns: 'app' })}</span>
</div>
)}
{!systemFeatures.branding.enabled && !showFirstEmptyState && (
<Footer />
)}
<CheckModal />
<div ref={anchorRef} className="h-0"> </div>
<TagManagementModal
type="app"
show={showTagManagementModal}
onClose={() => setShowTagManagementModal(false)}
onTagsChange={refetch}
/>
</div>
{showCreateFromDSLModal && (
<CreateFromDSLModal
show={showCreateFromDSLModal}
onClose={() => {
setShowCreateFromDSLModal(false)
setDroppedDSLFile(undefined)
}}
onSuccess={() => {
setShowCreateFromDSLModal(false)
setDroppedDSLFile(undefined)
onPlanInfoChanged()
refetch()
}}
droppedFile={droppedDSLFile}
/>
)}
{showNewAppModal && (
<CreateAppModal
show={showNewAppModal}
onClose={() => setShowNewAppModal(false)}
onSuccess={() => {
onPlanInfoChanged()
refetch()
}}
onCreateFromTemplate={() => {
setShowNewAppTemplateDialog(true)
setShowNewAppModal(false)
}}
defaultAppMode={category !== 'all' ? category : undefined}
/>
)}
{showNewAppTemplateDialog && (
<CreateAppTemplateDialog
show={showNewAppTemplateDialog}
onClose={() => setShowNewAppTemplateDialog(false)}
onSuccess={() => {
onPlanInfoChanged()
refetch()
}}
onCreateFromBlank={() => {
setShowNewAppModal(true)
setShowNewAppTemplateDialog(false)
}}
/>
)}
</>
)
}
export default List