'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) => (
) // 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 ( { if (!open) { // Cancel any in-flight request BEFORE closing the store — a // request that resolves after the modal closes would still toast // against the now-unmounted modal and pollute version history. abortInFlight() closeGenerator() } }} >
{/* Left pane: instructions + ideal output + model selector */}
{isRefine ? t(($) => $['workflowGenerator.refineTitle'], { mode: modeLabel }) : t(($) => $['workflowGenerator.title'], { mode: modeLabel })} {isRefine ? t(($) => $['workflowGenerator.refineDescription']) : t(($) => $['workflowGenerator.description'])}
{t(($) => $['workflowGenerator.instruction'])}