mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 11:04:27 +08:00
496 lines
18 KiB
TypeScript
496 lines
18 KiB
TypeScript
'use client'
|
|
|
|
import type { RecommendedAppResponse } from '@dify/contracts/api/console/explore/types.gen'
|
|
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
|
|
import type { StepByStepTourTaskId } from '@/app/components/step-by-step-tour/types'
|
|
import type { TrackCreateAppParams } from '@/utils/create-app-tracking'
|
|
import { cn } from '@langgenius/dify-ui/cn'
|
|
import { useQueryClient, useSuspenseQueries, useSuspenseQuery } from '@tanstack/react-query'
|
|
import { useDebouncedValue } from 'foxact/use-debounced-value'
|
|
import { useAtomValue, useSetAtom } from 'jotai'
|
|
import { useQueryState } from 'nuqs'
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { MAIN_NAV_APP_CARD_GRID_CLASS_NAME } from '@/app/components/main-nav/app-card-grid'
|
|
import {
|
|
getStepByStepTourPermissionVariant,
|
|
trackStepByStepTourEvent,
|
|
} from '@/app/components/step-by-step-tour/analytics'
|
|
import {
|
|
activeStepByStepTourGuideIndexAtom,
|
|
activeStepByStepTourTaskIdAtom,
|
|
advanceStepByStepTourGuideAtom,
|
|
completedStepByStepTourTaskIdsAtom,
|
|
completeStepByStepTourTaskAtom,
|
|
resetStepByStepTourSessionAtom,
|
|
} from '@/app/components/step-by-step-tour/state'
|
|
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
|
|
import { STEP_BY_STEP_TOUR_TASKS } from '@/app/components/step-by-step-tour/tasks'
|
|
import { useLocale } from '@/context/i18n'
|
|
import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
|
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
|
import { useImportDSL } from '@/hooks/use-import-dsl'
|
|
import { DSLImportMode } from '@/models/app'
|
|
import dynamic from '@/next/dynamic'
|
|
import { consoleQuery } from '@/service/console'
|
|
import { trackCreateApp } from '@/utils/create-app-tracking'
|
|
import { hasPermission } from '@/utils/permission'
|
|
import { HomeBanner } from '../banner/home-banner'
|
|
import { HomeIntro } from '../home-intro'
|
|
import { HomeShell } from '../home-shell'
|
|
import { TemplateCard } from '../template-card'
|
|
import { HomeRecommendations } from './recommendations'
|
|
import { HomeTemplatesHeader } from './templates-header'
|
|
|
|
const TryApp = dynamic(() => import('@/app/components/explore/try-app'), { ssr: false })
|
|
const CreateAppModal = dynamic(() => import('@/app/components/explore/create-app-modal'), {
|
|
ssr: false,
|
|
})
|
|
const DSLConfirmModal = dynamic(
|
|
() => import('@/app/components/app/create-from-dsl-modal/dsl-confirm-modal'),
|
|
{ ssr: false },
|
|
)
|
|
|
|
const HOME_STEP_BY_STEP_TOUR_TASK_ID = 'home' satisfies StepByStepTourTaskId
|
|
|
|
export function HomeContent() {
|
|
const { t } = useTranslation()
|
|
const locale = useLocale()
|
|
const queryClient = useQueryClient()
|
|
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
|
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
|
const [templatesQuery, recentAppsQuery] = useSuspenseQueries({
|
|
queries: [
|
|
consoleQuery.explore.apps.get.queryOptions({
|
|
input: { query: { language: locale } },
|
|
}),
|
|
consoleQuery.apps.recent.get.queryOptions({
|
|
input: { query: { limit: 8 } },
|
|
}),
|
|
],
|
|
})
|
|
const templatesData = templatesQuery.data
|
|
const continueWorkApps = recentAppsQuery.data.data
|
|
const allCategoriesEn = t(($) => $['apps.allCategories'], { ns: 'explore', lng: 'en' })
|
|
const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management')
|
|
const activeStepByStepTourTaskId = useAtomValue(activeStepByStepTourTaskIdAtom)
|
|
const activeStepByStepTourGuideIndex = useAtomValue(activeStepByStepTourGuideIndexAtom)
|
|
const completedStepByStepTourTaskIds = useAtomValue(completedStepByStepTourTaskIdsAtom)
|
|
const advanceStepByStepTourGuide = useSetAtom(advanceStepByStepTourGuideAtom)
|
|
const completeStepByStepTourTask = useSetAtom(completeStepByStepTourTaskAtom)
|
|
const resetStepByStepTourSession = useSetAtom(resetStepByStepTourSessionAtom)
|
|
const trackHomeTourCompleted = useCallback(
|
|
(
|
|
completedTaskIds: StepByStepTourTaskId[],
|
|
homeOutcome: 'lesson_app_created' | 'lesson_opened',
|
|
) => {
|
|
trackStepByStepTourEvent({
|
|
action: 'task_completed',
|
|
task_id: HOME_STEP_BY_STEP_TOUR_TASK_ID,
|
|
completed_task_count: completedTaskIds.length,
|
|
home_outcome: homeOutcome,
|
|
permission_variant: getStepByStepTourPermissionVariant({
|
|
canCreateApp,
|
|
hasIntegrationWalkthroughPermissions: true,
|
|
hasKnowledgeWalkthroughPermissions: true,
|
|
taskId: HOME_STEP_BY_STEP_TOUR_TASK_ID,
|
|
}),
|
|
task_total: STEP_BY_STEP_TOUR_TASKS.length,
|
|
})
|
|
|
|
if (STEP_BY_STEP_TOUR_TASKS.every((task) => completedTaskIds.includes(task.id))) {
|
|
trackStepByStepTourEvent({
|
|
action: 'tour_completed',
|
|
completed_task_count: completedTaskIds.length,
|
|
task_total: STEP_BY_STEP_TOUR_TASKS.length,
|
|
})
|
|
}
|
|
},
|
|
[canCreateApp],
|
|
)
|
|
|
|
const [keywords, setKeywords] = useState('')
|
|
const debouncedKeywords = useDebouncedValue(keywords, 500)
|
|
const searchKeywords = keywords ? debouncedKeywords : ''
|
|
|
|
const [currCategory, setCurrCategory] = useQueryState('category', {
|
|
defaultValue: allCategoriesEn,
|
|
})
|
|
|
|
const visibleCategories = useMemo(() => {
|
|
const categoriesWithApps = new Set<string>()
|
|
templatesData.recommended_apps.forEach((app) => {
|
|
app.categories?.forEach((category) => categoriesWithApps.add(category))
|
|
})
|
|
|
|
return templatesData.categories.filter((category) => categoriesWithApps.has(category))
|
|
}, [templatesData])
|
|
|
|
const activeCategory = visibleCategories.includes(currCategory) ? currCategory : allCategoriesEn
|
|
|
|
const filteredList = useMemo(() => {
|
|
return [...templatesData.recommended_apps]
|
|
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
|
|
.filter(
|
|
(item) => activeCategory === allCategoriesEn || item.categories?.includes(activeCategory),
|
|
)
|
|
}, [templatesData, activeCategory, allCategoriesEn])
|
|
|
|
const searchFilteredList = useMemo(() => {
|
|
if (!searchKeywords || !filteredList || filteredList.length === 0) return filteredList
|
|
|
|
const lowerCaseSearchKeywords = searchKeywords.toLowerCase()
|
|
|
|
return filteredList.filter(
|
|
(item) =>
|
|
item.app && item.app.name && item.app.name.toLowerCase().includes(lowerCaseSearchKeywords),
|
|
)
|
|
}, [searchKeywords, filteredList])
|
|
|
|
const [currApp, setCurrApp] = useState<RecommendedAppResponse | null>(null)
|
|
const [isShowCreateModal, setIsShowCreateModal] = useState(false)
|
|
|
|
const { handleImportDSL, handleImportDSLConfirm, versions, isFetching } = useImportDSL()
|
|
const [showDSLConfirmModal, setShowDSLConfirmModal] = useState(false)
|
|
|
|
const [currentTryApp, setCurrentTryApp] = useState<RecommendedAppResponse | undefined>(undefined)
|
|
const currentCreateAppModeRef = useRef<string | null>(null)
|
|
const currentCreateAppTrackingRef = useRef<Pick<
|
|
TrackCreateAppParams,
|
|
'source' | 'templateId'
|
|
> | null>(null)
|
|
const isCurrentTryAppFromLearnDifyRef = useRef(false)
|
|
const shouldCompleteHomeTourOnCreateRef = useRef(false)
|
|
const isSubmittingHomeTourCreateRef = useRef(false)
|
|
const wasHomeTryAppCreateGuideActiveRef = useRef(false)
|
|
const shouldForceShowLearnDifyForTour =
|
|
activeStepByStepTourTaskId === HOME_STEP_BY_STEP_TOUR_TASK_ID &&
|
|
!completedStepByStepTourTaskIds.includes(HOME_STEP_BY_STEP_TOUR_TASK_ID) &&
|
|
(activeStepByStepTourGuideIndex ?? 0) === 0
|
|
const abandonHomeTour = useCallback(() => {
|
|
if (
|
|
activeStepByStepTourTaskId !== HOME_STEP_BY_STEP_TOUR_TASK_ID ||
|
|
completedStepByStepTourTaskIds.includes(HOME_STEP_BY_STEP_TOUR_TASK_ID)
|
|
) {
|
|
return
|
|
}
|
|
|
|
resetStepByStepTourSession()
|
|
}, [activeStepByStepTourTaskId, completedStepByStepTourTaskIds, resetStepByStepTourSession])
|
|
|
|
const completeHomeTourAfterCreate = useCallback(() => {
|
|
if (!shouldCompleteHomeTourOnCreateRef.current) return
|
|
|
|
resetStepByStepTourSession()
|
|
isCurrentTryAppFromLearnDifyRef.current = false
|
|
shouldCompleteHomeTourOnCreateRef.current = false
|
|
isSubmittingHomeTourCreateRef.current = false
|
|
completeStepByStepTourTask({
|
|
taskId: HOME_STEP_BY_STEP_TOUR_TASK_ID,
|
|
onSuccess: (completedTaskIds) => {
|
|
trackHomeTourCompleted(completedTaskIds, 'lesson_app_created')
|
|
},
|
|
})
|
|
}, [completeStepByStepTourTask, resetStepByStepTourSession, trackHomeTourCompleted])
|
|
|
|
const completeHomeTourAfterOpenDetails = useCallback(() => {
|
|
if (
|
|
activeStepByStepTourTaskId !== HOME_STEP_BY_STEP_TOUR_TASK_ID ||
|
|
completedStepByStepTourTaskIds.includes(HOME_STEP_BY_STEP_TOUR_TASK_ID) ||
|
|
(activeStepByStepTourGuideIndex ?? 0) !== 0
|
|
) {
|
|
return
|
|
}
|
|
|
|
resetStepByStepTourSession()
|
|
completeStepByStepTourTask({
|
|
taskId: HOME_STEP_BY_STEP_TOUR_TASK_ID,
|
|
onSuccess: (completedTaskIds) => trackHomeTourCompleted(completedTaskIds, 'lesson_opened'),
|
|
})
|
|
}, [
|
|
activeStepByStepTourGuideIndex,
|
|
activeStepByStepTourTaskId,
|
|
completedStepByStepTourTaskIds,
|
|
completeStepByStepTourTask,
|
|
resetStepByStepTourSession,
|
|
trackHomeTourCompleted,
|
|
])
|
|
|
|
const abandonHomeTourCreate = useCallback(() => {
|
|
if (!isCurrentTryAppFromLearnDifyRef.current || isSubmittingHomeTourCreateRef.current) return
|
|
|
|
abandonHomeTour()
|
|
setCurrentTryApp(undefined)
|
|
setCurrApp(null)
|
|
currentCreateAppTrackingRef.current = null
|
|
currentCreateAppModeRef.current = null
|
|
isCurrentTryAppFromLearnDifyRef.current = false
|
|
shouldCompleteHomeTourOnCreateRef.current = false
|
|
}, [abandonHomeTour])
|
|
|
|
const hideTryAppPanel = useCallback(() => {
|
|
abandonHomeTourCreate()
|
|
// oxlint-disable-next-line eslint-react/set-state-in-effect -- Also called from the tour-state sync effect when the Learn Dify action guide is skipped.
|
|
setCurrentTryApp(undefined)
|
|
}, [abandonHomeTourCreate])
|
|
const homeTryAppCreateGuideActive =
|
|
activeStepByStepTourTaskId === HOME_STEP_BY_STEP_TOUR_TASK_ID &&
|
|
activeStepByStepTourGuideIndex === 1 &&
|
|
!completedStepByStepTourTaskIds.includes(HOME_STEP_BY_STEP_TOUR_TASK_ID)
|
|
useEffect(() => {
|
|
if (!isCurrentTryAppFromLearnDifyRef.current || !currentTryApp || isShowCreateModal) {
|
|
wasHomeTryAppCreateGuideActiveRef.current = false
|
|
return
|
|
}
|
|
|
|
if (homeTryAppCreateGuideActive) {
|
|
wasHomeTryAppCreateGuideActiveRef.current = true
|
|
return
|
|
}
|
|
|
|
if (wasHomeTryAppCreateGuideActiveRef.current) {
|
|
wasHomeTryAppCreateGuideActiveRef.current = false
|
|
hideTryAppPanel()
|
|
}
|
|
}, [currentTryApp, hideTryAppPanel, homeTryAppCreateGuideActive, isShowCreateModal])
|
|
const handleTryApp = useCallback((app: RecommendedAppResponse) => {
|
|
isCurrentTryAppFromLearnDifyRef.current = false
|
|
setCurrentTryApp(app)
|
|
}, [])
|
|
const handleTryAppFromLearnDify = useCallback(
|
|
(app: RecommendedAppResponse) => {
|
|
isCurrentTryAppFromLearnDifyRef.current = true
|
|
setCurrentTryApp(app)
|
|
|
|
if (
|
|
activeStepByStepTourTaskId === HOME_STEP_BY_STEP_TOUR_TASK_ID &&
|
|
!completedStepByStepTourTaskIds.includes(HOME_STEP_BY_STEP_TOUR_TASK_ID) &&
|
|
(activeStepByStepTourGuideIndex ?? 0) === 0
|
|
) {
|
|
if (!canCreateApp) {
|
|
completeHomeTourAfterOpenDetails()
|
|
isCurrentTryAppFromLearnDifyRef.current = false
|
|
return
|
|
}
|
|
|
|
advanceStepByStepTourGuide({
|
|
guideIndex: 1,
|
|
})
|
|
}
|
|
},
|
|
[
|
|
activeStepByStepTourGuideIndex,
|
|
activeStepByStepTourTaskId,
|
|
advanceStepByStepTourGuide,
|
|
canCreateApp,
|
|
completedStepByStepTourTaskIds,
|
|
completeHomeTourAfterOpenDetails,
|
|
],
|
|
)
|
|
const handleShowFromTryApp = useCallback(() => {
|
|
setCurrApp(currentTryApp || null)
|
|
currentCreateAppTrackingRef.current = {
|
|
source: 'explore_template_preview',
|
|
templateId: currentTryApp?.app_id,
|
|
}
|
|
shouldCompleteHomeTourOnCreateRef.current =
|
|
isCurrentTryAppFromLearnDifyRef.current &&
|
|
activeStepByStepTourTaskId === HOME_STEP_BY_STEP_TOUR_TASK_ID &&
|
|
!completedStepByStepTourTaskIds.includes(HOME_STEP_BY_STEP_TOUR_TASK_ID) &&
|
|
activeStepByStepTourGuideIndex === 1
|
|
setIsShowCreateModal(true)
|
|
}, [
|
|
activeStepByStepTourGuideIndex,
|
|
activeStepByStepTourTaskId,
|
|
completedStepByStepTourTaskIds,
|
|
currentTryApp,
|
|
])
|
|
const handleCreateFromLearnDify = useCallback((app: RecommendedAppResponse) => {
|
|
setCurrApp(app)
|
|
setIsShowCreateModal(true)
|
|
}, [])
|
|
const handleCreateFromTemplate = useCallback((app: RecommendedAppResponse) => {
|
|
currentCreateAppTrackingRef.current = {
|
|
source: 'explore_template_list',
|
|
templateId: app.app_id,
|
|
}
|
|
setCurrApp(app)
|
|
setIsShowCreateModal(true)
|
|
}, [])
|
|
const trackCurrentCreateApp = useCallback((appMode?: string | null) => {
|
|
const currentCreateAppTracking = currentCreateAppTrackingRef.current
|
|
const resolvedAppMode = appMode ?? currentCreateAppModeRef.current
|
|
if (!resolvedAppMode || !currentCreateAppTracking) return
|
|
|
|
trackCreateApp({
|
|
...currentCreateAppTracking,
|
|
appMode: resolvedAppMode,
|
|
})
|
|
currentCreateAppTrackingRef.current = null
|
|
currentCreateAppModeRef.current = null
|
|
}, [])
|
|
const handleCreateModalHide = useCallback(() => {
|
|
if (!isSubmittingHomeTourCreateRef.current) abandonHomeTourCreate()
|
|
|
|
setIsShowCreateModal(false)
|
|
}, [abandonHomeTourCreate])
|
|
|
|
const onCreate: CreateAppModalProps['onConfirm'] = useCallback(
|
|
async ({ name, icon_type, icon, icon_background, description }) => {
|
|
isSubmittingHomeTourCreateRef.current = shouldCompleteHomeTourOnCreateRef.current
|
|
hideTryAppPanel()
|
|
|
|
const appId = currApp?.app_id
|
|
if (!appId) return
|
|
|
|
const appDetail = await queryClient.query({
|
|
...consoleQuery.explore.apps.byAppId.get.queryOptions({
|
|
input: { params: { app_id: appId } },
|
|
}),
|
|
staleTime: 'static',
|
|
})
|
|
|
|
const { export_data, mode } = appDetail
|
|
currentCreateAppModeRef.current = mode
|
|
const payload = {
|
|
mode: DSLImportMode.YAML_CONTENT,
|
|
yaml_content: export_data,
|
|
name,
|
|
icon_type,
|
|
icon,
|
|
icon_background,
|
|
description,
|
|
}
|
|
let didTransitionCreateFlow = false
|
|
await handleImportDSL(payload, {
|
|
onSuccess: (response) => {
|
|
didTransitionCreateFlow = true
|
|
trackCurrentCreateApp(response.app_mode)
|
|
completeHomeTourAfterCreate()
|
|
setIsShowCreateModal(false)
|
|
},
|
|
onPending: () => {
|
|
didTransitionCreateFlow = true
|
|
setShowDSLConfirmModal(true)
|
|
},
|
|
skipRedirectOnSuccess: shouldCompleteHomeTourOnCreateRef.current,
|
|
})
|
|
if (!didTransitionCreateFlow && shouldCompleteHomeTourOnCreateRef.current) {
|
|
isSubmittingHomeTourCreateRef.current = false
|
|
abandonHomeTourCreate()
|
|
}
|
|
},
|
|
[
|
|
abandonHomeTourCreate,
|
|
completeHomeTourAfterCreate,
|
|
currApp?.app_id,
|
|
handleImportDSL,
|
|
hideTryAppPanel,
|
|
queryClient,
|
|
trackCurrentCreateApp,
|
|
],
|
|
)
|
|
|
|
const onConfirmDSL = useCallback(async () => {
|
|
await handleImportDSLConfirm({
|
|
onSuccess: (response) => {
|
|
trackCurrentCreateApp(response.app_mode)
|
|
completeHomeTourAfterCreate()
|
|
},
|
|
skipRedirectOnSuccess: shouldCompleteHomeTourOnCreateRef.current,
|
|
})
|
|
}, [completeHomeTourAfterCreate, handleImportDSLConfirm, trackCurrentCreateApp])
|
|
|
|
const handleCancelDSLConfirm = useCallback(() => {
|
|
setShowDSLConfirmModal(false)
|
|
isSubmittingHomeTourCreateRef.current = false
|
|
abandonHomeTourCreate()
|
|
}, [abandonHomeTourCreate])
|
|
|
|
return (
|
|
<HomeShell>
|
|
<div className="flex flex-1 flex-col overflow-y-auto">
|
|
<HomeIntro />
|
|
{systemFeatures.enable_explore_banner && <HomeBanner />}
|
|
<HomeRecommendations
|
|
canCreate={canCreateApp}
|
|
continueWorkApps={continueWorkApps}
|
|
forceShowLearnDify={shouldForceShowLearnDifyForTour}
|
|
onCreate={handleCreateFromLearnDify}
|
|
onTry={handleTryAppFromLearnDify}
|
|
/>
|
|
|
|
<HomeTemplatesHeader
|
|
allCategoriesEn={allCategoriesEn}
|
|
categories={visibleCategories}
|
|
currCategory={activeCategory}
|
|
keywords={keywords}
|
|
onCategoryChange={setCurrCategory}
|
|
onKeywordsChange={setKeywords}
|
|
/>
|
|
|
|
<div className={cn('relative flex flex-1 shrink-0 grow flex-col pb-6')}>
|
|
<section
|
|
aria-labelledby="home-templates-title"
|
|
className={cn('shrink-0 content-start gap-2.5 px-8', MAIN_NAV_APP_CARD_GRID_CLASS_NAME)}
|
|
>
|
|
{searchFilteredList.map((app) => (
|
|
<TemplateCard
|
|
key={app.app_id}
|
|
app={app}
|
|
canCreate={canCreateApp}
|
|
onCreate={() => handleCreateFromTemplate(app)}
|
|
onTry={handleTryApp}
|
|
/>
|
|
))}
|
|
</section>
|
|
</div>
|
|
</div>
|
|
{isShowCreateModal && (
|
|
<CreateAppModal
|
|
appIconType={
|
|
currApp?.app?.icon_type === 'image' ||
|
|
currApp?.app?.icon_type === 'emoji' ||
|
|
currApp?.app?.icon_type === 'link'
|
|
? currApp.app.icon_type
|
|
: 'emoji'
|
|
}
|
|
appIcon={currApp?.app?.icon || ''}
|
|
appIconBackground={currApp?.app?.icon_background || ''}
|
|
appIconUrl={currApp?.app?.icon_url}
|
|
appName={currApp?.app?.name || ''}
|
|
appDescription={currApp?.description || ''}
|
|
show={isShowCreateModal}
|
|
onConfirm={onCreate}
|
|
confirmDisabled={isFetching}
|
|
onHide={handleCreateModalHide}
|
|
/>
|
|
)}
|
|
{showDSLConfirmModal && (
|
|
<DSLConfirmModal
|
|
versions={versions}
|
|
onCancel={handleCancelDSLConfirm}
|
|
onConfirm={onConfirmDSL}
|
|
confirmDisabled={isFetching}
|
|
/>
|
|
)}
|
|
|
|
{currentTryApp && (
|
|
<TryApp
|
|
appId={currentTryApp.app_id}
|
|
app={currentTryApp}
|
|
canCreate={canCreateApp}
|
|
categories={currentTryApp.categories ?? []}
|
|
createButtonStepByStepTourTarget={
|
|
canCreateApp && isCurrentTryAppFromLearnDifyRef.current && !isShowCreateModal
|
|
? STEP_BY_STEP_TOUR_TARGETS.homeTryAppCreate
|
|
: undefined
|
|
}
|
|
onClose={hideTryAppPanel}
|
|
onCreate={handleShowFromTryApp}
|
|
/>
|
|
)}
|
|
</HomeShell>
|
|
)
|
|
}
|