'use client' import type { GeneratedGraph } from './types' import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { CompletionParams, Model } 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 { useBoolean } from 'ahooks' import { useLocalStorage } from 'foxact/use-local-storage' import * as React from 'react' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import IdeaOutput from '@/app/components/app/configuration/config/automatic/idea-output' 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 { useAppContext } from '@/context/app-context' import { useRouter } from '@/next/navigation' import { generateWorkflow } from '@/service/debug' import { fetchWorkflowDraft } from '@/service/workflow' import { ModelModeType } from '@/types/app' import { getRedirectionPath } from '@/utils/app-redirection' import { applyToCurrentApp, applyToNewApp, WorkflowApplyHashCollisionError, WorkflowApplyOrphanError } from './apply' import ExamplePrompts from './example-prompts' import GenerationPhases from './generation-phases' import { useWorkflowGeneratorStore } from './store' import useGenGraph from './use-gen-graph' const STORAGE_MODEL_KEY = 'workflow-gen-model' // 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 // Stable default used both as the SSR/empty-storage seed for the persisted // model and as the merge base when patching a partial update. Module-level so // the reference stays identical across renders (useLocalStorage uses it as the // server value, which must not change identity each render). const EMPTY_MODEL: Model = { name: '', provider: '', mode: ModelModeType.chat, completion_params: {} as CompletionParams, } 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 { isCurrentWorkspaceEditor } = useAppContext() 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 closeGenerator = useWorkflowGeneratorStore(s => s.closeGenerator) const isRefine = intent === 'refine' && !!currentAppId // Persisted model selection. ``useLocalStorage`` is the storage boundary // mandated for client-only preferences — the empty model is the SSR/seed // value so ``model`` is always a concrete ``Model`` (never null) here. const [model, setModel] = useLocalStorage(STORAGE_MODEL_KEY, EMPTY_MODEL) 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_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_MODEL), provider: newValue.provider, name: newValue.modelId, mode: newValue.mode as ModelModeType, })) }, [setModel]) const handleCompletionParamsChange = useCallback((newParams: FormValue) => { setModel(prev => ({ ...(prev ?? EMPTY_MODEL), completion_params: newParams as CompletionParams, })) }, [setModel]) const [instruction, setInstruction] = useState('') const [ideaOutput, setIdeaOutput] = useState('') 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) // Per-attempt nonce — bumped on each Generate click so ``GenerationPhases`` // can reset its internal phase timer instead of resuming wherever the // previous attempt left off (which makes the UI look wedged). const [startedAt, setStartedAt] = useState(0) // 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 / ideaOutput) // 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 } const onGenerate = async () => { if (!isValid()) return // Cancel any previous in-flight request (double-click guard). The // previous promise will reject with AbortError which our catch swallows. abortInFlight() setStartedAt(Date.now()) generatedModeRef.current = mode setLoadingTrue() // Hard frontend timeout — aborts the request and surfaces a localised // toast so the user sees something actionable instead of a perpetual // spinner if the backend hangs. timeoutRef.current = setTimeout(() => { abortRef.current?.abort() abortRef.current = null toast.error(t('workflowGenerator.errors.timeout')) }, FE_TIMEOUT_MS) try { // Refine mode: pull the current draft graph 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 saved yet) degrades gracefully to a // from-scratch generation — better than blocking the user entirely — but // the user asked to REFINE, so tell them their draft isn't being used // instead of silently generating something unrelated. let currentGraph: Awaited>['graph'] | undefined if (isRefine && currentAppId) { try { const draft = await fetchWorkflowDraft(`apps/${currentAppId}/workflows/draft`) if (draft?.graph?.nodes?.length) currentGraph = draft.graph } catch { currentGraph = undefined } if (!currentGraph) toast.warning(t('workflowGenerator.refineDraftUnavailable')) } const res = await generateWorkflow({ mode, instruction, ideal_output: ideaOutput, model_config: model, ...(currentGraph ? { current_graph: currentGraph } : {}), }, { getAbortController: (c) => { abortRef.current = c }, }) const first = res.errors?.[0] if (first) { // Prefer the localised copy for the structured code; fall back to // the backend's human-readable ``detail`` for codes we don't have // a translation for yet. const i18nKey = `workflowGenerator.errors.${first.code}` const localised = t(i18nKey, { defaultValue: '' }) toast.error(localised || first.detail || res.error || t('workflowGenerator.generateFailed')) return } if (res.error) { toast.error(res.error) return } if (!res.graph?.nodes?.length) { // Defensive: a success envelope with an empty graph should never // leave the backend, but if it does, an empty "version" would just // pollute the selector with a blank preview. toast.error(t('workflowGenerator.generateFailed')) return } addVersion(res) } catch (e: unknown) { // Aborts are intentional (modal close, second click, timeout) — never // toast for them. The timeout path already showed its own toast. if (isAbortError(e)) return const message = e instanceof Error ? e.message : '' toast.error(message || t('workflowGenerator.generateFailed')) } finally { setLoadingFalse() clearTimers() abortRef.current = null } } 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 } = await applyToNewApp({ mode, graph: current.graph as GeneratedGraph, instruction, appName: current.app_name, icon: current.icon, }) toast.success(t('workflowGenerator.applied')) closeGenerator() router.push(getRedirectionPath(isCurrentWorkspaceEditor, { id: appId, mode: appMode })) } 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, isCurrentWorkspaceEditor, closeGenerator, t, isApplying, 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') 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')}