'use client'
import type { WorkflowGenerateErrorResponse } from '@dify/contracts/api/console/workflow-generate/types.gen'
import type { Hotkey } from '@tanstack/react-hotkeys'
import type { SelectorParam, TFunction } from 'i18next'
import type { GeneratedGraph } from './types'
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type {
GenerateWorkflowBody,
GenerateWorkflowResponse as StreamResult,
WorkflowGenPlan,
} from '@/service/workflow-generator'
import type { CompletionParams, ModelModeType } from '@/types/app'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Field, FieldLabel } from '@langgenius/dify-ui/field'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { matchesKeyboardEvent } from '@tanstack/react-hotkeys'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useBoolean } from 'ahooks'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import VersionSelector from '@/app/components/app/configuration/config/automatic/version-selector'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
import WorkflowPreview from '@/app/components/workflow/workflow-preview'
import { WORKFLOW_GENERATION_TIMEOUT_MS } from '@/config'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useRouter } from '@/next/navigation'
import { fetchWorkflowDraft } from '@/service/workflow'
import { generateWorkflow, generateWorkflowStream } from '@/service/workflow-generator'
import { getRedirectionPath } from '@/utils/app-redirection'
import {
applyToCurrentApp,
applyToNewApp,
WorkflowApplyHashCollisionError,
WorkflowApplyOrphanError,
} from './apply'
import ExamplePrompts from './example-prompts'
import GenerationPlan from './generation-plan'
import { diffGraphs } from './graph-diff'
import {
EMPTY_WORKFLOW_GENERATOR_MODEL,
useWorkflowGeneratorLastInstruction,
useWorkflowGeneratorModel,
} from './storage'
import { useWorkflowGeneratorStore } from './store'
import useGenGraph from './use-gen-graph'
// Mirrors the backend's instruction/ideal-output cap on /workflow-generate —
// keeping the limit client-side turns an opaque 400 into a visible input stop.
const MAX_INSTRUCTION_LENGTH = 10_000
const WORKFLOW_GENERATOR_SUBMIT_HOTKEY = 'Mod+Enter' satisfies Hotkey
// A single structured generation error. Mirrors the backend ``errors[]`` entry
// (stable ``code`` + human ``detail`` + optional ``node_id``) so the error panel
// can localise the message and point at the offending node.
type GenError = WorkflowGenerateErrorResponse
type WorkflowGeneratorErrorCode = WorkflowGenerateErrorResponse['code']
const workflowGeneratorErrorSelectors: Record<
WorkflowGeneratorErrorCode,
SelectorParam<'workflow'>
> = {
DANGLING_EDGE: ($) => $['workflowGenerator.errors.DANGLING_EDGE'],
DUPLICATE_NODE_ID: ($) => $['workflowGenerator.errors.DUPLICATE_NODE_ID'],
EMPTY_INSTRUCTION: ($) => $['workflowGenerator.errors.EMPTY_INSTRUCTION'],
EMPTY_PLAN: ($) => $['workflowGenerator.errors.EMPTY_PLAN'],
GRAPH_CYCLE: ($) => $['workflowGenerator.errors.GRAPH_CYCLE'],
INSTRUCTION_TOO_LONG: ($) => $['workflowGenerator.errors.INSTRUCTION_TOO_LONG'],
INVALID_CONTAINER: ($) => $['workflowGenerator.errors.INVALID_CONTAINER'],
INVALID_JSON: ($) => $['workflowGenerator.errors.INVALID_JSON'],
INVALID_SCHEMA: ($) => $['workflowGenerator.errors.INVALID_SCHEMA'],
MISSING_START: ($) => $['workflowGenerator.errors.MISSING_START'],
MISSING_TERMINAL: ($) => $['workflowGenerator.errors.MISSING_TERMINAL'],
MODEL_ERROR: ($) => $['workflowGenerator.errors.MODEL_ERROR'],
UNKNOWN_NODE_REFERENCE: ($) => $['workflowGenerator.errors.UNKNOWN_NODE_REFERENCE'],
UNKNOWN_TOOL: ($) => $['workflowGenerator.errors.UNKNOWN_TOOL'],
UNRESOLVED_REFERENCE: ($) => $['workflowGenerator.errors.UNRESOLVED_REFERENCE'],
}
function getWorkflowGeneratorErrorMessage(error: GenError, t: TFunction<'workflow'>) {
return t(workflowGeneratorErrorSelectors[error.code])
}
const renderPlaceholder = (label: string) => (
{label}
)
// AbortController throws a DOMException in modern browsers and a plain
// Error in older / non-DOM environments — accept both so we don't toast
// for an abort the user intentionally triggered.
const isAbortError = (e: unknown): boolean =>
(e instanceof DOMException || e instanceof Error) && e.name === 'AbortError'
type RecoveryDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description: string
cancelLabel: string
confirmLabel: string
onConfirm: () => void
}
// Shared shell for "we hit a snag — here's a Reload / Confirm button"
// dialogs. The overwrite-confirm and hash-collision dialogs differ only in
// copy and confirm handler; this collapses 30 lines of duplicate JSX to
// one props bag and keeps the visual styling in lockstep across both.
const RecoveryDialog = ({
open,
onOpenChange,
title,
description,
cancelLabel,
confirmLabel,
onConfirm,
}: RecoveryDialogProps) => (
!o && onOpenChange(false)}>
{title}
{description}
{cancelLabel}{confirmLabel}
)
function WorkflowGeneratorModal() {
const { t } = useTranslation('workflow')
const router = useRouter()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isRbacEnabled = systemFeatures.rbac_enabled
const isOpen = useWorkflowGeneratorStore((s) => s.isOpen)
const mode = useWorkflowGeneratorStore((s) => s.mode)
const intent = useWorkflowGeneratorStore((s) => s.intent)
const currentAppId = useWorkflowGeneratorStore((s) => s.currentAppId)
const currentAppMode = useWorkflowGeneratorStore((s) => s.currentAppMode)
const initialInstruction = useWorkflowGeneratorStore((s) => s.initialInstruction)
const autoMode = useWorkflowGeneratorStore((s) => s.autoMode)
const closeGenerator = useWorkflowGeneratorStore((s) => s.closeGenerator)
const isRefine = intent === 'refine' && !!currentAppId
const [model, setModel] = useWorkflowGeneratorModel()
const { defaultModel } = useModelListAndDefaultModelAndCurrentProviderAndModel(
ModelTypeEnum.textGeneration,
)
// Hydrate model from defaultModel once it loads (async). We deliberately set state
// from an effect here because defaultModel only resolves after the workspace's model
// catalogue fetch completes.
useEffect(() => {
if (defaultModel && !model.name) {
setModel((prev) => ({
...(prev ?? EMPTY_WORKFLOW_GENERATOR_MODEL),
name: defaultModel.model,
provider: defaultModel.provider.provider,
}))
}
}, [defaultModel, model.name, setModel])
const handleModelChange = useCallback(
(newValue: { modelId: string; provider: string; mode?: string; features?: string[] }) => {
setModel((prev) => ({
...(prev ?? EMPTY_WORKFLOW_GENERATOR_MODEL),
provider: newValue.provider,
name: newValue.modelId,
mode: newValue.mode as ModelModeType,
}))
},
[setModel],
)
const handleCompletionParamsChange = useCallback(
(newParams: FormValue) => {
setModel((prev) => ({
...(prev ?? EMPTY_WORKFLOW_GENERATOR_MODEL),
completion_params: newParams as CompletionParams,
}))
},
[setModel],
)
const [lastInstruction, setLastInstruction] = useWorkflowGeneratorLastInstruction()
// Seed from the palette's inline-captured instruction, else the last instruction
// generated from (persisted across opens). Captured at mount only — the modal
// remounts on each open, so this is just the initial value.
const [instruction, setInstruction] = useState(initialInstruction || lastInstruction || '')
// Planner result, streamed ahead of the graph (null until it lands).
const [plan, setPlan] = useState(null)
// Structured generation errors (validation / model). Drives the actionable
// error panel; null when the last attempt succeeded or hasn't run.
const [genError, setGenError] = useState(null)
// Refine base graph captured at Generate time, diffed against the result so
// the user can see what "apply" changes before overwriting their draft.
const [refineBaseGraph, setRefineBaseGraph] = useState(null)
const storageKey = `${mode}-${currentAppId ?? 'new'}`
const { addVersion, current, currentVersionIndex, setCurrentVersionIndex, versions } =
useGenGraph({
storageKey,
})
const [isLoading, { setTrue: setLoadingTrue, setFalse: setLoadingFalse }] = useBoolean(false)
const [isApplying, { setTrue: setApplyingTrue, setFalse: setApplyingFalse }] = useBoolean(false)
// Confirmation dialog for "Apply to current draft"
const [
isShowConfirmOverwrite,
{ setTrue: showConfirmOverwrite, setFalse: hideConfirmOverwrite },
] = useBoolean(false)
// Surfaced when the backend rejects the draft sync because another tab
// edited the workspace after we fetched it. Dedicated dialog instead of a
// toast because the user needs an explicit Reload action — without that,
// a generic "apply failed" toast leaves them stuck and confused.
const [isShowHashCollision, { setTrue: showHashCollision, setFalse: hideHashCollision }] =
useBoolean(false)
// Holds the AbortController of the in-flight ``/workflow-generate`` request
// so we can cancel it on (a) modal close, (b) a second Generate click
// while loading, (c) the hard frontend timeout, or (d) the user
// pressing Cancel. Without this an in-flight request outlives the modal
// and can race a future Generate call.
const abortRef = useRef(null)
// Companion timer so the timeout doesn't keep running after the response
// lands. Cleared inside the same ``finally`` block that flips loading off.
const timeoutRef = useRef | null>(null)
// Mode the user generated against. If they switch app context mid-flight
// (e.g. open the same modal from a different Studio in another tab) we
// hide the "Apply to current" button so the wrong-mode graph never lands
// in the wrong Studio. Captured at Generate time, not Apply time.
const generatedModeRef = useRef(null)
const clearTimers = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}, [])
const abortInFlight = useCallback(() => {
if (abortRef.current) {
abortRef.current.abort()
abortRef.current = null
}
clearTimers()
}, [clearTimers])
// Cleanup on unmount — a modal unmount mid-generation must NOT leave the
// request running in the background (it would still resolve, mutate the
// store, and toast "applied" against a stale modal).
useEffect(() => {
return () => {
abortInFlight()
}
// The cleanup function reads refs only, so it's stable; we intentionally
// exclude ``abortInFlight`` from deps to avoid re-running this effect on
// every render.
// oxlint-disable-next-line react/exhaustive-deps
}, [])
// Note: the modal is mounted lazily by ``mount.tsx`` which unmounts it when
// ``isOpen`` flips to false, so transient state (instruction / plan / errors)
// resets implicitly on the next open. No reset effect needed.
const isValid = () => {
const trimmed = instruction.trim()
if (!trimmed) {
toast.error(t(($) => $['workflowGenerator.instructionRequired']))
return false
}
if (!model.name) {
// No usable model resolved (provider catalogue empty or still
// loading). Without this guard the request would fly with an empty
// ``model_config.name`` and surface as a backend 400 — not actionable
// for the user. Tell them to pick a model.
toast.error(t(($) => $['workflowGenerator.modelRequired']))
return false
}
return true
}
// Apply a finished generation result (from the stream's ``result`` event or
// the non-streaming fallback). Structured errors go to the actionable error
// panel rather than a transient toast; a version is added only for a real graph.
const handleResult = useCallback(
(res: StreamResult) => {
if (res.errors?.length) {
setGenError(res.errors)
return
}
if (!res.graph?.nodes?.length) {
setGenError([
{
code: 'INVALID_SCHEMA',
detail: res.error || t(($) => $['workflowGenerator.generateFailed']),
},
])
return
}
setGenError(null)
addVersion(res)
},
[addVersion, t],
)
const onGenerate = async () => {
if (!isValid()) return
// Cancel any previous in-flight request (double-click guard).
abortInFlight()
generatedModeRef.current = mode
setLastInstruction(instruction)
setGenError(null)
setPlan(null)
setLoadingTrue()
// Hard frontend timeout — aborts the request and surfaces a localised toast
// instead of a perpetual spinner if the backend hangs. Generous on purpose
// (NEXT_PUBLIC_WORKFLOW_GENERATION_TIMEOUT_MS, default 180s): the backend
// runs a planner call plus parallel builder calls and may retry transient
// errors, so aborting a slow-but-succeeding generation is the worse
// failure mode.
timeoutRef.current = setTimeout(() => {
abortRef.current?.abort()
abortRef.current = null
toast.error(t(($) => $['workflowGenerator.errors.timeout']))
setLoadingFalse()
}, WORKFLOW_GENERATION_TIMEOUT_MS)
// Refine mode: pull the current draft so the backend amends it instead of
// starting from scratch. The modal mounts outside the Studio's ReactFlow
// provider, so we read the persisted draft rather than the live canvas. A
// fetch failure (no draft yet) degrades to from-scratch generation, but we
// warn since the user explicitly asked to refine.
let currentGraph: GeneratedGraph | undefined
if (isRefine && currentAppId) {
try {
const draft = await fetchWorkflowDraft(`apps/${currentAppId}/workflows/draft`)
if (draft?.graph?.nodes?.length) currentGraph = draft.graph as GeneratedGraph
} catch {
currentGraph = undefined
}
if (!currentGraph) toast.warning(t(($) => $['workflowGenerator.refineDraftUnavailable']))
}
setRefineBaseGraph(currentGraph ?? null)
const body: GenerateWorkflowBody = {
// Auto-mode sends 'auto' so the planner picks Workflow vs Chatflow; the
// resolved concrete mode comes back on the result and drives apply.
mode: autoMode ? 'auto' : mode,
instruction,
model_config: { ...model, mode: model.mode || 'chat' },
...(currentGraph
? { current_graph: currentGraph as unknown as GenerateWorkflowBody['current_graph'] }
: {}),
}
const finish = () => {
setLoadingFalse()
clearTimers()
abortRef.current = null
}
// Plan-first streaming: surface the planner outline the moment it lands, then
// the graph. ``settled`` tracks whether the stream produced anything, so a
// stream that dies before any event (endpoint missing, proxy buffering) can
// fall back to the single-shot endpoint instead of failing the user.
let settled = false
generateWorkflowStream(body, {
getAbortController: (c) => {
abortRef.current = c
},
onPlan: (p) => {
settled = true
setPlan(p)
},
onResult: (res) => {
settled = true
handleResult(res)
finish()
},
onError: (msg) => {
if (!settled) {
generateWorkflow(body, {
getAbortController: (c) => {
abortRef.current = c
},
})
.then((res) => handleResult(res))
.catch((e: unknown) => {
if (isAbortError(e)) return
const message = e instanceof Error ? e.message : ''
toast.error(message || t(($) => $['workflowGenerator.generateFailed']))
})
.finally(finish)
return
}
if (msg) toast.error(msg)
finish()
},
onCompleted: () => {
if (settled) finish()
},
})
}
const onCancelGeneration = useCallback(() => {
abortInFlight()
setLoadingFalse()
}, [abortInFlight, setLoadingFalse])
// "Apply to current" is valid only when the visible graph was generated
// for the app we'd be writing to. We require: a current app exists, its
// mode matches the current modal mode, AND the last Generate (if any)
// ran in this same mode — otherwise the user switched tabs mid-flight
// and we'd be writing a workflow graph into a chatflow draft (or vice
// versa). Falls back to "Create new app" only.
const generatedMode = generatedModeRef.current
const generatedModeMatches = generatedMode === null || generatedMode === mode
const canApplyToCurrent = !!currentAppId && currentAppMode === mode && generatedModeMatches
const handleApplyToNew = useCallback(async () => {
if (!current?.graph || isApplying) return
setApplyingTrue()
try {
const { appId, appMode, permissionKeys } = await applyToNewApp({
// Resolved mode — when the request used auto-mode this is the concrete
// type the planner picked, so the new app is created as the right kind.
mode: current.mode ?? mode,
graph: current.graph as GeneratedGraph,
instruction,
appName: current.app_name,
icon: current.icon,
})
// Nudge the freshly-created Studio toward iterating with cmd+k /refine
// instead of regenerating from scratch for a small tweak.
toast.success(t(($) => $['workflowGenerator.appliedRefineHint']))
closeGenerator()
router.push(
getRedirectionPath(
{ id: appId, mode: appMode, permission_keys: permissionKeys },
{ isRbacEnabled },
),
)
} catch (e: unknown) {
if (e instanceof WorkflowApplyOrphanError) {
// Sync failed AND we couldn't roll back. Route the user to /apps so
// the orphan is still discoverable — they can delete it by hand.
toast.error(t(($) => $['workflowGenerator.errors.apply_failed_orphan']))
closeGenerator()
router.push('/apps')
return
}
const message = e instanceof Error ? e.message : ''
toast.error(message || t(($) => $['workflowGenerator.applyFailed']))
} finally {
setApplyingFalse()
}
}, [
current,
instruction,
mode,
router,
closeGenerator,
t,
isApplying,
isRbacEnabled,
setApplyingTrue,
setApplyingFalse,
])
const handleApplyToCurrentConfirmed = useCallback(async () => {
if (!current?.graph || !currentAppId || isApplying) return
hideConfirmOverwrite()
setApplyingTrue()
try {
await applyToCurrentApp({ appId: currentAppId, graph: current.graph as GeneratedGraph })
toast.success(t(($) => $['workflowGenerator.applied']))
closeGenerator()
// Hard reload the workflow page so the canvas picks up the new draft —
// ``router.refresh()`` only revalidates server-rendered route data, and
// the Studio canvas is hydrated client-side via react-query / zustand.
if (typeof window !== 'undefined') window.location.reload()
} catch (e: unknown) {
if (e instanceof WorkflowApplyHashCollisionError) {
// Another tab edited the draft after we fetched it. Show a
// dedicated dialog with a Reload affordance instead of a generic
// "apply failed" toast — the user needs to know what actually
// happened so they can pick up the other tab's edits before
// retrying.
showHashCollision()
return
}
const message = e instanceof Error ? e.message : ''
toast.error(message || t(($) => $['workflowGenerator.applyFailed']))
} finally {
setApplyingFalse()
}
}, [
current,
currentAppId,
hideConfirmOverwrite,
closeGenerator,
t,
isApplying,
setApplyingTrue,
setApplyingFalse,
showHashCollision,
])
const modeLabel =
mode === 'workflow'
? t(($) => $['workflowGenerator.modes.workflow'])
: t(($) => $['workflowGenerator.modes.chatflow'])
// Refine diff — what an "apply" would change vs. the draft we started from.
const refineDiff = useMemo(() => {
if (!isRefine || !refineBaseGraph || !current?.graph?.nodes?.length) return null
return diffGraphs(refineBaseGraph, current.graph as GeneratedGraph)
}, [isRefine, refineBaseGraph, current])
const hasRefineChanges =
!!refineDiff &&
(refineDiff.added.length > 0 || refineDiff.removed.length > 0 || refineDiff.changed.length > 0)
// Derived view of the last structured error for the actionable error panel.
const firstGenError = genError?.[0]
const genErrorMessage = firstGenError ? getWorkflowGeneratorErrorMessage(firstGenError, t) : ''
const genErrorHasUnknownTool = !!genError?.some((e) => e.code === 'UNKNOWN_TOOL')
return (
)
}
export default WorkflowGeneratorModal