From 0bfbd2061e57a278aebd69a73f1e4621d39984ba Mon Sep 17 00:00:00 2001 From: Crazywoola <100913391+crazywoola@users.noreply.github.com> Date: Thu, 4 Jun 2026 19:06:17 +0800 Subject: [PATCH] feat: enhance go to anything (#32130) Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- api/controllers/console/app/generator.py | 74 + api/core/workflow/generator/__init__.py | 20 + .../workflow/generator/prompts/__init__.py | 1 + .../generator/prompts/builder_prompts.py | 553 ++++ .../generator/prompts/planner_prompts.py | 189 ++ api/core/workflow/generator/runner.py | 1253 +++++++++ api/core/workflow/generator/tool_catalogue.py | 153 ++ api/core/workflow/generator/types.py | 147 ++ api/openapi/markdown/console-swagger.md | 37 + api/services/workflow_generator_service.py | 96 + .../console/app/test_generator_api.py | 205 ++ .../core/workflow/generator/__init__.py | 0 .../core/workflow/generator/test_prompts.py | 129 + .../core/workflow/generator/test_runner.py | 2271 +++++++++++++++++ .../workflow/generator/test_tool_catalogue.py | 349 +++ .../test_workflow_generator_service.py | 201 ++ .../generated/api/console/orpc.gen.ts | 2 + .../api/console/workflow-generate/orpc.gen.ts | 35 + .../console/workflow-generate/types.gen.ts | 53 + .../api/console/workflow-generate/zod.gen.ts | 44 + .../workflow-generator/use-gen-graph.test.ts | 65 + web/app/(commonLayout)/layout.tsx | 2 + .../commands/__tests__/create.spec.tsx | 178 ++ .../commands/__tests__/refine.spec.tsx | 151 ++ .../actions/commands/__tests__/slash.spec.tsx | 4 + .../goto-anything/actions/commands/create.tsx | 119 + .../goto-anything/actions/commands/refine.tsx | 91 + .../goto-anything/actions/commands/slash.tsx | 6 + .../goto-anything/command-selector.tsx | 2 + .../_base/components/file-upload-setting.tsx | 6 +- .../__tests__/apply.spec.ts | 265 ++ .../__tests__/example-prompts.spec.tsx | 57 + .../__tests__/generation-phases.spec.tsx | 92 + .../__tests__/store.spec.ts | 155 ++ .../workflow/workflow-generator/apply.ts | 195 ++ .../workflow-generator/example-prompts.tsx | 67 + .../workflow-generator/generation-phases.tsx | 72 + .../workflow/workflow-generator/index.tsx | 592 +++++ .../workflow/workflow-generator/mount.tsx | 22 + .../workflow/workflow-generator/store.ts | 58 + .../workflow/workflow-generator/types.ts | 30 + .../workflow-generator/use-gen-graph.ts | 45 + web/i18n/en-US/app.json | 9 + web/i18n/en-US/workflow.json | 55 +- web/i18n/zh-Hans/app.json | 9 + web/i18n/zh-Hans/workflow.json | 55 +- web/service/debug.spec.ts | 83 + web/service/debug.ts | 99 + 48 files changed, 8391 insertions(+), 5 deletions(-) create mode 100644 api/core/workflow/generator/__init__.py create mode 100644 api/core/workflow/generator/prompts/__init__.py create mode 100644 api/core/workflow/generator/prompts/builder_prompts.py create mode 100644 api/core/workflow/generator/prompts/planner_prompts.py create mode 100644 api/core/workflow/generator/runner.py create mode 100644 api/core/workflow/generator/tool_catalogue.py create mode 100644 api/core/workflow/generator/types.py create mode 100644 api/services/workflow_generator_service.py create mode 100644 api/tests/unit_tests/core/workflow/generator/__init__.py create mode 100644 api/tests/unit_tests/core/workflow/generator/test_prompts.py create mode 100644 api/tests/unit_tests/core/workflow/generator/test_runner.py create mode 100644 api/tests/unit_tests/core/workflow/generator/test_tool_catalogue.py create mode 100644 api/tests/unit_tests/services/test_workflow_generator_service.py create mode 100644 packages/contracts/generated/api/console/workflow-generate/orpc.gen.ts create mode 100644 packages/contracts/generated/api/console/workflow-generate/types.gen.ts create mode 100644 packages/contracts/generated/api/console/workflow-generate/zod.gen.ts create mode 100644 web/__tests__/workflow/workflow-generator/use-gen-graph.test.ts create mode 100644 web/app/components/goto-anything/actions/commands/__tests__/create.spec.tsx create mode 100644 web/app/components/goto-anything/actions/commands/__tests__/refine.spec.tsx create mode 100644 web/app/components/goto-anything/actions/commands/create.tsx create mode 100644 web/app/components/goto-anything/actions/commands/refine.tsx create mode 100644 web/app/components/workflow/workflow-generator/__tests__/apply.spec.ts create mode 100644 web/app/components/workflow/workflow-generator/__tests__/example-prompts.spec.tsx create mode 100644 web/app/components/workflow/workflow-generator/__tests__/generation-phases.spec.tsx create mode 100644 web/app/components/workflow/workflow-generator/__tests__/store.spec.ts create mode 100644 web/app/components/workflow/workflow-generator/apply.ts create mode 100644 web/app/components/workflow/workflow-generator/example-prompts.tsx create mode 100644 web/app/components/workflow/workflow-generator/generation-phases.tsx create mode 100644 web/app/components/workflow/workflow-generator/index.tsx create mode 100644 web/app/components/workflow/workflow-generator/mount.tsx create mode 100644 web/app/components/workflow/workflow-generator/store.ts create mode 100644 web/app/components/workflow/workflow-generator/types.ts create mode 100644 web/app/components/workflow/workflow-generator/use-gen-graph.ts create mode 100644 web/service/debug.spec.ts diff --git a/api/controllers/console/app/generator.py b/api/controllers/console/app/generator.py index 67367cbe99..e471d0ed88 100644 --- a/api/controllers/console/app/generator.py +++ b/api/controllers/console/app/generator.py @@ -1,4 +1,5 @@ from collections.abc import Sequence +from typing import Literal from flask_restx import Resource from pydantic import BaseModel, Field @@ -25,6 +26,7 @@ from graphon.model_runtime.entities.llm_entities import LLMMode from graphon.model_runtime.errors.invoke import InvokeError from libs.login import login_required from models import App +from services.workflow_generator_service import WorkflowGeneratorService from services.workflow_service import WorkflowService @@ -42,6 +44,24 @@ class InstructionTemplatePayload(BaseModel): type: str = Field(..., description="Instruction template type") +class WorkflowGeneratePayload(BaseModel): + """Payload for the cmd+k `/create` and `/refine` workflow generator endpoint. + + See ``services/workflow_generator_service.py`` for behaviour. Errors are + surfaced through the same envelope as ``/rule-generate`` so the frontend + can reuse its existing handler. + """ + + mode: Literal["workflow", "advanced-chat"] = Field(..., description="Target app mode for the generated graph") + instruction: str = Field(..., description="Natural-language workflow description") + ideal_output: str = Field(default="", description="Optional sample output for grounding") + model_config_data: ModelConfig = Field(..., alias="model_config", description="Model configuration") + current_graph: dict | None = Field( + default=None, + description="Existing draft graph to refine (cmd+k `/refine`); omit for create-from-scratch", + ) + + register_enum_models(console_ns, LLMMode) register_schema_models( console_ns, @@ -50,6 +70,7 @@ register_schema_models( RuleStructuredOutputPayload, InstructionGeneratePayload, InstructionTemplatePayload, + WorkflowGeneratePayload, ModelConfig, ) @@ -265,3 +286,56 @@ class InstructionGenerationTemplateApi(Resource): return {"data": INSTRUCTION_GENERATE_TEMPLATE_CODE} case _: raise ValueError(f"Invalid type: {args.type}") + + +@console_ns.route("/workflow-generate") +class WorkflowGenerateApi(Resource): + """Generate a Workflow / Chatflow draft graph from a natural-language description. + + Triggered by the cmd+k `/create` slash command. Returns a graph payload + shaped exactly like ``WorkflowService.sync_draft_workflow``'s input, so the + frontend can hand it straight to ``/apps/{id}/workflows/draft``. + """ + + @console_ns.doc("generate_workflow_graph") + @console_ns.doc(description="Generate a Dify workflow graph from natural language") + @console_ns.expect(console_ns.models[WorkflowGeneratePayload.__name__]) + @console_ns.response(200, "Workflow graph generated successfully") + @console_ns.response(400, "Invalid request parameters") + @console_ns.response(402, "Provider quota exceeded") + @setup_required + @login_required + @account_initialization_required + @with_current_tenant_id + def post(self, current_tenant_id: str): + args = WorkflowGeneratePayload.model_validate(console_ns.payload) + + # Reject obviously-empty instructions at the boundary — Pydantic only + # validates ``instruction`` is a str, but a whitespace-only string + # would still hit the LLM and waste a planner+builder roundtrip on a + # response that the postprocess validator would reject anyway. + if not args.instruction.strip(): + return { + "error": "Instruction is required", + "errors": [{"code": "EMPTY_INSTRUCTION", "detail": "Instruction is required"}], + }, 400 + + try: + result = WorkflowGeneratorService.generate_workflow_graph( + tenant_id=current_tenant_id, + mode=args.mode, + instruction=args.instruction, + model_config=args.model_config_data, + ideal_output=args.ideal_output, + current_graph=args.current_graph, + ) + except ProviderTokenNotInitError as ex: + raise ProviderNotInitializeError(ex.description) + except QuotaExceededError: + raise ProviderQuotaExceededError() + except ModelCurrentlyNotSupportError: + raise ProviderModelCurrentlyNotSupportError() + except InvokeError as e: + raise CompletionRequestError(e.description) + + return result diff --git a/api/core/workflow/generator/__init__.py b/api/core/workflow/generator/__init__.py new file mode 100644 index 0000000000..9a885db948 --- /dev/null +++ b/api/core/workflow/generator/__init__.py @@ -0,0 +1,20 @@ +""" +Workflow generator package. + +Generates a Dify workflow graph (nodes, edges, viewport) from a natural-language +instruction. Intended for the cmd+k `/create` slash command's preview/apply flow. + +Pipeline (slim, single-shot variant): + + runner.WorkflowGenerator.generate_workflow_graph(...) + ├── planner_prompts: short LLM call → high-level node plan + └── builder_prompts: structured-output LLM call → full graph JSON + └── postprocess: fill defaults, auto-layout viewport, sanity-check edges + +The runner is pure domain logic; ``WorkflowGeneratorService`` (in ``services/``) +owns the model-manager dependency and is what controllers call. +""" + +from .runner import WorkflowGenerator + +__all__ = ["WorkflowGenerator"] diff --git a/api/core/workflow/generator/prompts/__init__.py b/api/core/workflow/generator/prompts/__init__.py new file mode 100644 index 0000000000..c5f708a653 --- /dev/null +++ b/api/core/workflow/generator/prompts/__init__.py @@ -0,0 +1 @@ +"""Prompt templates for the workflow generator (planner + builder).""" diff --git a/api/core/workflow/generator/prompts/builder_prompts.py b/api/core/workflow/generator/prompts/builder_prompts.py new file mode 100644 index 0000000000..0f81fb4251 --- /dev/null +++ b/api/core/workflow/generator/prompts/builder_prompts.py @@ -0,0 +1,553 @@ +""" +Builder prompts. + +The builder is the second step of the slim planner→builder pipeline. It takes +the planner's high-level node list and emits the *full* graph JSON consumed by +``WorkflowService.sync_draft_workflow``. + +The builder owns: node configuration (prompts, code, headers, etc.), edge wiring, +handle ids ("source"/"target"), positions, and the viewport. It is the only +prompt that needs to know the concrete shape of each node type — keep its +examples accurate or the LLM will invent fields. +""" + +import json +from typing import Any + +# Per-node-type configuration cheatsheet. +# +# Each entry mirrors the production ``defaultValue`` from +# ``web/app/components/workflow/nodes//default.ts`` so the generated +# graph loads in Studio identically to a manually-created node and survives +# both ``WorkflowService.sync_draft_workflow``'s structural checks and the +# runtime entity validation each node performs when the workflow runs. +# +# The postprocessor in ``runner.py`` fills missing wrapper fields (``type``, +# ``positionAbsolute``, ``width``, ``height``, ``sourcePosition`` / +# ``targetPosition``, edge ``data.sourceType`` / ``data.targetType``), so the +# LLM only needs to emit semantically meaningful fields. +NODE_CONFIG_CHEATSHEET = """\ +## Node wrapper (every node, top-level) + + {"id": "node1" (digits + letters only — see "Node IDs" below), + "type": "custom", # ReactFlow renderer key. Iteration/loop + # *start* children use special types + # (see Containers below). + "position": {"x": , "y": }, + "data": { ... per-type fields ... }} + +Children of iteration / loop containers additionally need +``parentId``, ``zIndex: 1002`` and ``extent: "parent"`` — see Containers. + +## Shared "data" fields (every node) + + {"type": "", # e.g. "llm", "start", "if-else" + "title": "", + "desc": "", + "selected": false} + +## Per type — additional "data" fields + +- start: + {"variables": [ + {"variable": "url", "label": "URL", "type": "text-input", + "required": true, "max_length": 256, "options": []}, + {"variable": "topic", "label": "Topic", "type": "paragraph", + "required": false, "max_length": 4096, "options": []} + ]} + EVERY user-supplied value referenced by a downstream node + (``{{#node-id.var#}}`` in a prompt / answer / template, or + ``["node-id", "var"]`` in a value_selector / iterator_selector / + tool_parameters) MUST be declared here as an entry of ``variables``. + If the planner's ``start_inputs`` list is non-empty, use it verbatim + (the user prompt section "Start inputs" surfaces it). Types: + text-input | paragraph | select | number | file | file-list. + In Advanced-Chat mode ``sys.query`` and ``sys.files`` are automatic + system variables — downstream nodes may reference them; do NOT add + them to ``variables``. + +- end (Workflow mode only): + {"outputs": [ + {"variable": "result", "value_selector": ["", ""]} + ]} + +- answer (Advanced Chat mode only): + {"variables": [], + "answer": ".#}} placeholders>"} + +- llm: + {"model": {"provider": "", "name": "", "mode": "chat", + "completion_params": {"temperature": 0.7}}, + "prompt_template": [ + {"role": "system", "text": ""}, + {"role": "user", "text": ".#}}>"} + ], + "context": {"enabled": false, "variable_selector": []}, + "vision": {"enabled": false}} + + Prompt-writing rules for the user-message text: + * ``{{#node.var#}}`` placeholders are interpolated by Dify BEFORE the + LLM sees them — at run time the model only sees the resolved value. + So an instruction like "Translate this: {{#node1.text#}}" is read + by the LLM as "Translate this: ". + * NEVER include placeholder syntax inside an "example output" block + in your prompt — the LLM will treat the example as the literal + answer template and echo placeholders back as output. Wrong: + Output JSON: {"en": "{{#node1.text#}}", "es": "{{#node1.text#}}"} + Right: + Translate the input into English, Spanish, French, German. + Output a JSON object with keys "en", "es", "fr", "de" whose + values are the translations. + Input: {{#node1.text#}} + * Each placeholder only resolves the variable from its source node — + it cannot be a Jinja template or call a function. + +- knowledge-retrieval: + {"query_variable_selector": ["", ""], + "query_attachment_selector": [], + "dataset_ids": [], + "retrieval_mode": "multiple", + "multiple_retrieval_config": {"top_k": 4, "score_threshold": null, + "reranking_enable": false}} + +- code (escape hatch — only if no installed tool fits): + {"code_language": "python3", + "code": "def main(arg1: str) -> dict:\\n return {'result': arg1}", + "variables": [{"variable": "arg1", "value_selector": ["", ""]}], + "outputs": {"result": {"type": "string", "children": null}}} + +- template-transform: + {"template": "Hello {{ name }}", + "variables": [{"variable": "name", "value_selector": ["", ""]}]} + +- http-request (escape hatch — only if no installed tool fits): + {"variables": [], "method": "get", "url": "https://example.com", + "authorization": {"type": "no-auth", "config": null}, + "headers": "", "params": "", + "body": {"type": "none", "data": []}, + "ssl_verify": true, + "timeout": {"max_connect_timeout": 0, "max_read_timeout": 0, + "max_write_timeout": 0}, + "retry_config": {"retry_enabled": true, "max_retries": 3, + "retry_interval": 100}} + +- tool (PREFERRED for external actions when listed in Available tools): + {"provider_id": "", # provider portion of provider/tool + "provider_type": "builtin", # exact value from catalogue + "provider_name": "", # usually same as provider_id + "tool_name": "", # tool portion of provider/tool + "tool_label": "", + "tool_node_version": "2", + "tool_configurations": {}, + "tool_parameters": {"": {"type": "mixed", + "value": "{{#.#}}"}}} + Parameter ``type`` is one of: + "mixed" — string template referencing variables ({{#...#}}) + "variable" — direct reference, value is ["", ""] + "constant" — literal value + +- if-else: + {"_targetBranches": [{"id": "true", "name": "IF"}, + {"id": "false", "name": "ELSE"}], + "logical_operator": "and", + "cases": [ + {"case_id": "true", + "logical_operator": "and", + "conditions": [{"id": "c1", + "variable_selector": ["", ""], + "comparison_operator": "is", + "value": ""}]} + ]} + Source handle for downstream edges = the case_id ("true" / "false"). + +- question-classifier: + {"query_variable_selector": ["", ""], + "model": {"provider": "

", "name": "", "mode": "chat", + "completion_params": {"temperature": 0.7}}, + "classes": [{"id": "1", "name": "Topic A", "label": "CLASS 1"}, + {"id": "2", "name": "Topic B", "label": "CLASS 2"}], + "_targetBranches": [{"id": "1", "name": ""}, {"id": "2", "name": ""}], + "vision": {"enabled": false}, + "instruction": ""} + Source handle for downstream edges = the class_id ("1" / "2" / ...). + +- parameter-extractor: + {"query": [["", ""]], # array of value_selector arrays + "model": {"provider": "

", "name": "", "mode": "chat", + "completion_params": {"temperature": 0.7}}, + "parameters": [{"name": "topic", "type": "string", + "description": "", "required": true}], + "reasoning_mode": "prompt", + "vision": {"enabled": false}, + "instruction": ""} + +- document-extractor: + {"variable_selector": ["", ""], # a file / file-list input + "is_array_file": false} # true when the input is a + # file-list (array of files) + Single output variable ``text``: a string when ``is_array_file`` is false, + an array of strings (one per file) when it is true. ``variable_selector`` + MUST point at a ``start`` variable declared with type "file" / "file-list" + (or ``sys.files`` in Advanced-Chat mode). + +- variable-aggregator (merge mutually-exclusive branches into one output): + {"output_type": "string", # VarType of the merged value — one of + # string | number | object | array[string] | + # array[number] | array[object] | file | + # array[file] | any. Match the branch vars. + "variables": [["", ""], + ["", ""]]} + Output variable: ``output`` (the first branch that actually ran). Place it + after an ``if-else`` / ``question-classifier`` to rejoin paths before the + ``end`` / ``answer`` node. Each entry of ``variables`` is a value_selector + array, NOT a placeholder string. + +- list-operator (filter / sort / slice an array variable): + {"variable": ["", ""], + "filter_by": {"enabled": false, "conditions": []}, + "extract_by": {"enabled": false, "serial": "1"}, + "order_by": {"enabled": false, "key": "", "value": "asc"}, + "limit": {"enabled": false, "size": 10}} + Enable only the sub-features you need; ``conditions`` reuse the if-else + condition shape (key / comparison_operator / value). Outputs: ``result`` + (the processed array), ``first_record``, ``last_record``. + +## Containers — iteration / loop + +These are SUBGRAPH nodes. To use one you MUST emit, in order: + +1. The container node itself, e.g. for iteration: + id: "nodeK" + type: "custom" + data: {"type": "iteration", + "title": "

+ +
+ ), + data: { command: 'create.open', args: { mode: opt.mode } }, + })) + }, + + register() { + registerCommands({ + 'create.open': async (args) => { + const mode: WorkflowGeneratorMode = (args?.mode ?? 'workflow') as WorkflowGeneratorMode + + // If a graph-based Studio app is open and its mode matches the picked + // mode, thread it through so the modal can offer "Apply to current + // draft". A mode mismatch (or no app open) falls back to new-app only, + // mirroring the precondition the modal uses for canApplyToCurrent. + const appDetail = useAppStore.getState().appDetail + const currentAppMode: WorkflowGeneratorMode | null + = appDetail?.mode === AppModeEnum.WORKFLOW + ? 'workflow' + : appDetail?.mode === AppModeEnum.ADVANCED_CHAT + ? 'advanced-chat' + : null + + if (appDetail && currentAppMode === mode) { + useWorkflowGeneratorStore.getState().openGenerator({ + mode, + currentAppId: appDetail.id, + currentAppMode, + }) + return + } + + useWorkflowGeneratorStore.getState().openGenerator({ mode }) + }, + }) + }, + + unregister() { + unregisterCommands(['create.open']) + }, +} diff --git a/web/app/components/goto-anything/actions/commands/refine.tsx b/web/app/components/goto-anything/actions/commands/refine.tsx new file mode 100644 index 0000000000..b8a455290f --- /dev/null +++ b/web/app/components/goto-anything/actions/commands/refine.tsx @@ -0,0 +1,91 @@ +import type { SlashCommandHandler } from './types' +import type { WorkflowGeneratorMode } from '@/app/components/workflow/workflow-generator/types' +import { RiSparkling2Line } from '@remixicon/react' +import * as React from 'react' +import { getI18n } from 'react-i18next' +import { useStore as useAppStore } from '@/app/components/app/store' +import { useWorkflowGeneratorStore } from '@/app/components/workflow/workflow-generator/store' +import { AppModeEnum } from '@/types/app' +import { registerCommands, unregisterCommands } from './command-bus' + +/** + * Map the open app's mode to a generator mode, or null when the current app + * isn't a graph-based Studio (Chat / Agent / Completion have no canvas to + * refine). This is the single source of truth for both the availability gate + * and the mode we open the generator with. + */ +const currentStudioMode = (): WorkflowGeneratorMode | null => { + const appMode = useAppStore.getState().appDetail?.mode + if (appMode === AppModeEnum.WORKFLOW) + return 'workflow' + if (appMode === AppModeEnum.ADVANCED_CHAT) + return 'advanced-chat' + return null +} + +/** + * Open the generator in `refine` intent for the current Studio. The modal + * fetches the current draft graph and sends it as context so the LLM amends + * what's on the canvas instead of starting from scratch. No-op when there's + * no graph-based app open (the command is gated by `isAvailable`, but the + * guard keeps a stray command-bus call safe). + */ +const openRefineGenerator = () => { + const appDetail = useAppStore.getState().appDetail + const mode = currentStudioMode() + if (!appDetail || !mode) + return + useWorkflowGeneratorStore.getState().openGenerator({ + intent: 'refine', + mode, + currentAppId: appDetail.id, + currentAppMode: mode, + }) +} + +/** + * `/refine` command — refine the CURRENT Workflow / Chatflow draft graph from + * a natural-language change description. Only available inside a graph-based + * Studio; the mode is taken from the open app (no submenu to pick), and the + * result always applies back to the current draft. + */ +export const refineCommand: SlashCommandHandler = { + name: 'refine', + aliases: ['improve'], + // Fallback only — the palette localises the root row via the slashKeyMap in + // command-selector.tsx (gotoAnything.actions.refineCategoryDesc). + description: 'Refine the current workflow or chatflow graph', + mode: 'direct', + + // Only surface inside a Workflow / Advanced-Chat Studio — elsewhere there's + // no draft graph to refine. + isAvailable: () => currentStudioMode() !== null, + + execute: openRefineGenerator, + + async search(_args: string, locale?: string) { + const i18n = getI18n() + return [{ + id: 'refine-current', + title: i18n.t('gotoAnything.actions.refineTitle', { ns: 'app', lng: locale }), + description: i18n.t('gotoAnything.actions.refineDesc', { ns: 'app', lng: locale }), + type: 'command' as const, + icon: ( +
+ +
+ ), + data: { command: 'refine.open', args: {} }, + }] + }, + + register() { + registerCommands({ + 'refine.open': async () => openRefineGenerator(), + }) + }, + + unregister() { + unregisterCommands(['refine.open']) + }, +} diff --git a/web/app/components/goto-anything/actions/commands/slash.tsx b/web/app/components/goto-anything/actions/commands/slash.tsx index 20584eef23..0f8bc0af26 100644 --- a/web/app/components/goto-anything/actions/commands/slash.tsx +++ b/web/app/components/goto-anything/actions/commands/slash.tsx @@ -7,10 +7,12 @@ import { setLocaleOnClient } from '@/i18n-config' import { accountCommand } from './account' import { executeCommand } from './command-bus' import { communityCommand } from './community' +import { createCommand } from './create' import { docsCommand } from './docs' import { forumCommand } from './forum' import { goCommand } from './go' import { languageCommand } from './language' +import { refineCommand } from './refine' import { slashCommandRegistry } from './registry' import { themeCommand } from './theme' import { zenCommand } from './zen' @@ -50,6 +52,8 @@ const registerSlashCommands = (deps: Record) => { slashCommandRegistry.register(accountCommand, {}) slashCommandRegistry.register(zenCommand, {}) slashCommandRegistry.register(goCommand, {}) + slashCommandRegistry.register(createCommand, {}) + slashCommandRegistry.register(refineCommand, {}) } const unregisterSlashCommands = () => { @@ -62,6 +66,8 @@ const unregisterSlashCommands = () => { slashCommandRegistry.unregister('account') slashCommandRegistry.unregister('zen') slashCommandRegistry.unregister('go') + slashCommandRegistry.unregister('create') + slashCommandRegistry.unregister('refine') } export const SlashCommandProvider = () => { diff --git a/web/app/components/goto-anything/command-selector.tsx b/web/app/components/goto-anything/command-selector.tsx index 93430eafc4..3cadfd742d 100644 --- a/web/app/components/goto-anything/command-selector.tsx +++ b/web/app/components/goto-anything/command-selector.tsx @@ -109,6 +109,8 @@ const CommandSelector: FC = ({ actions, onCommandSelect, searchFilter, co ? ( (() => { const slashKeyMap = { + '/create': 'gotoAnything.actions.createCategoryDesc', + '/refine': 'gotoAnything.actions.refineCategoryDesc', '/theme': 'gotoAnything.actions.themeCategoryDesc', '/language': 'gotoAnything.actions.languageChangeDesc', '/account': 'gotoAnything.actions.accountDesc', diff --git a/web/app/components/workflow/nodes/_base/components/file-upload-setting.tsx b/web/app/components/workflow/nodes/_base/components/file-upload-setting.tsx index 93a96aabd9..df662dc3f8 100644 --- a/web/app/components/workflow/nodes/_base/components/file-upload-setting.tsx +++ b/web/app/components/workflow/nodes/_base/components/file-upload-setting.tsx @@ -33,10 +33,10 @@ const FileUploadSetting: FC = ({ const { t } = useTranslation() const { - allowed_file_upload_methods, + allowed_file_upload_methods = [], max_length, - allowed_file_types, - allowed_file_extensions, + allowed_file_types = [], + allowed_file_extensions = [], } = payload const { data: fileUploadConfigResponse } = useFileUploadConfig() const { diff --git a/web/app/components/workflow/workflow-generator/__tests__/apply.spec.ts b/web/app/components/workflow/workflow-generator/__tests__/apply.spec.ts new file mode 100644 index 0000000000..e4860bffde --- /dev/null +++ b/web/app/components/workflow/workflow-generator/__tests__/apply.spec.ts @@ -0,0 +1,265 @@ +import type { GeneratedGraph } from '../types' +import { AppModeEnum } from '@/types/app' +import { applyToCurrentApp, applyToNewApp, WorkflowApplyHashCollisionError, WorkflowApplyOrphanError } from '../apply' + +// Stub the service calls so each test can assert what was POSTed without +// touching real fetch / next router state. +const mockCreateApp = vi.fn() +const mockSyncWorkflowDraft = vi.fn() +const mockFetchWorkflowDraft = vi.fn() +const mockDeleteApp = vi.fn() + +vi.mock('@/service/apps', () => ({ + createApp: (params: unknown) => mockCreateApp(params), + deleteApp: (appId: string) => mockDeleteApp(appId), +})) + +vi.mock('@/service/workflow', () => ({ + fetchWorkflowDraft: (url: string) => mockFetchWorkflowDraft(url), + syncWorkflowDraft: (params: unknown) => mockSyncWorkflowDraft(params), +})) + +const makeGraph = (): GeneratedGraph => ({ + nodes: [ + { id: 'node-1', type: 'custom', position: { x: 0, y: 0 }, data: { type: 'start', title: 'Start' } } as never, + ], + edges: [], + viewport: { x: 0, y: 0, zoom: 0.7 }, +}) + +describe('applyToNewApp', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCreateApp.mockResolvedValue({ id: 'new-app-1', mode: AppModeEnum.WORKFLOW }) + mockSyncWorkflowDraft.mockResolvedValue({}) + }) + + // The new-app path must create the app, then sync the generated graph to + // its draft and return the routing context the caller uses to navigate. + it('should create the app, sync the draft and return the new app id and mode', async () => { + const graph = makeGraph() + const result = await applyToNewApp({ mode: 'workflow', graph, instruction: 'Summarize a URL' }) + + expect(mockCreateApp).toHaveBeenCalledWith(expect.objectContaining({ + mode: AppModeEnum.WORKFLOW, + icon_type: 'emoji', + })) + expect(mockSyncWorkflowDraft).toHaveBeenCalledWith({ + url: 'apps/new-app-1/workflows/draft', + params: { + graph, + features: {}, + environment_variables: [], + conversation_variables: [], + }, + }) + expect(result).toEqual({ appId: 'new-app-1', appMode: AppModeEnum.WORKFLOW }) + }) + + // Mode → AppModeEnum must round-trip for chatflow; the type-level guarantee + // is verified at runtime so a regression here is caught before users hit it. + it('should map advanced-chat mode to AppModeEnum.ADVANCED_CHAT', async () => { + mockCreateApp.mockResolvedValueOnce({ id: 'cf-1', mode: AppModeEnum.ADVANCED_CHAT }) + + const result = await applyToNewApp({ + mode: 'advanced-chat', + graph: makeGraph(), + instruction: 'A chat bot that answers questions', + }) + + expect(mockCreateApp).toHaveBeenCalledWith(expect.objectContaining({ mode: AppModeEnum.ADVANCED_CHAT })) + expect(result.appMode).toBe(AppModeEnum.ADVANCED_CHAT) + }) + + // The derived name keeps the user instruction recognisable in the apps list + // — strip trailing punctuation and never produce an empty string. + it('should derive a sensible app name from the instruction', async () => { + await applyToNewApp({ mode: 'workflow', graph: makeGraph(), instruction: ' Build a translator. ' }) + + expect(mockCreateApp).toHaveBeenCalledWith(expect.objectContaining({ name: 'Build a translator' })) + }) + + // Instruction-only-of-punctuation must still produce a usable, non-empty + // app name so create-app doesn't fail validation. + it('should fall back to "Generated Workflow" when the instruction is empty', async () => { + await applyToNewApp({ mode: 'workflow', graph: makeGraph(), instruction: ' ' }) + + expect(mockCreateApp).toHaveBeenCalledWith(expect.objectContaining({ name: 'Generated Workflow' })) + }) + + // When the planner picks a name + emoji, those win over the + // instruction-derived fallback so users see a real product name in the + // apps list (e.g. "URL Summarizer" + 📰 instead of "Summarize a URL" + 🤖). + it('should prefer planner-supplied app_name and icon over the fallbacks', async () => { + await applyToNewApp({ + mode: 'workflow', + graph: makeGraph(), + instruction: 'Summarize a URL', + appName: 'URL Summarizer', + icon: '📰', + }) + + expect(mockCreateApp).toHaveBeenCalledWith(expect.objectContaining({ + name: 'URL Summarizer', + icon: '📰', + })) + }) + + // When the planner returns whitespace-only values (older prompts / model + // drift), the fallbacks must kick in so we never POST an empty string to + // createApp. + it('should fall back when planner-supplied app_name / icon are blank', async () => { + await applyToNewApp({ + mode: 'workflow', + graph: makeGraph(), + instruction: 'Summarize a URL', + appName: ' ', + icon: '', + }) + + expect(mockCreateApp).toHaveBeenCalledWith(expect.objectContaining({ + name: 'Summarize a URL', + icon: '🤖', + })) + }) + + // Sync failure must roll back the createApp so the user isn't left with an + // empty app in their /apps list. deleteApp is called with the new app id, + // and the original sync error is re-thrown so the caller can toast it. + it('should delete the new app when syncWorkflowDraft fails', async () => { + mockCreateApp.mockResolvedValueOnce({ id: 'doomed', mode: AppModeEnum.WORKFLOW }) + const syncErr = new Error('sync exploded') + mockSyncWorkflowDraft.mockRejectedValueOnce(syncErr) + mockDeleteApp.mockResolvedValueOnce(undefined) + + await expect(applyToNewApp({ + mode: 'workflow', + graph: makeGraph(), + instruction: 'x', + })).rejects.toBe(syncErr) + + expect(mockDeleteApp).toHaveBeenCalledWith('doomed') + }) + + // The truly stuck path: sync fails AND the rollback delete also fails. We + // throw WorkflowApplyOrphanError so the caller can route to /apps where + // the orphan is at least discoverable for manual cleanup. The error + // carries the orphan app id so the toast can name it. + it('should throw WorkflowApplyOrphanError when both sync and rollback fail', async () => { + mockCreateApp.mockResolvedValueOnce({ id: 'orphan-7', mode: AppModeEnum.WORKFLOW }) + mockSyncWorkflowDraft.mockRejectedValueOnce(new Error('sync exploded')) + mockDeleteApp.mockRejectedValueOnce(new Error('delete also exploded')) + + let caught: unknown + try { + await applyToNewApp({ mode: 'workflow', graph: makeGraph(), instruction: 'x' }) + } + catch (e) { + caught = e + } + expect(caught).toBeInstanceOf(WorkflowApplyOrphanError) + expect((caught as WorkflowApplyOrphanError).orphanAppId).toBe('orphan-7') + }) +}) + +describe('applyToCurrentApp', () => { + beforeEach(() => { + vi.clearAllMocks() + mockSyncWorkflowDraft.mockResolvedValue({}) + }) + + // Happy path: the fetch yields an existing draft so the sync MUST include + // its hash. Without this, the backend rejects the write with + // WorkflowHashNotEqualError (the original bug behind the manual fix). + it('should fetch the current draft and forward its hash on sync', async () => { + mockFetchWorkflowDraft.mockResolvedValue({ + hash: 'h-existing', + features: { file_upload: { enabled: true } }, + environment_variables: [{ id: 'e1', name: 'API_KEY', value_type: 'secret', value: 'x' }], + conversation_variables: [{ id: 'c1', name: 'memory', value_type: 'string', value: '' }], + }) + + const graph = makeGraph() + await applyToCurrentApp({ appId: 'app-42', graph }) + + expect(mockFetchWorkflowDraft).toHaveBeenCalledWith('apps/app-42/workflows/draft') + expect(mockSyncWorkflowDraft).toHaveBeenCalledWith({ + url: 'apps/app-42/workflows/draft', + params: expect.objectContaining({ + graph, + features: { file_upload: { enabled: true } }, + hash: 'h-existing', + }), + }) + // Existing env vars and conversation vars must be preserved verbatim. + const params = mockSyncWorkflowDraft.mock.calls[0]![0].params + expect(params.environment_variables).toHaveLength(1) + expect(params.conversation_variables).toHaveLength(1) + }) + + // First-apply path: a freshly created Workflow app has no draft yet, so the + // fetch resolves to undefined and we must sync without a hash field so the + // backend lazy-creates the draft instead of raising. + it('should sync without a hash when no draft yet exists', async () => { + mockFetchWorkflowDraft.mockResolvedValue(undefined) + + await applyToCurrentApp({ appId: 'fresh-app', graph: makeGraph() }) + + expect(mockSyncWorkflowDraft).toHaveBeenCalledTimes(1) + const params = mockSyncWorkflowDraft.mock.calls[0]![0].params + expect(params).not.toHaveProperty('hash') + expect(params.features).toEqual({}) + expect(params.environment_variables).toEqual([]) + expect(params.conversation_variables).toEqual([]) + }) + + // Resilience: a fetch failure (network blip, transient 5xx) must not block + // the apply — fall back to a hashless sync so the new draft can still land. + it('should fall back to a hashless sync when fetchWorkflowDraft throws', async () => { + mockFetchWorkflowDraft.mockRejectedValue(new Error('network down')) + + await applyToCurrentApp({ appId: 'app-7', graph: makeGraph() }) + + expect(mockSyncWorkflowDraft).toHaveBeenCalledTimes(1) + const params = mockSyncWorkflowDraft.mock.calls[0]![0].params + expect(params).not.toHaveProperty('hash') + }) + + // A 409 from syncWorkflowDraft is the backend's signal that another tab + // edited the draft between our fetch and sync. The apply layer translates + // that Response into a typed error so the caller can show a Reload + // affordance instead of a generic "apply failed" toast. + it('should translate a 409 sync rejection into WorkflowApplyHashCollisionError', async () => { + mockFetchWorkflowDraft.mockResolvedValue({ + hash: 'h1', + features: {}, + environment_variables: [], + conversation_variables: [], + }) + // ``base.ts`` rejects with the raw Response on non-401 — fake just the + // ``status`` field, which is what ``isHashCollisionResponse`` consults. + mockSyncWorkflowDraft.mockRejectedValueOnce({ status: 409, code: 'draft_workflow_not_sync' }) + + await expect(applyToCurrentApp({ appId: 'app-9', graph: makeGraph() })) + .rejects + .toBeInstanceOf(WorkflowApplyHashCollisionError) + }) + + // Non-409 errors (5xx, network) MUST NOT be misclassified as hash + // collisions — those still surface as the original rejection so the + // generic "apply failed" toast fires. + it('should NOT translate non-409 sync rejections', async () => { + mockFetchWorkflowDraft.mockResolvedValue({ + hash: 'h1', + features: {}, + environment_variables: [], + conversation_variables: [], + }) + const original = { status: 500, code: 'internal_server_error' } + mockSyncWorkflowDraft.mockRejectedValueOnce(original) + + await expect(applyToCurrentApp({ appId: 'app-9', graph: makeGraph() })) + .rejects + .toBe(original) + }) +}) diff --git a/web/app/components/workflow/workflow-generator/__tests__/example-prompts.spec.tsx b/web/app/components/workflow/workflow-generator/__tests__/example-prompts.spec.tsx new file mode 100644 index 0000000000..aab51324a4 --- /dev/null +++ b/web/app/components/workflow/workflow-generator/__tests__/example-prompts.spec.tsx @@ -0,0 +1,57 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import ExamplePrompts from '../example-prompts' + +describe('ExamplePrompts', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('rendering', () => { + // Workflow mode surfaces a curated 4-prompt set; the count matters + // because the chip row's wrap behaviour was tuned for ≤ 4 entries. + it('should render the 4 workflow-mode prompts', () => { + render() + + expect(screen.getAllByRole('button')).toHaveLength(4) + expect(screen.getByRole('button', { name: /workflowGenerator\.examples\.workflow\.summarize/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /workflowGenerator\.examples\.workflow\.translate/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /workflowGenerator\.examples\.workflow\.rag/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /workflowGenerator\.examples\.workflow\.classify/i })).toBeInTheDocument() + }) + + // Advanced-chat mode surfaces a different (3-prompt) set tailored to + // chatflow patterns. None of the workflow prompts should leak through. + it('should render the 3 chatflow-mode prompts when mode is advanced-chat', () => { + render() + + expect(screen.getAllByRole('button')).toHaveLength(3) + expect(screen.getByRole('button', { name: /workflowGenerator\.examples\.chatflow\.support/i })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /workflowGenerator\.examples\.workflow\.summarize/i })).not.toBeInTheDocument() + }) + + // The "Try one of these" label anchors the row visually; missing it + // would degrade the section to anonymous chips. + it('should render a section label above the chips', () => { + render() + expect(screen.getByText(/workflowGenerator\.examples\.label/i)).toBeInTheDocument() + }) + }) + + describe('selection', () => { + // Clicking a chip is the whole point of the component — it must hand + // the chip text back to the parent verbatim so the parent can populate + // the instruction textarea. + it('should forward the clicked chip\'s text to onSelect', async () => { + const user = userEvent.setup() + const onSelect = vi.fn() + render() + + const chip = screen.getByRole('button', { name: /workflowGenerator\.examples\.workflow\.summarize/i }) + await user.click(chip) + + expect(onSelect).toHaveBeenCalledTimes(1) + expect(onSelect.mock.calls[0]![0]).toMatch(/workflowGenerator\.examples\.workflow\.summarize/i) + }) + }) +}) diff --git a/web/app/components/workflow/workflow-generator/__tests__/generation-phases.spec.tsx b/web/app/components/workflow/workflow-generator/__tests__/generation-phases.spec.tsx new file mode 100644 index 0000000000..8331245c50 --- /dev/null +++ b/web/app/components/workflow/workflow-generator/__tests__/generation-phases.spec.tsx @@ -0,0 +1,92 @@ +import { act, render, screen } from '@testing-library/react' +import GenerationPhases from '../generation-phases' + +describe('GenerationPhases', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + // The first frame the user sees during generation must be the "planning" + // phase — never an empty container or a different phase — so the perceived + // latency starts dropping immediately. + it('should start on the planning phase', () => { + render() + expect(screen.getByText(/phases\.planning/i)).toBeInTheDocument() + }) + + // After the planner timer elapses we move to "building". The component + // doesn't reset to "planning" if the parent stays mounted — the timer + // chain only steps forward. + it('should advance to the building phase after the planning timer', () => { + render() + + act(() => { + vi.advanceTimersByTime(3500) + }) + + expect(screen.getByText(/phases\.building/i)).toBeInTheDocument() + expect(screen.queryByText(/phases\.planning/i)).not.toBeInTheDocument() + }) + + // The validating phase is the last in the schedule; once we get there we + // stay there indefinitely so a slow LLM doesn't make the indicator loop + // backwards and confuse the user. + it('should land on validating and not loop back to planning even after long delays', () => { + render() + + // Advance through phases in two steps — React schedules the next + // ``setTimeout`` only after the prior effect re-runs with the new + // ``phaseIndex``, so a single combined advance leaves us mid-phase. + act(() => { + vi.advanceTimersByTime(3500) + }) + act(() => { + vi.advanceTimersByTime(12000) + }) + expect(screen.getByText(/phases\.validating/i)).toBeInTheDocument() + + act(() => { + vi.advanceTimersByTime(60000) + }) + // Still validating — no reset, no loop. + expect(screen.getByText(/phases\.validating/i)).toBeInTheDocument() + expect(screen.queryByText(/phases\.planning/i)).not.toBeInTheDocument() + }) + + // Unmount cleanup matters because the modal is destroyed when the user + // closes it mid-generation; lingering timers would keep firing setState on + // an unmounted tree. + it('should not leak a timer when unmounted before the next phase fires', () => { + const { unmount } = render() + // Sanity: pending timer should exist. + expect(vi.getTimerCount()).toBeGreaterThan(0) + + unmount() + expect(vi.getTimerCount()).toBe(0) + }) + + // A second Generate click bumps ``startedAt``. The component must reset to + // "Planning" so the indicator doesn't appear wedged on "Validating" from + // the previous attempt. Without this the user thinks the system is stuck. + it('should reset to the planning phase when startedAt changes', () => { + const { rerender } = render() + // Drive the first attempt all the way to validating. + act(() => { + vi.advanceTimersByTime(3500) + }) + act(() => { + vi.advanceTimersByTime(12000) + }) + expect(screen.getByText(/phases\.validating/i)).toBeInTheDocument() + + // New attempt starts → bump startedAt. The component should snap back + // to planning rather than staying on validating. + rerender() + expect(screen.getByText(/phases\.planning/i)).toBeInTheDocument() + expect(screen.queryByText(/phases\.validating/i)).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/workflow/workflow-generator/__tests__/store.spec.ts b/web/app/components/workflow/workflow-generator/__tests__/store.spec.ts new file mode 100644 index 0000000000..336a90820b --- /dev/null +++ b/web/app/components/workflow/workflow-generator/__tests__/store.spec.ts @@ -0,0 +1,155 @@ +import { act, renderHook } from '@testing-library/react' +import { useWorkflowGeneratorStore } from '../store' + +// Reset zustand state between tests so they don't share opener context. +const resetStore = () => { + useWorkflowGeneratorStore.setState({ + isOpen: false, + mode: 'workflow', + currentAppId: null, + currentAppMode: null, + }) +} + +describe('useWorkflowGeneratorStore', () => { + beforeEach(() => { + vi.clearAllMocks() + resetStore() + }) + + describe('initial state', () => { + // Default snapshot: the generator modal is closed, mode is "workflow", and + // there is no current-app context attached. + it('should start closed in workflow mode with no current app', () => { + const { result } = renderHook(() => useWorkflowGeneratorStore()) + + expect(result.current.isOpen).toBe(false) + expect(result.current.mode).toBe('workflow') + expect(result.current.currentAppId).toBeNull() + expect(result.current.currentAppMode).toBeNull() + }) + }) + + describe('openGenerator', () => { + // Opening from a non-Studio surface (e.g. /apps page): only the requested + // mode is set; currentAppId stays null so the modal hides "Apply to current". + it('should open with the requested mode and no current app by default', () => { + const { result } = renderHook(() => useWorkflowGeneratorStore()) + + act(() => { + result.current.openGenerator({ mode: 'advanced-chat' }) + }) + + expect(result.current.isOpen).toBe(true) + expect(result.current.mode).toBe('advanced-chat') + expect(result.current.currentAppId).toBeNull() + expect(result.current.currentAppMode).toBeNull() + }) + + // Opening from inside Studio: caller passes currentAppId + currentAppMode + // so the modal can show "Apply to current draft". + it('should accept a current app id and mode when opened from Studio', () => { + const { result } = renderHook(() => useWorkflowGeneratorStore()) + + act(() => { + result.current.openGenerator({ + mode: 'workflow', + currentAppId: 'app-123', + currentAppMode: 'workflow', + }) + }) + + expect(result.current.isOpen).toBe(true) + expect(result.current.mode).toBe('workflow') + expect(result.current.currentAppId).toBe('app-123') + expect(result.current.currentAppMode).toBe('workflow') + }) + + // Reopening with new parameters must overwrite the previous mode/context; + // stale state would let the modal apply to the wrong app. + it('should overwrite previous state on a subsequent open', () => { + const { result } = renderHook(() => useWorkflowGeneratorStore()) + + act(() => { + result.current.openGenerator({ mode: 'workflow', currentAppId: 'app-1', currentAppMode: 'workflow' }) + }) + act(() => { + result.current.openGenerator({ mode: 'advanced-chat' }) + }) + + expect(result.current.mode).toBe('advanced-chat') + expect(result.current.currentAppId).toBeNull() + expect(result.current.currentAppMode).toBeNull() + }) + }) + + describe('closeGenerator', () => { + // Closing flips isOpen back to false but preserves mode / currentAppId so + // a subsequent reopen can decide whether to keep or replace them. + it('should close the modal without clearing the captured context', () => { + const { result } = renderHook(() => useWorkflowGeneratorStore()) + + act(() => { + result.current.openGenerator({ mode: 'workflow', currentAppId: 'app-9', currentAppMode: 'workflow' }) + }) + act(() => { + result.current.closeGenerator() + }) + + expect(result.current.isOpen).toBe(false) + expect(result.current.mode).toBe('workflow') + expect(result.current.currentAppId).toBe('app-9') + expect(result.current.currentAppMode).toBe('workflow') + }) + }) + + describe('history reset on /create open', () => { + // /create must always start on the empty placeholder — the previous + // session's versions belong to a different intent and confuse the user. + // Without this, opening /create twice in a row would re-show the prior + // generated graph. Studio-refine sessions (with currentAppId) keep + // their history so close+reopen of the toolbar Generate doesn't lose + // the versions the user was comparing. + it('should clear new-app sessionStorage keys when opened without a currentAppId', () => { + sessionStorage.setItem('workflow-gen-workflow-new-versions', JSON.stringify([{ ghost: true }])) + sessionStorage.setItem('workflow-gen-workflow-new-version-index', '3') + + const { result } = renderHook(() => useWorkflowGeneratorStore()) + act(() => { + result.current.openGenerator({ mode: 'workflow' }) + }) + + expect(sessionStorage.getItem('workflow-gen-workflow-new-versions')).toBeNull() + expect(sessionStorage.getItem('workflow-gen-workflow-new-version-index')).toBeNull() + }) + + // Only the mode being opened gets cleared — opening /create for + // workflow must not wipe a parallel advanced-chat /create session in + // another tab's sessionStorage path (we share the same sessionStorage + // namespace per tab, but only the corresponding mode key is wiped). + it('should leave the other mode\'s new-app history alone', () => { + sessionStorage.setItem('workflow-gen-advanced-chat-new-versions', JSON.stringify([{ keep: true }])) + + const { result } = renderHook(() => useWorkflowGeneratorStore()) + act(() => { + result.current.openGenerator({ mode: 'workflow' }) + }) + + expect(sessionStorage.getItem('workflow-gen-advanced-chat-new-versions')).not.toBeNull() + }) + + // Studio refine sessions (currentAppId present) must NOT clear their + // history — the user expects to find their previous versions when they + // reopen the toolbar Generate button. + it('should NOT clear history when opened with a currentAppId', () => { + sessionStorage.setItem('workflow-gen-workflow-app-42-versions', JSON.stringify([{ keep: true }])) + + const { result } = renderHook(() => useWorkflowGeneratorStore()) + act(() => { + result.current.openGenerator({ mode: 'workflow', currentAppId: 'app-42', currentAppMode: 'workflow' }) + }) + + expect(sessionStorage.getItem('workflow-gen-workflow-app-42-versions')).not.toBeNull() + }) + }) +}) diff --git a/web/app/components/workflow/workflow-generator/apply.ts b/web/app/components/workflow/workflow-generator/apply.ts new file mode 100644 index 0000000000..a4e7733551 --- /dev/null +++ b/web/app/components/workflow/workflow-generator/apply.ts @@ -0,0 +1,195 @@ +import type { GeneratedGraph, WorkflowGeneratorMode } from './types' +import { createApp, deleteApp } from '@/service/apps' +import { fetchWorkflowDraft, syncWorkflowDraft } from '@/service/workflow' +import { AppModeEnum } from '@/types/app' + +const MODE_TO_APP_MODE: Record = { + 'workflow': AppModeEnum.WORKFLOW, + 'advanced-chat': AppModeEnum.ADVANCED_CHAT, +} + +/** + * Thrown by ``applyToCurrentApp`` when the backend rejects the sync because + * the draft's ``unique_hash`` doesn't match — typically another tab edited + * the draft after we fetched it. The caller maps this to a dedicated + * "Workspace was edited elsewhere" toast with a Reload affordance instead + * of a generic "Apply failed". + * + * Backend surfaces this as HTTP 409 with ``error_code: + * "draft_workflow_not_sync"`` (see + * ``api/controllers/console/app/error.py::DraftWorkflowNotSync``). + */ +export class WorkflowApplyHashCollisionError extends Error { + constructor() { + super('Workflow draft was modified in another tab; reload required.') + this.name = 'WorkflowApplyHashCollisionError' + } +} + +/** + * Thrown by ``applyToNewApp`` when the freshly-created app's draft sync + * failed AND we couldn't roll back by deleting the new app. The caller + * routes the user to ``/apps`` so the orphan is at least discoverable and + * surfaces a localised toast — without this an unrecoverable orphan would + * silently sit in the app list with no graph. + */ +export class WorkflowApplyOrphanError extends Error { + readonly orphanAppId: string + + constructor(orphanAppId: string, cause?: unknown) { + // ES2022 Error supports ``cause`` natively via the options bag — far + // cleaner than reassigning a typed-cast property after construction. + super(`Failed to apply graph; new app ${orphanAppId} may be orphaned.`, { cause }) + this.name = 'WorkflowApplyOrphanError' + this.orphanAppId = orphanAppId + } +} + +const isHashCollisionResponse = (e: unknown): boolean => { + // The shared ``post()`` wrapper rejects with the raw ``Response`` for non-401 + // failures (see ``service/base.ts::request`` catch branch). At this layer the + // only reliable signal is the HTTP status. + if (!e || typeof e !== 'object') + return false + return (e as { status?: number }).status === 409 +} + +// Derive a sane App name from the user's instruction: trim, cap at 40 chars, +// strip trailing punctuation. +const deriveAppName = (instruction: string): string => { + const trimmed = instruction.trim().slice(0, 40) + return trimmed.replace(/[.,!?;:。,!?;:]+$/, '').trim() || 'Generated Workflow' +} + +type ApplyToNewAppParams = { + mode: WorkflowGeneratorMode + graph: GeneratedGraph + instruction: string + /** + * Planner-picked product-style name (e.g. "URL Summarizer"). When empty, + * we fall back to ``deriveAppName(instruction)`` so the apps list never + * shows an empty title. + */ + appName?: string + /** + * Planner-picked emoji (e.g. "📰"). When empty, we fall back to 🤖 + * which is the historical default. + */ + icon?: string +} + +/** + * Apply path A — create a brand-new Workflow / Chatflow app and write the + * generated graph into its draft. Returns the created app id so the caller + * can route to ``/app/{id}/workflow``. + */ +export const applyToNewApp = async ({ + mode, + graph, + instruction, + appName, + icon, +}: ApplyToNewAppParams): Promise<{ appId: string, appMode: AppModeEnum }> => { + const appMode = MODE_TO_APP_MODE[mode] + const name = (appName ?? '').trim() || deriveAppName(instruction) + const appIcon = (icon ?? '').trim() || '🤖' + const app = await createApp({ + name, + mode: appMode, + icon_type: 'emoji', + icon: appIcon, + icon_background: '#FFEAD5', + description: instruction.trim().slice(0, 200), + }) + + // Sync the generated graph into the brand-new app's draft. ``createApp`` + // already succeeded so the app exists; if the sync fails (network blip, + // backend rejection of the graph) we MUST roll the createApp back so the + // user isn't left with a discoverable-but-empty app sitting at the top + // of their /apps list. ``deleteApp`` is best-effort — if that also fails + // (it usually won't, it's a simple DELETE) we surface ``WorkflowApplyOrphanError`` + // so the caller can route to /apps where the orphan is still recoverable + // by hand. + try { + await syncWorkflowDraft({ + url: `apps/${app.id}/workflows/draft`, + params: { + graph, + features: {}, + environment_variables: [], + conversation_variables: [], + }, + }) + } + catch (syncErr) { + try { + await deleteApp(app.id) + } + catch (deleteErr) { + throw new WorkflowApplyOrphanError(app.id, deleteErr) + } + throw syncErr + } + + return { appId: app.id, appMode } +} + +type ApplyToCurrentAppParams = { + appId: string + graph: GeneratedGraph +} + +/** + * Apply path B — overwrite the current Workflow Studio's draft graph. + * + * The backend's ``sync_draft_workflow`` rejects writes whose ``hash`` doesn't + * match the existing draft's ``unique_hash`` (WorkflowHashNotEqualError), so we + * must read the current draft first to grab its hash. We also preserve the + * existing ``features``, ``environment_variables`` and ``conversation_variables`` + * — only nodes / edges / viewport (the ``graph`` field) get replaced by the + * generated graph. + * + * Caller is responsible for showing the overwrite confirmation dialog before + * invoking this. + */ +export const applyToCurrentApp = async ({ + appId, + graph, +}: ApplyToCurrentAppParams): Promise => { + const url = `apps/${appId}/workflows/draft` + + // First sync may have no existing draft (workflow apps can exist before Studio + // has created/saved a draft). ``fetchWorkflowDraft`` rejects on non-2xx (e.g. + // 404), so we treat any fetch failure as "no existing draft" and sync without + // a hash. + let existing: Awaited> | null = null + try { + existing = await fetchWorkflowDraft(url) + } + catch { + existing = null + } + + try { + await syncWorkflowDraft({ + url, + params: { + graph, + features: existing?.features ?? {}, + environment_variables: existing?.environment_variables ?? [], + conversation_variables: existing?.conversation_variables ?? [], + // Field is accepted by the backend but not typed in the Pick<> shape of + // ``syncWorkflowDraft``'s params — spread it in so it reaches the wire. + ...(existing?.hash ? { hash: existing.hash } : {}), + } as Parameters[0]['params'], + }) + } + catch (e) { + // 409 → draft was edited in another tab between our fetch and sync. + // Translate the raw Response rejection into a typed error so the caller + // can show a Reload affordance instead of a generic "apply failed" toast. + if (isHashCollisionResponse(e)) + throw new WorkflowApplyHashCollisionError() + throw e + } +} diff --git a/web/app/components/workflow/workflow-generator/example-prompts.tsx b/web/app/components/workflow/workflow-generator/example-prompts.tsx new file mode 100644 index 0000000000..e105f98911 --- /dev/null +++ b/web/app/components/workflow/workflow-generator/example-prompts.tsx @@ -0,0 +1,67 @@ +'use client' +import type { WorkflowGeneratorMode } from './types' +import { memo, useMemo } from 'react' +import { useTranslation } from 'react-i18next' + +type Props = { + mode: WorkflowGeneratorMode + onSelect: (prompt: string) => void +} + +/** + * "Try one of these" chips that sit below the instruction textarea. + * + * For brand-new users the blank instruction box is intimidating — they don't + * know what kinds of prompts the planner handles well. The chips give them + * a one-click way to populate a real prompt so they can see the modal end- + * to-end on their first attempt. + * + * The four prompts per mode are intentionally chosen to cover a spread of + * shapes: + * - workflow: summarization, translation, RAG, classification. + * - advanced-chat: support agent, tutor, triage. + * + * The strings live in i18n so they translate alongside the rest of the + * generator UI. + */ +const ExamplePrompts: React.FC = ({ mode, onSelect }) => { + const { t } = useTranslation('workflow') + + const prompts = useMemo(() => { + if (mode === 'workflow') { + return [ + t('workflowGenerator.examples.workflow.summarize'), + t('workflowGenerator.examples.workflow.translate'), + t('workflowGenerator.examples.workflow.rag'), + t('workflowGenerator.examples.workflow.classify'), + ] + } + return [ + t('workflowGenerator.examples.chatflow.support'), + t('workflowGenerator.examples.chatflow.tutor'), + t('workflowGenerator.examples.chatflow.triage'), + ] + }, [mode, t]) + + return ( +
+
+ {t('workflowGenerator.examples.label')} +
+
+ {prompts.map(prompt => ( + + ))} +
+
+ ) +} + +export default memo(ExamplePrompts) diff --git a/web/app/components/workflow/workflow-generator/generation-phases.tsx b/web/app/components/workflow/workflow-generator/generation-phases.tsx new file mode 100644 index 0000000000..f2dd7c0c99 --- /dev/null +++ b/web/app/components/workflow/workflow-generator/generation-phases.tsx @@ -0,0 +1,72 @@ +'use client' +import { memo, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import Loading from '@/app/components/base/loading' + +/** + * Approximate stage durations (ms) for the slim planner→builder pipeline. + * + * The endpoint is single-shot — we don't get real per-phase events from the + * backend — but the user perception of "the system is doing things" is much + * better than a static spinner. The schedule below targets the typical + * 15–18 s response time. If the real response lands earlier the modal + * unmounts this component; if it lands later we hold on the last phase + * indefinitely (rather than cycling back) so the user doesn't think we + * restarted. + */ +const PLANNING_MS = 3500 +const BUILDING_MS = 12000 + +type Props = { + /** + * Per-attempt nonce — typically ``Date.now()`` of when Generate was + * clicked. The component resets ``phaseIndex`` whenever this changes so a + * second Generate click starts the indicator from "Planning…" instead of + * resuming wherever the previous attempt left off. + */ + startedAt: number +} + +const GenerationPhases = ({ startedAt }: Props) => { + const { t } = useTranslation('workflow') + const [phaseIndex, setPhaseIndex] = useState(0) + + // Reset the indicator whenever a new attempt starts. Without this, a + // failed first attempt followed by a quick retry would resume mid-phase + // (or stuck on "Validating…") which looks like the system is wedged. + // ``set-state-in-effect`` flags this pattern, but the reset is the + // intent — driven by an external prop change, not by render-time state. + useEffect(() => { + // eslint-disable-next-line react/set-state-in-effect + setPhaseIndex(0) + }, [startedAt]) + + useEffect(() => { + if (phaseIndex === 0) { + const timer = setTimeout(() => setPhaseIndex(1), PLANNING_MS) + return () => clearTimeout(timer) + } + if (phaseIndex === 1) { + const timer = setTimeout(() => setPhaseIndex(2), BUILDING_MS) + return () => clearTimeout(timer) + } + // phaseIndex === 2 — terminal phase, no further timer. + }, [phaseIndex]) + + const label = (() => { + if (phaseIndex === 0) + return t('workflowGenerator.phases.planning') + if (phaseIndex === 1) + return t('workflowGenerator.phases.building') + return t('workflowGenerator.phases.validating') + })() + + return ( +
+ +
{label}
+
+ ) +} + +export default memo(GenerationPhases) diff --git a/web/app/components/workflow/workflow-generator/index.tsx b/web/app/components/workflow/workflow-generator/index.tsx new file mode 100644 index 0000000000..94ea3e8d78 --- /dev/null +++ b/web/app/components/workflow/workflow-generator/index.tsx @@ -0,0 +1,592 @@ +'use client' +import type { GeneratedGraph } from './types' +import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { CompletionParams, Model, 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 { useBoolean } from 'ahooks' +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 { useLocalStorage } from '@/hooks/use-local-storage' +import { useRouter } from '@/next/navigation' +import { generateWorkflow } 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 GenerationPhases from './generation-phases' +import { useWorkflowGeneratorStore } from './store' +import useGenGraph from './use-gen-graph' + +const STORAGE_MODEL_KEY = 'workflow-gen-model' +const FE_TIMEOUT_MS = 60_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: 'chat' as unknown as 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. + 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 + } + } + + 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 + } + 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')} +
+