mirror of
https://github.com/langgenius/dify.git
synced 2026-06-21 10:01:08 +08:00
Co-authored-by: zhangx1n <zhangxin@dify.ai> Co-authored-by: yyh <yuanyouhuilyz@gmail.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
361 lines
12 KiB
TypeScript
361 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import type { GuideMethod, WorkflowSourceApp } from '@/features/deployments/create-guide/state'
|
|
import { Button } from '@langgenius/dify-ui/button'
|
|
import { cn } from '@langgenius/dify-ui/cn'
|
|
import { Input } from '@langgenius/dify-ui/input'
|
|
import { RadioRoot } from '@langgenius/dify-ui/radio'
|
|
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
|
import { useAtomValue, useSetAtom } from 'jotai'
|
|
import { useTranslation } from 'react-i18next'
|
|
import Uploader from '@/app/components/app/create-from-dsl-modal/uploader'
|
|
import AppIcon from '@/app/components/base/app-icon'
|
|
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
|
import { DeploymentStateMessage } from '@/features/deployments/components/empty-state'
|
|
import { TitleTooltip } from '@/features/deployments/components/title-tooltip'
|
|
import { UnsupportedDslNodesAlert } from '@/features/deployments/components/unsupported-dsl-nodes-alert'
|
|
import {
|
|
continueFromSourceAtom,
|
|
dslFileAtom,
|
|
dslReadErrorAtom,
|
|
dslUnsupportedModeAtom,
|
|
effectiveSelectedAppAtom,
|
|
isReadingDslAtom,
|
|
methodAtom,
|
|
selectDslFileAtom,
|
|
selectMethodAtom,
|
|
selectSourceAppAtom,
|
|
setSourceSearchTextAtom,
|
|
sourceAppsQueryAtom,
|
|
sourceCanGoNextAtom,
|
|
sourceSearchTextAtom,
|
|
unsupportedDslNodesAtom,
|
|
} from '@/features/deployments/create-guide/state'
|
|
import { StepShell } from './layout'
|
|
|
|
const sourceAppSkeletonKeys = ['first-source-app', 'second-source-app', 'third-source-app']
|
|
|
|
export function SourceStepContent() {
|
|
const method = useAtomValue(methodAtom)
|
|
const unsupportedDslNodes = useAtomValue(unsupportedDslNodesAtom)
|
|
|
|
return (
|
|
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
|
<SourceMethodSection />
|
|
{method === 'bindApp' && (
|
|
<SourceAppSelectionSection />
|
|
)}
|
|
{method === 'importDsl' && (
|
|
<DslUploadSection />
|
|
)}
|
|
<UnsupportedDslNodesAlert nodes={unsupportedDslNodes} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SourceMethodSection() {
|
|
const { t } = useTranslation('deployments')
|
|
const method = useAtomValue(methodAtom)
|
|
const selectMethod = useSetAtom(selectMethodAtom)
|
|
|
|
return (
|
|
<StepShell
|
|
title={t('createGuide.steps.method')}
|
|
description={t('createGuide.method.description')}
|
|
descriptionClassName="lg:hidden"
|
|
hideHeader
|
|
>
|
|
<RadioGroup<GuideMethod>
|
|
value={method}
|
|
onValueChange={selectMethod}
|
|
className="flex flex-col items-stretch gap-2 sm:flex-row"
|
|
>
|
|
<SourceMethodCard
|
|
value="bindApp"
|
|
icon="i-ri-stack-line"
|
|
title={t('createGuide.methods.bindApp.title')}
|
|
description={t('createGuide.methods.bindApp.description')}
|
|
/>
|
|
<SourceMethodCard
|
|
value="importDsl"
|
|
icon="i-ri-file-code-line"
|
|
title={t('createGuide.methods.importDsl.title')}
|
|
description={t('createGuide.methods.importDsl.description')}
|
|
/>
|
|
</RadioGroup>
|
|
</StepShell>
|
|
)
|
|
}
|
|
|
|
function SourceMethodCard({ value, icon, title, description, badge }: {
|
|
value: GuideMethod
|
|
icon: string
|
|
title: string
|
|
description: string
|
|
badge?: string
|
|
}) {
|
|
return (
|
|
<RadioRoot<GuideMethod>
|
|
value={value}
|
|
variant="unstyled"
|
|
className={cn(
|
|
`relative box-content h-[84px] w-full cursor-pointer rounded-xl border-[0.5px]
|
|
border-components-option-card-option-border bg-components-panel-on-panel-item-bg p-3
|
|
text-left shadow-xs outline-hidden hover:shadow-md focus-visible:ring-2
|
|
focus-visible:ring-state-accent-solid sm:w-[240px]`,
|
|
'data-checked:border-components-option-card-option-selected-border data-checked:bg-components-option-card-option-selected-bg data-checked:shadow-md data-checked:ring-[0.5px] data-checked:ring-components-option-card-option-selected-border data-checked:ring-inset',
|
|
)}
|
|
>
|
|
<span className="flex size-6 shrink-0 items-center justify-center rounded-md border border-divider-subtle bg-background-default-subtle">
|
|
<span className={cn('size-4 text-text-tertiary', icon)} aria-hidden="true" />
|
|
</span>
|
|
<span className="mt-2 mb-0.5 flex min-w-0 items-center gap-1">
|
|
<span className="truncate system-sm-semibold text-text-secondary">{title}</span>
|
|
{badge && (
|
|
<span className="shrink-0 rounded-md bg-background-default-subtle px-1.5 py-0.5 system-2xs-medium-uppercase text-text-tertiary">
|
|
{badge}
|
|
</span>
|
|
)}
|
|
</span>
|
|
<span className="flex min-w-0 items-start gap-1">
|
|
<TitleTooltip content={description}>
|
|
<span className="line-clamp-2 min-w-0 grow system-xs-regular text-text-tertiary">
|
|
{description}
|
|
</span>
|
|
</TitleTooltip>
|
|
</span>
|
|
</RadioRoot>
|
|
)
|
|
}
|
|
|
|
function SourceAppSelectionSection() {
|
|
const { t } = useTranslation('deployments')
|
|
|
|
return (
|
|
<StepShell
|
|
title={t('createGuide.source.title')}
|
|
description={t('createGuide.source.description')}
|
|
descriptionClassName="lg:hidden"
|
|
hideHeader
|
|
className="min-h-0 flex-1"
|
|
>
|
|
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
|
<SourceSearchInput />
|
|
<SourceAppList />
|
|
</div>
|
|
</StepShell>
|
|
)
|
|
}
|
|
|
|
function SourceSearchInput() {
|
|
const { t } = useTranslation('deployments')
|
|
const sourceSearchText = useAtomValue(sourceSearchTextAtom)
|
|
const setSourceSearchText = useSetAtom(setSourceSearchTextAtom)
|
|
|
|
return (
|
|
<div className="relative">
|
|
<span className="pointer-events-none absolute top-1/2 left-2.5 i-ri-search-line size-4 -translate-y-1/2 text-text-tertiary" aria-hidden="true" />
|
|
<Input
|
|
id="create-guide-source-search"
|
|
aria-label={t('createGuide.source.sourceApp')}
|
|
value={sourceSearchText}
|
|
onChange={event => setSourceSearchText(event.target.value)}
|
|
placeholder={t('createGuide.source.searchPlaceholder')}
|
|
className="h-9 pr-8 pl-8"
|
|
/>
|
|
{sourceSearchText && (
|
|
<button
|
|
type="button"
|
|
aria-label={t('createGuide.source.clearSearch')}
|
|
onClick={() => setSourceSearchText('')}
|
|
className="absolute top-1/2 right-2.5 flex size-4 -translate-y-1/2 items-center justify-center text-text-quaternary hover:text-text-secondary"
|
|
>
|
|
<span className="i-ri-close-circle-fill size-4" aria-hidden="true" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SourceAppList() {
|
|
const { t } = useTranslation('deployments')
|
|
const selectSourceApp = useSetAtom(selectSourceAppAtom)
|
|
const effectiveSelectedApp = useAtomValue(effectiveSelectedAppAtom)
|
|
const sourceAppsQuery = useAtomValue(sourceAppsQueryAtom)
|
|
const sourceApps = (sourceAppsQuery.data?.pages.flatMap(page => page.data) ?? []) as WorkflowSourceApp[]
|
|
const sourceAppsLoading = sourceAppsQuery.isLoading || sourceAppsQuery.isPlaceholderData || (sourceAppsQuery.isFetching && sourceApps.length === 0)
|
|
|
|
return (
|
|
<div className="min-h-0 flex-1 overflow-y-auto rounded-lg border border-divider-subtle bg-background-default">
|
|
{sourceAppsLoading
|
|
? <SourceAppSkeleton />
|
|
: sourceApps.length === 0
|
|
? (
|
|
<DeploymentStateMessage variant="embedded">
|
|
{t('createGuide.source.empty')}
|
|
</DeploymentStateMessage>
|
|
)
|
|
: (
|
|
<div>
|
|
{sourceApps.map(app => (
|
|
<SourceAppOption
|
|
key={app.id}
|
|
app={app}
|
|
selected={effectiveSelectedApp?.id === app.id}
|
|
onSelect={() => selectSourceApp(app)}
|
|
/>
|
|
))}
|
|
{sourceAppsQuery.hasNextPage && (
|
|
<div className="flex justify-center border-t border-divider-subtle px-3 py-2">
|
|
<Button
|
|
type="button"
|
|
size="small"
|
|
disabled={sourceAppsQuery.isFetchingNextPage}
|
|
onClick={() => {
|
|
void sourceAppsQuery.fetchNextPage()
|
|
}}
|
|
>
|
|
{sourceAppsQuery.isFetchingNextPage ? t('createModal.loadingApps') : t('createModal.loadMoreApps')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SourceAppSkeleton() {
|
|
return (
|
|
<div className="divide-y divide-divider-subtle">
|
|
{sourceAppSkeletonKeys.map(key => (
|
|
<SkeletonRow key={key} className="h-14 px-3 py-2">
|
|
<SkeletonRectangle className="my-0 size-7 animate-pulse rounded-lg" />
|
|
<div className="flex min-w-0 grow flex-col gap-1">
|
|
<SkeletonRectangle className="my-0 h-3.5 w-2/3 animate-pulse" />
|
|
<SkeletonRectangle className="my-0 h-2.5 w-1/3 animate-pulse" />
|
|
</div>
|
|
</SkeletonRow>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SourceAppOption({ app, onSelect, selected }: {
|
|
app: WorkflowSourceApp
|
|
onSelect: () => void
|
|
selected: boolean
|
|
}) {
|
|
return (
|
|
<label
|
|
className={cn(
|
|
'group flex min-h-14 cursor-pointer items-center gap-3 border-b border-b-divider-subtle px-3 py-2 transition-colors first:rounded-t-lg last:rounded-b-lg last:border-b-0',
|
|
selected
|
|
? 'bg-state-accent-hover hover:bg-state-accent-hover'
|
|
: 'bg-background-default hover:bg-state-base-hover',
|
|
)}
|
|
>
|
|
<AppIcon
|
|
className="shrink-0"
|
|
size="xs"
|
|
iconType={app.icon_type}
|
|
icon={app.icon}
|
|
background={app.icon_background}
|
|
imageUrl={app.icon_url}
|
|
/>
|
|
<span className="flex min-w-0 grow">
|
|
<span className={cn('truncate system-sm-medium', selected ? 'text-text-accent' : 'text-text-primary')}>{app.name}</span>
|
|
</span>
|
|
<input
|
|
type="radio"
|
|
name="source-app"
|
|
checked={selected}
|
|
onChange={onSelect}
|
|
className="sr-only"
|
|
/>
|
|
<span
|
|
className={cn(
|
|
'flex size-5 shrink-0 items-center justify-center rounded-full',
|
|
selected ? 'bg-primary-600 text-text-primary-on-surface' : 'text-transparent',
|
|
)}
|
|
aria-hidden="true"
|
|
>
|
|
<span className="i-ri-check-line size-4" />
|
|
</span>
|
|
</label>
|
|
)
|
|
}
|
|
|
|
function DslUploadSection() {
|
|
const { t } = useTranslation('deployments')
|
|
const dslFile = useAtomValue(dslFileAtom)
|
|
const selectDslFile = useSetAtom(selectDslFileAtom)
|
|
|
|
return (
|
|
<StepShell title={t('createGuide.dsl.title')} description={t('createGuide.dsl.description')} hideHeader>
|
|
<div className="flex flex-col gap-4 rounded-xl border border-components-panel-border bg-components-panel-bg-blur p-5">
|
|
<div className="flex items-start gap-3">
|
|
<span className="mt-0.5 i-ri-upload-cloud-2-line size-5 shrink-0 text-text-tertiary" aria-hidden="true" />
|
|
<div className="flex min-w-0 flex-col gap-1">
|
|
<div className="system-sm-semibold text-text-primary">{t('createGuide.dsl.dropTitle')}</div>
|
|
<div className="system-sm-regular text-text-tertiary">{t('createGuide.dsl.dropDescription')}</div>
|
|
</div>
|
|
</div>
|
|
<Uploader
|
|
className="mt-0"
|
|
file={dslFile}
|
|
updateFile={selectDslFile}
|
|
/>
|
|
<DslReadStatus />
|
|
</div>
|
|
</StepShell>
|
|
)
|
|
}
|
|
|
|
function DslReadStatus() {
|
|
const { t } = useTranslation('deployments')
|
|
const isReadingDsl = useAtomValue(isReadingDslAtom)
|
|
const dslReadError = useAtomValue(dslReadErrorAtom)
|
|
const dslUnsupportedMode = useAtomValue(dslUnsupportedModeAtom)
|
|
|
|
return (
|
|
<>
|
|
{isReadingDsl && (
|
|
<div className="system-xs-regular text-text-tertiary">
|
|
{t('createGuide.dsl.reading')}
|
|
</div>
|
|
)}
|
|
{dslReadError && (
|
|
<div className="system-xs-regular text-text-destructive">
|
|
{t('createGuide.dsl.readFailed')}
|
|
</div>
|
|
)}
|
|
{dslUnsupportedMode && (
|
|
<div role="alert" className="system-xs-regular text-text-destructive">
|
|
{t('createGuide.dsl.unsupportedMode')}
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
export function SourceActionButtons() {
|
|
const { t } = useTranslation('deployments')
|
|
const canGoNext = useAtomValue(sourceCanGoNextAtom)
|
|
const continueFromSource = useSetAtom(continueFromSourceAtom)
|
|
|
|
return (
|
|
<Button
|
|
type="button"
|
|
variant="primary"
|
|
disabled={!canGoNext}
|
|
onClick={() => continueFromSource({
|
|
defaultDslAppName: t('createGuide.dsl.defaultAppName'),
|
|
defaultReleaseName: t('createGuide.release.defaultName'),
|
|
})}
|
|
>
|
|
{t('createGuide.actions.next')}
|
|
</Button>
|
|
)
|
|
}
|