'use client' 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/debug' 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 } from '@langgenius/dify-ui/dialog' import { Textarea } from '@langgenius/dify-ui/textarea' import { toast } from '@langgenius/dify-ui/toast' import { RiErrorWarningLine } from '@remixicon/react' import { useSuspenseQuery } from '@tanstack/react-query' import { useBoolean } from 'ahooks' import * as React from 'react' 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 { systemFeaturesQueryOptions } from '@/features/system-features/client' import { useRouter } from '@/next/navigation' import { generateWorkflow, generateWorkflowStream } from '@/service/debug' import { fetchWorkflowDraft } from '@/service/workflow' 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' // Hard ceiling before we abort a hung request. Generous on purpose: the // backend runs two sequential LLM calls and may retry a transient provider // error (bounded backoff) or an unparseable response (one extra call), so a // slow-but-succeeding generation can legitimately pass the one-minute mark. // Aborting work that would have landed is the worse failure mode. const FE_TIMEOUT_MS = 90_000 // 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 // 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 = { code: string, detail: string, node_id?: string } 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}
) const WorkflowGeneratorModal: React.FC = () => { 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 60 s 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. // eslint-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 as GenError[]) return } if (!res.graph?.nodes?.length) { setGenError([{ code: 'EMPTY', 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. timeoutRef.current = setTimeout(() => { abortRef.current?.abort() abortRef.current = null toast.error(t('workflowGenerator.errors.timeout')) setLoadingFalse() }, FE_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, ...(currentGraph ? { current_graph: currentGraph } : {}), } 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 ? (t(`workflowGenerator.errors.${firstGenError.code}`, { defaultValue: '' }) || firstGenError.detail || t('workflowGenerator.generateFailed')) : '' 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')}