'use client' import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal' import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal' import type { EnvironmentVariable } from '@/app/components/workflow/types' import type { WorkflowOnlineUser } from '@/models/app' import type { App } from '@/types/app' import { AlertDialog, AlertDialogActions, AlertDialogCancelButton, AlertDialogConfirmButton, AlertDialogContent, AlertDialogDescription, AlertDialogTitle, } from '@langgenius/dify-ui/alert-dialog' import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { toast } from '@langgenius/dify-ui/toast' import { Tooltip, TooltipContent, TooltipTrigger, } from '@langgenius/dify-ui/tooltip' import { useSuspenseQuery } from '@tanstack/react-query' import * as React from 'react' import { useCallback, useId, useMemo, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import { AppTypeIcon } from '@/app/components/app/type-selector' import AppIcon from '@/app/components/base/app-icon' import Input from '@/app/components/base/input' import { UserAvatarList } from '@/app/components/base/user-avatar-list' import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { useAppContext } from '@/context/app-context' import { useProviderContext } from '@/context/provider-context' import { AppCardTags } from '@/features/tag-management/components/app-card-tags' import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' import { AccessMode } from '@/models/access-control' import dynamic from '@/next/dynamic' import { useRouter } from '@/next/navigation' import { useGetUserCanAccessApp } from '@/service/access-control' import { copyApp, exportAppConfig, updateAppInfo } from '@/service/apps' import { fetchInstalledAppList } from '@/service/explore' import { systemFeaturesQueryOptions } from '@/service/system-features' import { useDeleteAppMutation } from '@/service/use-apps' import { fetchWorkflowDraft } from '@/service/workflow' import { AppModeEnum } from '@/types/app' import { getRedirection } from '@/utils/app-redirection' import { downloadBlob } from '@/utils/download' import { formatTime } from '@/utils/time' import { basePath } from '@/utils/var' const EditAppModal = dynamic(() => import('@/app/components/explore/create-app-modal'), { ssr: false, }) const DuplicateAppModal = dynamic(() => import('@/app/components/app/duplicate-modal'), { ssr: false, }) const SwitchAppModal = dynamic(() => import('@/app/components/app/switch-app-modal'), { ssr: false, }) const DSLExportConfirmModal = dynamic(() => import('@/app/components/workflow/dsl-export-confirm-modal'), { ssr: false, }) const AccessControl = dynamic(() => import('@/app/components/app/app-access-control'), { ssr: false, }) type AppCardProps = { app: App onlineUsers?: WorkflowOnlineUser[] onRefresh?: () => void onOpenTagManagement?: () => void } type AppCardOperationsMenuProps = { app: App shouldShowSwitchOption: boolean shouldShowOpenInExploreOption: boolean shouldShowAccessControlOption: boolean onEdit: () => void onDuplicate: () => void onExport: () => void onSwitch: () => void onDelete: () => void onAccessControl: () => void } const AppCardOperationsMenu: React.FC = ({ app, shouldShowSwitchOption, shouldShowOpenInExploreOption, shouldShowAccessControlOption, onEdit, onDuplicate, onExport, onSwitch, onDelete, onAccessControl, }) => { const { t } = useTranslation() const openAsyncWindow = useAsyncWindowOpen() const handleMenuAction = useCallback((e: React.MouseEvent, action: () => void) => { e.stopPropagation() e.preventDefault() action() }, []) const handleOpenInstalledApp = useCallback(async (e: React.MouseEvent) => { e.stopPropagation() e.preventDefault() try { await openAsyncWindow(async () => { const { installed_apps } = await fetchInstalledAppList(app.id) if (installed_apps?.length > 0) return `${basePath}/explore/installed/${installed_apps[0]!.id}` throw new Error('No app found in Explore') }, { onError: (err) => { toast.error(`${err.message || err}`) }, }) } catch (e: unknown) { const message = e instanceof Error ? e.message : `${e}` toast.error(message) } }, [app.id, openAsyncWindow]) return ( <> handleMenuAction(e, onEdit)}> {t('editApp', { ns: 'app' })} handleMenuAction(e, onDuplicate)}> {t('duplicate', { ns: 'app' })} handleMenuAction(e, onExport)}> {t('export', { ns: 'app' })} {shouldShowSwitchOption && ( <> handleMenuAction(e, onSwitch)}> {t('switch', { ns: 'app' })} )} {shouldShowOpenInExploreOption && ( <> {t('openInExplore', { ns: 'app' })} )} {shouldShowAccessControlOption && ( <> handleMenuAction(e, onAccessControl)}> {t('accessControl', { ns: 'app' })} )} handleMenuAction(e, onDelete)} > {t('operation.delete', { ns: 'common' })} ) } type AppCardOperationsMenuContentProps = Omit const AppCardOperationsMenuContent: React.FC = (props) => { const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp } = useGetUserCanAccessApp({ appId: props.app.id, enabled: systemFeatures.webapp_auth.enabled, }) const shouldShowOpenInExploreOption = !props.app.has_draft_trigger && ( !systemFeatures.webapp_auth.enabled || (!isGettingUserCanAccessApp && Boolean(userCanAccessApp?.result)) ) return ( ) } const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () => {} }: AppCardProps) => { const { t } = useTranslation() const deleteAppNameInputId = useId() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { isCurrentWorkspaceEditor } = useAppContext() const { onPlanInfoChanged } = useProviderContext() const { push } = useRouter() const [showEditModal, setShowEditModal] = useState(false) const [showDuplicateModal, setShowDuplicateModal] = useState(false) const [showSwitchModal, setShowSwitchModal] = useState(false) const [showConfirmDelete, setShowConfirmDelete] = useState(false) const [confirmDeleteInput, setConfirmDeleteInput] = useState('') const [showAccessControl, setShowAccessControl] = useState(false) const [isOperationsMenuOpen, setIsOperationsMenuOpen] = useState(false) const [secretEnvList, setSecretEnvList] = useState([]) const { mutateAsync: mutateDeleteApp, isPending: isDeleting } = useDeleteAppMutation() const onConfirmDelete = useCallback(async () => { try { await mutateDeleteApp(app.id) toast.success(t('appDeleted', { ns: 'app' })) onPlanInfoChanged() setShowConfirmDelete(false) setConfirmDeleteInput('') } catch (e) { const message = e instanceof Error ? e.message : '' toast.error(`${t('appDeleteFailed', { ns: 'app' })}${message ? `: ${message}` : ''}`) } }, [app.id, mutateDeleteApp, onPlanInfoChanged, t]) const onDeleteDialogOpenChange = useCallback((open: boolean) => { if (isDeleting) return setShowConfirmDelete(open) if (!open) setConfirmDeleteInput('') }, [isDeleting]) const isDeleteConfirmDisabled = isDeleting || confirmDeleteInput !== app.name const onDeleteDialogSubmit: React.FormEventHandler = useCallback((e) => { e.preventDefault() if (isDeleteConfirmDisabled) return void onConfirmDelete() }, [isDeleteConfirmDisabled, onConfirmDelete]) const handleShowEditModal = useCallback(() => { setIsOperationsMenuOpen(false) queueMicrotask(() => { setShowEditModal(true) }) }, []) const handleShowDuplicateModal = useCallback(() => { setIsOperationsMenuOpen(false) queueMicrotask(() => { setShowDuplicateModal(true) }) }, []) const handleShowSwitchModal = useCallback(() => { setIsOperationsMenuOpen(false) queueMicrotask(() => { setShowSwitchModal(true) }) }, []) const handleShowDeleteConfirm = useCallback(() => { setIsOperationsMenuOpen(false) queueMicrotask(() => { setShowConfirmDelete(true) }) }, []) const handleShowAccessControl = useCallback(() => { setIsOperationsMenuOpen(false) queueMicrotask(() => { setShowAccessControl(true) }) }, []) const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({ name, icon_type, icon, icon_background, description, use_icon_as_answer_icon, max_active_requests, }) => { try { await updateAppInfo({ appID: app.id, name, icon_type, icon, icon_background, description, use_icon_as_answer_icon, max_active_requests, }) setShowEditModal(false) toast.success(t('editDone', { ns: 'app' })) if (onRefresh) onRefresh() } catch (e) { toast.error(e instanceof Error ? e.message : t('editFailed', { ns: 'app' })) } }, [app.id, onRefresh, t]) const onCopy: DuplicateAppModalProps['onConfirm'] = async ({ name, icon_type, icon, icon_background }) => { try { const newApp = await copyApp({ appID: app.id, name, icon_type, icon, icon_background, mode: app.mode, }) setShowDuplicateModal(false) toast.success(t('newApp.appCreated', { ns: 'app' })) localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1') if (onRefresh) onRefresh() onPlanInfoChanged() getRedirection(isCurrentWorkspaceEditor, newApp, push) } catch { toast.error(t('newApp.appCreateFailed', { ns: 'app' })) } } const onExport = async (include = false) => { try { const { data } = await exportAppConfig({ appID: app.id, include, }) const file = new Blob([data], { type: 'application/yaml' }) downloadBlob({ data: file, fileName: `${app.name}.yml` }) } catch { toast.error(t('exportFailed', { ns: 'app' })) } } const exportCheck = async () => { setIsOperationsMenuOpen(false) if (app.mode !== AppModeEnum.WORKFLOW && app.mode !== AppModeEnum.ADVANCED_CHAT) { onExport() return } try { const workflowDraft = await fetchWorkflowDraft(`/apps/${app.id}/workflows/draft`) const list = (workflowDraft.environment_variables || []).filter(env => env.value_type === 'secret') if (list.length === 0) { onExport() return } setSecretEnvList(list) } catch { toast.error(t('exportFailed', { ns: 'app' })) } } const onSwitch = () => { if (onRefresh) onRefresh() setShowSwitchModal(false) } const onUpdateAccessControl = useCallback(() => { if (onRefresh) onRefresh() setShowAccessControl(false) }, [onRefresh, setShowAccessControl]) const shouldShowSwitchOption = app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT const shouldShowAccessControlOption = systemFeatures.webapp_auth.enabled && isCurrentWorkspaceEditor const operationsMenuWidthClassName = shouldShowSwitchOption ? 'w-[256px]' : 'w-[216px]' const EditTimeText = useMemo(() => { const timeText = formatTime({ date: (app.updated_at || app.created_at) * 1000, dateFormat: `${t('segment.dateTimeFormat', { ns: 'datasetDocuments' })}`, }) return `${t('segment.editedAt', { ns: 'datasetDocuments' })} ${timeText}` }, [app.updated_at, app.created_at, t]) const onlinePresenceUsers = useMemo(() => { return onlineUsers .map((user, index) => { const id = user.user_id || user.sid || `${app.id}-online-${index}` const name = user.username || user.user_id || user.sid || `${index + 1}` return { id, name, avatar_url: user.avatar || null, } }) .filter(user => Boolean(user.id)) }, [app.id, onlineUsers]) return ( <>
{ e.preventDefault() getRedirection(isCurrentWorkspaceEditor, app, push) }} className="group relative col-span-1 inline-flex h-[160px] cursor-pointer flex-col rounded-xl border border-solid border-components-card-border bg-components-card-bg shadow-sm transition-shadow duration-200 ease-in-out hover:shadow-lg" >
{app.name}
{app.author_name}
ยท
{EditTimeText}
{onlinePresenceUsers.length > 0 && ( )}
{app.access_mode === AccessMode.PUBLIC && ( } /> {t('accessItemsDescription.anyone', { ns: 'app' })} )} {app.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && ( } /> {t('accessItemsDescription.specific', { ns: 'app' })} )} {app.access_mode === AccessMode.ORGANIZATION && ( } /> {t('accessItemsDescription.organization', { ns: 'app' })} )} {app.access_mode === AccessMode.EXTERNAL_MEMBERS && ( } /> {t('accessItemsDescription.external', { ns: 'app' })} )}
{app.description}
{isCurrentWorkspaceEditor && ( <>
{ e.stopPropagation() e.preventDefault() }} >
{ e.stopPropagation() e.preventDefault() }} >
{t('operation.more', { ns: 'common' })}
{systemFeatures.webapp_auth.enabled ? ( ) : ( )}
)}
{showEditModal && ( setShowEditModal(false)} /> )} {showDuplicateModal && ( setShowDuplicateModal(false)} /> )} {showSwitchModal && ( setShowSwitchModal(false)} onSuccess={onSwitch} /> )}
{t('deleteAppConfirmTitle', { ns: 'app' })} {t('deleteAppConfirmContent', { ns: 'app' })}
setConfirmDeleteInput(e.target.value)} className="border-components-input-border-hover bg-components-input-bg-normal focus:border-components-input-border-active focus:bg-components-input-bg-active" />
{t('operation.cancel', { ns: 'common' })} {t('operation.confirm', { ns: 'common' })}
{secretEnvList.length > 0 && ( setSecretEnvList([])} /> )} {showAccessControl && ( setShowAccessControl(false)} /> )} ) } export default React.memo(AppCard)