'use client' import type { AppPartial, EnvironmentVariableItemResponse, } from '@dify/contracts/api/console/apps/types.gen' import type { FormEventHandler, MouseEvent, ReactElement } from 'react' import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal' import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal' import { zIconType } from '@dify/contracts/api/console/apps/zod.gen' import { AlertDialog, AlertDialogActions, AlertDialogCancelButton, AlertDialogConfirmButton, AlertDialogContent, AlertDialogDescription, AlertDialogTitle, } from '@langgenius/dify-ui/alert-dialog' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from '@langgenius/dify-ui/context-menu' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { Field, FieldLabel } from '@langgenius/dify-ui/field' import { IconButton } from '@langgenius/dify-ui/icon-button' import { InputGroup, InputGroupAddon, InputGroupInput } from '@langgenius/dify-ui/input-group' import { toast } from '@langgenius/dify-ui/toast' import { Toggle } from '@langgenius/dify-ui/toggle' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useMutation, useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { useCallback, useMemo, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import { useExportAppDsl, useExportWorkflowAppDsl } from '@/app/components/app/use-export-app-dsl' import StarIcon from '@/app/components/base/icons/src/vender/Star' import { buildInstalledAppPath } from '@/app/components/explore/installed-app/routes' import { getStepByStepTourDropdownMenuContentProps, useStepByStepTourControlledDropdown, } from '@/app/components/step-by-step-tour/dropdown-menu' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { useProviderContext } from '@/context/provider-context' import { userProfileQueryOptions } from '@/features/account-profile/client' import { systemFeaturesQueryOptions } from '@/features/system-features/client' 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/use-app-access-control' import { consoleQuery } from '@/service/client' import { fetchInstalledAppList } from '@/service/explore' import { AppModeEnum } from '@/types/app' import { getRedirection } from '@/utils/app-redirection' import { getAppACLCapabilities, hasPermission } from '@/utils/permission' 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 OPERATIONS_MENU_POPUP_CLASS_NAME = 'min-w-[216px]' const APP_MODES_REQUIRING_PUBLISHED_WORKFLOW_IN_EXPLORE = new Set([ AppModeEnum.ADVANCED_CHAT, AppModeEnum.WORKFLOW, ]) function requiresPublishedWorkflowInExplore(app: AppPartial) { return APP_MODES_REQUIRING_PUBLISHED_WORKFLOW_IN_EXPLORE.has(app.mode) } type AppCardOperationsMenuItemsProps = { app: AppPartial kind: 'context' | 'dropdown' shouldShowEditOption: boolean shouldShowDuplicateOption: boolean shouldShowExportOption: boolean shouldShowSwitchOption: boolean shouldShowAccessConfigOption: boolean shouldShowDeleteOption: boolean isExporting: boolean onEdit: () => void onDuplicate: () => void onExport: () => void onSwitch: () => void onDelete: () => void onAccessConfig: () => void } function AppCardOperationsMenuItems({ app, kind, shouldShowEditOption, shouldShowDuplicateOption, shouldShowExportOption, shouldShowSwitchOption, shouldShowAccessConfigOption, shouldShowDeleteOption, isExporting, onEdit, onDuplicate, onExport, onSwitch, onDelete, onAccessConfig, }: AppCardOperationsMenuItemsProps) { const { t } = useTranslation() const openAsyncWindow = useAsyncWindowOpen() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp } = useGetUserCanAccessApp({ appId: app.id, enabled: systemFeatures.webapp_auth.enabled, }) const needsPublishBeforeExplore = requiresPublishedWorkflowInExplore(app) && !app.workflow?.id const shouldShowOpenInExploreOption = !app.has_draft_trigger && app.access_mode !== AccessMode.EXTERNAL_MEMBERS && (needsPublishBeforeExplore || !systemFeatures.webapp_auth.enabled || (!isGettingUserCanAccessApp && Boolean(userCanAccessApp?.result))) const hasEditGroup = shouldShowEditOption const hasCreateExportGroup = shouldShowDuplicateOption || shouldShowExportOption const hasSwitchOrExploreGroup = shouldShowSwitchOption || shouldShowOpenInExploreOption const hasAccessDeleteGroup = shouldShowAccessConfigOption || shouldShowDeleteOption const MenuItem = kind === 'context' ? ContextMenuItem : DropdownMenuItem const MenuSeparator = kind === 'context' ? ContextMenuSeparator : DropdownMenuSeparator function handleMenuAction(event: MouseEvent, action: () => void) { event.stopPropagation() event.preventDefault() action() } async function handleOpenInstalledApp(event: MouseEvent) { event.stopPropagation() event.preventDefault() if (requiresPublishedWorkflowInExplore(app) && !app.workflow?.id) { toast.error(t(($) => $.notPublishedYet, { ns: 'app' })) return } try { await openAsyncWindow( async () => { const { installed_apps } = await fetchInstalledAppList(app.id) if (installed_apps?.length > 0) return `${basePath}${buildInstalledAppPath(installed_apps[0]!.id)}` throw new Error(t(($) => $.notPublishedYet, { ns: 'app' })) }, { onError: (error) => { toast.error(`${error.message || error}`) }, }, ) } catch (error: unknown) { const message = error instanceof Error ? error.message : `${error}` toast.error(message) } } return ( <> {shouldShowEditOption && ( handleMenuAction(event, onEdit)}> {t(($) => $.editApp, { ns: 'app' })} )} {hasEditGroup && (hasCreateExportGroup || hasSwitchOrExploreGroup || hasAccessDeleteGroup) && ( )} {shouldShowDuplicateOption && ( handleMenuAction(event, onDuplicate)}> {t(($) => $.duplicate, { ns: 'app' })} )} {shouldShowExportOption && ( handleMenuAction(event, onExport)} > {t(($) => $.export, { ns: 'app' })} )} {hasCreateExportGroup && (hasSwitchOrExploreGroup || hasAccessDeleteGroup) && ( )} {shouldShowSwitchOption && ( handleMenuAction(event, onSwitch)}> {t(($) => $.switch, { ns: 'app' })} )} {shouldShowOpenInExploreOption && ( {t(($) => $.openInExplore, { ns: 'app' })} )} {hasSwitchOrExploreGroup && hasAccessDeleteGroup && } {shouldShowAccessConfigOption && ( handleMenuAction(event, onAccessConfig)} > {t(($) => $['settings.resourceAccess'], { ns: 'common' })} )} {shouldShowAccessConfigOption && shouldShowDeleteOption && } {shouldShowDeleteOption && ( handleMenuAction(event, onDelete)} > {t(($) => $['operation.delete'], { ns: 'common' })} )} ) } type AppCardInteractionsProps = { app: AppPartial children: ReactElement stepByStepTourActionMenuOpen?: boolean stepByStepTourActionMenuHighlightPart?: string } export function AppCardInteractions({ app, children, stepByStepTourActionMenuOpen = false, stepByStepTourActionMenuHighlightPart, }: AppCardInteractionsProps) { const { t } = useTranslation() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { data: currentUserId } = useSuspenseQuery({ ...userProfileQueryOptions(), select: (data) => data.profile.id, }) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const isRbacEnabled = systemFeatures.rbac_enabled const { onPlanInfoChanged } = useProviderContext() const { push } = useRouter() const { mutate: copyApp } = useMutation(consoleQuery.apps.byAppId.copy.post.mutationOptions()) const { mutateAsync: updateApp } = useMutation(consoleQuery.apps.byAppId.put.mutationOptions()) const { mutate: deleteApp, isPending: isDeleting } = useMutation( consoleQuery.apps.byAppId.delete.mutationOptions(), ) const { mutate: starApp, isPending: isStarring } = useMutation( consoleQuery.apps.byAppId.star.post.mutationOptions(), ) const { mutate: unstarApp, isPending: isUnstarring } = useMutation( consoleQuery.apps.byAppId.star.delete.mutationOptions(), ) const [activeDialog, setActiveDialog] = useState< 'delete' | 'duplicate' | 'edit' | 'switch' | null >(null) const [confirmDeleteInput, setConfirmDeleteInput] = useState('') const operationsMenu = useStepByStepTourControlledDropdown({ allowTriggerCloseWhileControlled: false, controlledOpen: stepByStepTourActionMenuOpen, }) const isOperationsMenuOpen = operationsMenu.open const setIsOperationsMenuOpen = operationsMenu.onOpenChange const [secretEnvList, setSecretEnvList] = useState([]) const { exportAppDsl, isExporting: isAppDslExporting } = useExportAppDsl() const { exportWorkflowAppDsl, isExporting: isWorkflowAppDslExporting } = useExportWorkflowAppDsl() const isExporting = isAppDslExporting || isWorkflowAppDslExporting const isTogglingStar = isStarring || isUnstarring const appIconType = zIconType.safeParse(app.icon_type).data ?? null const resourceMaintainer = app.maintainer ?? undefined const maintainerPermissionOptions = useMemo( () => ({ currentUserId, resourceMaintainer, workspacePermissionKeys, isRbacEnabled, }), [currentUserId, isRbacEnabled, resourceMaintainer, workspacePermissionKeys], ) const appACLCapabilities = useMemo( () => getAppACLCapabilities(app.permission_keys, maintainerPermissionOptions), [app.permission_keys, maintainerPermissionOptions], ) const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management') const onConfirmDelete = useCallback(() => { try { deleteApp( { params: { app_id: app.id } }, { onSuccess: () => { toast.success(t(($) => $.appDeleted, { ns: 'app' })) onPlanInfoChanged() setActiveDialog(null) setConfirmDeleteInput('') }, onError: (error) => { const message = error instanceof Error ? error.message : '' toast.error( `${t(($) => $.appDeleteFailed, { ns: 'app' })}${message ? `: ${message}` : ''}`, ) }, }, ) } catch (error) { const message = error instanceof Error ? error.message : '' toast.error(`${t(($) => $.appDeleteFailed, { ns: 'app' })}${message ? `: ${message}` : ''}`) } }, [app.id, deleteApp, onPlanInfoChanged, t]) const onDeleteDialogOpenChange = useCallback( (open: boolean) => { if (isDeleting) return setActiveDialog(open ? 'delete' : null) if (!open) setConfirmDeleteInput('') }, [isDeleting], ) const deleteNameMismatch = confirmDeleteInput !== app.name const isDeleteConfirmDisabled = isDeleting || deleteNameMismatch const onDeleteDialogSubmit: FormEventHandler = useCallback( (e) => { e.preventDefault() if (isDeleteConfirmDisabled) return void onConfirmDelete() }, [isDeleteConfirmDisabled, onConfirmDelete], ) const handleShowEditModal = useCallback(() => { setIsOperationsMenuOpen(false) queueMicrotask(() => { setActiveDialog('edit') }) }, [setIsOperationsMenuOpen]) const handleShowDuplicateModal = useCallback(() => { setIsOperationsMenuOpen(false) queueMicrotask(() => { setActiveDialog('duplicate') }) }, [setIsOperationsMenuOpen]) const handleShowSwitchModal = useCallback(() => { setIsOperationsMenuOpen(false) queueMicrotask(() => { setActiveDialog('switch') }) }, [setIsOperationsMenuOpen]) const handleShowDeleteConfirm = useCallback(() => { setIsOperationsMenuOpen(false) queueMicrotask(() => { setActiveDialog('delete') }) }, [setIsOperationsMenuOpen]) const handleOpenAccessConfig = useCallback(() => { setIsOperationsMenuOpen(false) push(`/app/${app.id}/access-config`) }, [app.id, push, setIsOperationsMenuOpen]) const onEdit: CreateAppModalProps['onConfirm'] = useCallback( async ({ name, icon_type, icon, icon_background, description, use_icon_as_answer_icon, max_active_requests, }) => { try { await updateApp({ params: { app_id: app.id }, body: { name, icon_type, icon, icon_background, description, use_icon_as_answer_icon, max_active_requests, }, }) setActiveDialog(null) toast.success(t(($) => $.editDone, { ns: 'app' })) } catch (e) { toast.error(e instanceof Error ? e.message : t(($) => $.editFailed, { ns: 'app' })) } }, [app.id, t, updateApp], ) const onCopy: DuplicateAppModalProps['onConfirm'] = ({ name, icon_type, icon, icon_background, }) => { try { copyApp( { params: { app_id: app.id }, body: { name, icon_type, icon, icon_background, }, }, { onSuccess: (newApp) => { if (!('mode' in newApp)) { toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' })) return } setActiveDialog(null) toast.success(t(($) => $['newApp.appCreated'], { ns: 'app' })) onPlanInfoChanged() getRedirection(newApp, push, { currentUserId, resourceMaintainer: newApp.maintainer ?? undefined, workspacePermissionKeys, isRbacEnabled, }) }, onError: () => toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' })), }, ) } catch { toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' })) } return Promise.resolve() } const onExport = async (include = false) => { await exportAppDsl({ appId: app.id, appName: app.name, includeSecret: include }) } const exportCheck = async () => { if (isExporting) return setIsOperationsMenuOpen(false) const isWorkflowApp = app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT const result = isWorkflowApp ? await exportWorkflowAppDsl({ appId: app.id, appName: app.name }) : await exportAppDsl({ appId: app.id, appName: app.name }) if (result?.status === 'confirmation-required') setSecretEnvList(result.secretEnvList) } const handleToggleStar = useCallback( (pressed: boolean) => { if (isTogglingStar) return const mutateStar = pressed ? starApp : unstarApp try { mutateStar( { params: { app_id: app.id } }, { onError: (error) => toast.error( error instanceof Error ? error.message : t(($) => $['studio.starFailed'], { ns: 'app' }), ), }, ) } catch (error) { toast.error( error instanceof Error ? error.message : t(($) => $['studio.starFailed'], { ns: 'app' }), ) } }, [app.id, isTogglingStar, starApp, t, unstarApp], ) const shouldShowEditOption = appACLCapabilities.canEdit const shouldShowDuplicateOption = canCreateApp const shouldShowExportOption = appACLCapabilities.canImportExportDSL const shouldShowSwitchOption = appACLCapabilities.canEdit && (app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT) const shouldShowAccessConfigOption = appACLCapabilities.canAccessConfig const shouldShowDeleteOption = appACLCapabilities.canDelete const shouldShowOperationsMenu = shouldShowEditOption || shouldShowDuplicateOption || shouldShowExportOption || shouldShowSwitchOption || shouldShowAccessConfigOption || shouldShowDeleteOption const starToggleLabel = t(($) => $['studio.starApp'], { ns: 'app' }) const starToggleAccessibleLabel = `${starToggleLabel}: ${app.name}` const operationsMenuItemsProps = { app, shouldShowEditOption, shouldShowDuplicateOption, shouldShowExportOption, shouldShowSwitchOption, shouldShowAccessConfigOption, shouldShowDeleteOption, isExporting, onEdit: handleShowEditModal, onDuplicate: handleShowDuplicateModal, onExport: exportCheck, onSwitch: handleShowSwitchModal, onDelete: handleShowDeleteConfirm, onAccessConfig: handleOpenAccessConfig, } return ( <> {shouldShowOperationsMenu ? ( ) : ( children )}
} /> } /> {starToggleLabel} {shouldShowOperationsMenu && ( $['operation.exporting'], { ns: 'common' }) : t(($) => $['operation.moreActionsFor'], { ns: 'common', name: app.name, }) } disabled={isExporting} className="data-popup-open:bg-state-base-hover" > } /> )}
{activeDialog === 'edit' && ( setActiveDialog(null)} /> )} {activeDialog === 'duplicate' && ( setActiveDialog(null)} /> )} {activeDialog === 'switch' && ( setActiveDialog(null)} /> )}
{t(($) => $.deleteAppConfirmTitle, { ns: 'app' })} {t(($) => $.deleteAppConfirmContent, { ns: 'app' })} $.deleteAppConfirmInputLabel} ns="app" values={{ appName: app.name }} components={{ appName: ( ), }} /> $.deleteAppConfirmInputPlaceholder, { ns: 'app' })} value={confirmDeleteInput} onValueChange={setConfirmDeleteInput} />
{t(($) => $['operation.cancel'], { ns: 'common' })} {t(($) => $['operation.confirm'], { ns: 'common' })}
{secretEnvList.length > 0 && ( setSecretEnvList([])} /> )} ) }