'use client' import type { FC } from 'react' import type { StudioPageType } from '.' import type { WorkflowOnlineUser } from '@/models/app' import { cn } from '@langgenius/dify-ui/cn' import { useSuspenseQuery } from '@tanstack/react-query' import { useDebounceFn } from 'ahooks' import { useQueryState } from 'nuqs' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' import TagFilter from '@/app/components/base/tag-management/filter' import { useStore as useTagStore } from '@/app/components/base/tag-management/store' import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { useAppContext } from '@/context/app-context' import { CheckModal } from '@/hooks/use-pay' import { useSnippetAndEvaluationPlanAccess } from '@/hooks/use-snippet-and-evaluation-plan-access' import dynamic from '@/next/dynamic' import { fetchWorkflowOnlineUsers } from '@/service/apps' import { systemFeaturesQueryOptions } from '@/service/system-features' import { useInfiniteAppList } from '@/service/use-apps' import { useInfiniteSnippetList } from '@/service/use-snippets' import { AppModeEnum } from '@/types/app' import SnippetCard from '../snippets/components/snippet-card' import SnippetCreateCard from '../snippets/components/snippet-create-card' import AppCard from './app-card' import { AppCardSkeleton } from './app-card-skeleton' import AppTypeFilter from './app-type-filter' import { parseAsAppListCategory } from './app-type-filter-shared' import CreatorsFilter from './creators-filter' import Empty from './empty' import Footer from './footer' import useAppsQueryState from './hooks/use-apps-query-state' import { useDSLDragDrop } from './hooks/use-dsl-drag-drop' import NewAppCard from './new-app-card' import StudioRouteSwitch from './studio-route-switch' const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), { ssr: false, }) const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-from-dsl-modal'), { ssr: false, }) type Props = { controlRefreshList?: number pageType?: StudioPageType } const List: FC = ({ controlRefreshList = 0, pageType = 'apps', }) => { const { t } = useTranslation() const isAppsPage = pageType === 'apps' const { canAccess: canAccessSnippetsAndEvaluation } = useSnippetAndEvaluationPlanAccess() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext() const showTagManagementModal = useTagStore(s => s.showTagManagementModal) const [activeTab, setActiveTab] = useQueryState( 'category', parseAsAppListCategory, ) const { query: { tagIDs = [], creatorIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState() const [tagFilterValue, setTagFilterValue] = useState(tagIDs) const [appKeywords, setAppKeywords] = useState(keywords) const [snippetKeywordsInput, setSnippetKeywordsInput] = useState('') const [snippetKeywords, setSnippetKeywords] = useState('') const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false) const [droppedDSLFile, setDroppedDSLFile] = useState() const containerRef = useRef(null) const anchorRef = useRef(null) const newAppCardRef = useRef(null) const [workflowOnlineUsersMap, setWorkflowOnlineUsersMap] = useState>({}) const setKeywords = useCallback((keywords: string) => { setQuery(prev => ({ ...prev, keywords })) }, [setQuery]) const setTagIDs = useCallback((nextTagIDs: string[]) => { setQuery(prev => ({ ...prev, tagIDs: nextTagIDs })) }, [setQuery]) const setCreatorIDs = useCallback((nextCreatorIDs: string[]) => { setQuery(prev => ({ ...prev, creatorIDs: nextCreatorIDs })) }, [setQuery]) const handleDSLFileDropped = useCallback((file: File) => { setDroppedDSLFile(file) setShowCreateFromDSLModal(true) }, []) const { dragging } = useDSLDragDrop({ onDSLFileDropped: handleDSLFileDropped, containerRef, enabled: isAppsPage && isCurrentWorkspaceEditor, }) const appListQueryParams = { page: 1, limit: 30, name: appKeywords, tag_ids: tagIDs, is_created_by_me: queryIsCreatedByMe, ...(creatorIDs.length > 0 ? { creator_id: creatorIDs.join(',') } : {}), ...(activeTab !== 'all' ? { mode: activeTab } : {}), } const { data, isLoading, isFetching, isFetchingNextPage, fetchNextPage, hasNextPage, error, refetch, } = useInfiniteAppList(appListQueryParams, { enabled: isAppsPage && !isCurrentWorkspaceDatasetOperator, }) const { data: snippetData, isLoading: isSnippetListLoading, isFetching: isSnippetListFetching, isFetchingNextPage: isSnippetListFetchingNextPage, fetchNextPage: fetchSnippetNextPage, hasNextPage: hasSnippetNextPage, error: snippetError, } = useInfiniteSnippetList({ page: 1, limit: 30, keyword: snippetKeywords || undefined, creator_id: creatorIDs.length > 0 ? creatorIDs.join(',') : undefined, }, { enabled: !isAppsPage, }) useEffect(() => { if (isAppsPage && controlRefreshList > 0) refetch() }, [controlRefreshList, isAppsPage, refetch]) useEffect(() => { if (!isAppsPage) return if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') { localStorage.removeItem(NEED_REFRESH_APP_LIST_KEY) refetch() } }, [isAppsPage, refetch]) useEffect(() => { if (isCurrentWorkspaceDatasetOperator) return const hasMore = isAppsPage ? (hasNextPage ?? true) : (hasSnippetNextPage ?? true) const isPageLoading = isAppsPage ? isLoading : isSnippetListLoading const isNextPageFetching = isAppsPage ? isFetchingNextPage : isSnippetListFetchingNextPage const currentError = isAppsPage ? error : snippetError let observer: IntersectionObserver | undefined if (currentError) { observer?.disconnect() return } if (anchorRef.current && containerRef.current) { const containerHeight = containerRef.current.clientHeight const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200)) observer = new IntersectionObserver((entries) => { if (entries[0]!.isIntersecting && !isPageLoading && !isNextPageFetching && !currentError && hasMore) { if (isAppsPage) fetchNextPage() else fetchSnippetNextPage() } }, { root: containerRef.current, rootMargin: `${dynamicMargin}px`, threshold: 0.1, }) observer.observe(anchorRef.current) } return () => observer?.disconnect() }, [error, fetchNextPage, fetchSnippetNextPage, hasNextPage, hasSnippetNextPage, isAppsPage, isCurrentWorkspaceDatasetOperator, isFetchingNextPage, isLoading, isSnippetListFetchingNextPage, isSnippetListLoading, snippetError]) const { run: handleAppSearch } = useDebounceFn((value: string) => { setAppKeywords(value) }, { wait: 500 }) const { run: handleSnippetSearch } = useDebounceFn((value: string) => { setSnippetKeywords(value) }, { wait: 500 }) const handleKeywordsChange = useCallback((value: string) => { if (isAppsPage) { setKeywords(value) handleAppSearch(value) return } setSnippetKeywordsInput(value) handleSnippetSearch(value) }, [handleAppSearch, handleSnippetSearch, isAppsPage, setKeywords]) const { run: handleTagsUpdate } = useDebounceFn((value: string[]) => { setTagIDs(value) }, { wait: 500 }) const handleTagsChange = useCallback((value: string[]) => { setTagFilterValue(value) handleTagsUpdate(value) }, [handleTagsUpdate]) const snippetItems = useMemo(() => { return (snippetData?.pages ?? []).flatMap(({ data }) => data) }, [snippetData?.pages]) const showSkeleton = isAppsPage ? (isLoading || (isFetching && data?.pages?.length === 0)) : (isSnippetListLoading || (isSnippetListFetching && snippetItems.length === 0)) const hasAnyApp = (data?.pages?.[0]?.total ?? 0) > 0 const hasAnySnippet = snippetItems.length > 0 const currentKeywords = isAppsPage ? keywords : snippetKeywordsInput const showEmptyState = !showSkeleton && (isAppsPage ? !hasAnyApp : !hasAnySnippet) const emptyStateMessage = isAppsPage ? t('newApp.noAppsFound', { ns: 'app' }) : t('tabs.noSnippetsFound', { ns: 'workflow' }) const pages = useMemo(() => data?.pages ?? [], [data?.pages]) const workflowOnlineUserAppIds = useMemo(() => { const appIds = new Set() pages.forEach(({ data: apps }) => { apps.forEach((app) => { if (app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT) appIds.add(app.id) }) }) return Array.from(appIds) }, [pages]) const refreshWorkflowOnlineUsers = useCallback(async () => { if (!systemFeatures.enable_collaboration_mode) { setWorkflowOnlineUsersMap({}) return } if (!workflowOnlineUserAppIds.length) { setWorkflowOnlineUsersMap({}) return } try { const onlineUsersMap = await fetchWorkflowOnlineUsers({ appIds: workflowOnlineUserAppIds }) setWorkflowOnlineUsersMap(onlineUsersMap) } catch { setWorkflowOnlineUsersMap({}) } }, [systemFeatures.enable_collaboration_mode, workflowOnlineUserAppIds]) useEffect(() => { void refreshWorkflowOnlineUsers() }, [refreshWorkflowOnlineUsers]) useEffect(() => { if (!systemFeatures.enable_collaboration_mode) return const timer = window.setInterval(() => { void refetch() void refreshWorkflowOnlineUsers() }, 10000) return () => window.clearInterval(timer) }, [refetch, refreshWorkflowOnlineUsers, systemFeatures.enable_collaboration_mode]) return ( <>
{dragging && (
)}
{isAppsPage && ( { void setActiveTab(value) }} /> )} {isAppsPage && ( )}
handleKeywordsChange(e.target.value)} onClear={() => handleKeywordsChange('')} />
{(isCurrentWorkspaceEditor || isLoadingCurrentWorkspace) && ( isAppsPage ? ( ) : canAccessSnippetsAndEvaluation && )} {showSkeleton && } {!showSkeleton && isAppsPage && hasAnyApp && pages.flatMap(({ data: apps }) => apps).map(app => ( ))} {!showSkeleton && !isAppsPage && hasAnySnippet && snippetItems.map(snippet => ( ))} {showEmptyState && } {isAppsPage && isFetchingNextPage && ( )} {!isAppsPage && isSnippetListFetchingNextPage && ( )}
{isAppsPage && isCurrentWorkspaceEditor && (
{t('newApp.dropDSLToCreateApp', { ns: 'app' })}
)} {!systemFeatures.branding.enabled && (
)}
{isAppsPage && showTagManagementModal && ( )}
{isAppsPage && showCreateFromDSLModal && ( { setShowCreateFromDSLModal(false) setDroppedDSLFile(undefined) }} onSuccess={() => { setShowCreateFromDSLModal(false) setDroppedDSLFile(undefined) refetch() }} droppedFile={droppedDSLFile} /> )} ) } export default List