mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
The App Builder composer's "Add missing details" link needs a backend. It fires in the pre-creation composer, so there is no app, no draft workflow and no Builder session yet -- it is tenant-scoped only, which puts it beside /workflow-generate/suggestions rather than under console/dify_builder. POST /console/api/workflow-generate/improve expands a short build instruction into one that states what a builder would otherwise have to ask about: the trigger and inputs, the steps, the rules that decide between outcomes, the output and its destination, and the failure path. Soft-fail like suggestions: no default model, an LLM error, an echo of the input, or a rewrite shorter than the input all return "" from the generator, and the route answers 200 with the caller's own text and changed=false. The action can never strand what the user typed, so there is deliberately no 402/429 mapping. Deliberately not grounded in the tenant's tools or knowledge bases. Naming tools here would prejudice the workflow generator's own tool router, which selects from the full installed catalogue against the instruction it is actually given -- and it keeps the endpoint free of DB and plugin-daemon calls. The boundary guard is split out rather than shared: _workflow_instruction_guard names ideal_output in its wording, which this payload does not carry. The existing endpoints' error strings stay byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
740 lines
31 KiB
Python
740 lines
31 KiB
Python
import json
|
|
from collections.abc import Generator, Sequence
|
|
from typing import Any, Literal
|
|
|
|
from flask_restx import Resource
|
|
from pydantic import BaseModel, ConfigDict, Field, RootModel
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from controllers.common.fields import SimpleDataResponse
|
|
from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models
|
|
from controllers.console import console_ns
|
|
from controllers.console.app.error import (
|
|
CompletionRequestError,
|
|
ProviderModelCurrentlyNotSupportError,
|
|
ProviderNotInitializeError,
|
|
ProviderQuotaExceededError,
|
|
)
|
|
from controllers.console.app.wraps import with_session
|
|
from controllers.console.wraps import (
|
|
account_initialization_required,
|
|
model_validate,
|
|
setup_required,
|
|
with_current_tenant_id,
|
|
)
|
|
from core.app.app_config.entities import ModelConfig
|
|
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
|
from core.helper.code_executor.code_node_provider import CodeNodeProvider
|
|
from core.helper.code_executor.javascript.javascript_code_provider import JavascriptCodeProvider
|
|
from core.helper.code_executor.python3.python3_code_provider import Python3CodeProvider
|
|
from core.llm_generator.entities import RuleCodeGeneratePayload, RuleGeneratePayload, RuleStructuredOutputPayload
|
|
from core.llm_generator.llm_generator import LLMGenerator
|
|
from core.workflow.generator.types import WorkflowGenerateErrorCode
|
|
from fields.base import ResponseModel
|
|
from graphon.model_runtime.entities.llm_entities import LLMMode
|
|
from graphon.model_runtime.errors.invoke import InvokeError
|
|
from libs.helper import compact_generate_response, dump_response
|
|
from libs.login import login_required
|
|
from models import App
|
|
from services.workflow_generator_service import WorkflowGeneratorService
|
|
from services.workflow_service import WorkflowService
|
|
|
|
|
|
class InstructionGeneratePayload(BaseModel):
|
|
flow_id: str = Field(..., description="Workflow/Flow ID")
|
|
node_id: str = Field(default="", description="Node ID for workflow context")
|
|
current: str = Field(default="", description="Current instruction text")
|
|
language: str = Field(default="javascript", description="Programming language (javascript/python)")
|
|
instruction: str = Field(..., description="Instruction for generation")
|
|
model_config_data: ModelConfig = Field(
|
|
...,
|
|
alias="model_config",
|
|
description="Model configuration",
|
|
)
|
|
ideal_output: str = Field(default="", description="Expected ideal output")
|
|
|
|
|
|
class InstructionTemplatePayload(BaseModel):
|
|
type: str = Field(..., description="Instruction template type")
|
|
|
|
|
|
# Upper bound for the generator's free-text inputs. Generous for prose (a
|
|
# detailed instruction rarely passes 2k chars) while keeping the
|
|
# planner+builder prompts well inside every mainstream context window.
|
|
# Mirrored by the ``maxLength`` on the frontend generator textarea.
|
|
_MAX_INSTRUCTION_LENGTH = 10_000
|
|
|
|
|
|
class WorkflowGraphPosition(BaseModel):
|
|
x: float
|
|
y: float
|
|
|
|
|
|
class WorkflowGraphViewport(WorkflowGraphPosition):
|
|
zoom: float
|
|
|
|
|
|
class WorkflowGraphNode(BaseModel):
|
|
"""React Flow node shape accepted and returned by the generator.
|
|
|
|
Node-specific configuration lives under ``data`` and wrapper metadata
|
|
differs for container children, so unknown wrapper fields must survive
|
|
request validation and response serialization.
|
|
"""
|
|
|
|
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
|
|
id: str
|
|
type: str
|
|
position: WorkflowGraphPosition
|
|
data: dict[str, Any]
|
|
|
|
|
|
class WorkflowGraphEdge(BaseModel):
|
|
"""React Flow edge shape with extensible renderer metadata."""
|
|
|
|
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
|
|
id: str
|
|
source: str
|
|
target: str
|
|
type: str
|
|
|
|
|
|
class WorkflowGraph(BaseModel):
|
|
nodes: list[WorkflowGraphNode]
|
|
edges: list[WorkflowGraphEdge]
|
|
viewport: WorkflowGraphViewport
|
|
|
|
|
|
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", "auto"] = Field(
|
|
...,
|
|
description="Target app mode for the generated graph; 'auto' lets the backend classify the instruction",
|
|
)
|
|
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: WorkflowGraph | None = Field(
|
|
default=None,
|
|
description="Existing draft graph to refine (cmd+k `/refine`); omit for create-from-scratch",
|
|
)
|
|
|
|
|
|
class WorkflowInstructionSuggestionsPayload(BaseModel):
|
|
"""Payload for the workflow-generator instruction-suggestions endpoint.
|
|
|
|
Runs before the user picks a model, so the suggestions come from the
|
|
tenant's default model. The underlying generator never raises — an empty
|
|
``suggestions`` list is a valid 200 (soft-fail).
|
|
"""
|
|
|
|
mode: Literal["workflow", "advanced-chat"] = Field(..., description="Target app mode for the suggestions")
|
|
language: str | None = Field(default=None, description="Optional language to write the suggestions in")
|
|
count: int = Field(default=4, ge=1, le=6, description="Number of suggestions to return (1-6)")
|
|
|
|
|
|
class WorkflowInstructionImprovePayload(BaseModel):
|
|
"""Payload for the workflow-generator instruction-improvement endpoint.
|
|
|
|
Backs the composer's "Add missing details" action. It runs before an app
|
|
exists and before the user picks a model, so the rewrite comes from the
|
|
tenant's default model. The underlying generator never raises -- the
|
|
original text with ``changed: false`` is a valid 200 (soft-fail).
|
|
"""
|
|
|
|
instruction: str = Field(..., description="The build instruction to expand")
|
|
mode: Literal["workflow", "advanced-chat"] = Field(
|
|
default="workflow", description="Target app mode the instruction is written for"
|
|
)
|
|
language: str | None = Field(default=None, description="Optional language to write the rewrite in")
|
|
|
|
|
|
class WorkflowGenerateErrorResponse(ResponseModel):
|
|
code: WorkflowGenerateErrorCode
|
|
detail: str
|
|
node_id: str | None = None
|
|
|
|
|
|
class WorkflowGenerateResponse(ResponseModel):
|
|
graph: WorkflowGraph
|
|
message: str = ""
|
|
app_name: str = ""
|
|
icon: str = ""
|
|
error: str = ""
|
|
errors: list[WorkflowGenerateErrorResponse] = Field(default_factory=list)
|
|
mode: Literal["workflow", "advanced-chat"] | None = None
|
|
|
|
|
|
class WorkflowPlanNodeResponse(ResponseModel):
|
|
label: str
|
|
node_type: str
|
|
purpose: str = ""
|
|
|
|
|
|
class WorkflowPlanStartInputResponse(ResponseModel):
|
|
variable: str
|
|
label: str = ""
|
|
type: str = ""
|
|
|
|
|
|
class WorkflowGeneratePlanEventResponse(ResponseModel):
|
|
event: Literal["plan"] = "plan"
|
|
title: str = ""
|
|
description: str = ""
|
|
app_name: str = ""
|
|
icon: str = ""
|
|
mode: Literal["workflow", "advanced-chat"]
|
|
nodes: list[WorkflowPlanNodeResponse]
|
|
start_inputs: list[WorkflowPlanStartInputResponse] = Field(default_factory=list)
|
|
|
|
|
|
class WorkflowGenerateResultEventResponse(WorkflowGenerateResponse):
|
|
event: Literal["result"] = "result"
|
|
|
|
|
|
class WorkflowGenerateStreamEventResponse(
|
|
RootModel[WorkflowGeneratePlanEventResponse | WorkflowGenerateResultEventResponse]
|
|
):
|
|
"""Schema for each JSON object carried by an SSE ``data:`` frame."""
|
|
|
|
|
|
class WorkflowInstructionSuggestionsResponse(ResponseModel):
|
|
suggestions: list[str]
|
|
|
|
|
|
class WorkflowInstructionImproveResponse(ResponseModel):
|
|
"""``instruction`` is always usable: the rewrite, or the caller's own text.
|
|
|
|
``changed`` is what distinguishes the two, so a client can tell an
|
|
improvement from a soft failure without diffing the strings itself.
|
|
"""
|
|
|
|
instruction: str
|
|
changed: bool
|
|
|
|
|
|
class GeneratorResponse(RootModel[Any]):
|
|
root: Any
|
|
|
|
|
|
register_enum_models(console_ns, LLMMode)
|
|
register_schema_models(
|
|
console_ns,
|
|
RuleGeneratePayload,
|
|
RuleCodeGeneratePayload,
|
|
RuleStructuredOutputPayload,
|
|
InstructionGeneratePayload,
|
|
InstructionTemplatePayload,
|
|
WorkflowGeneratePayload,
|
|
WorkflowInstructionSuggestionsPayload,
|
|
WorkflowInstructionImprovePayload,
|
|
ModelConfig,
|
|
)
|
|
register_response_schema_models(
|
|
console_ns,
|
|
GeneratorResponse,
|
|
SimpleDataResponse,
|
|
WorkflowGenerateResponse,
|
|
WorkflowGeneratePlanEventResponse,
|
|
WorkflowGenerateResultEventResponse,
|
|
WorkflowGenerateStreamEventResponse,
|
|
WorkflowInstructionSuggestionsResponse,
|
|
WorkflowInstructionImproveResponse,
|
|
)
|
|
|
|
|
|
@console_ns.route("/rule-generate")
|
|
class RuleGenerateApi(Resource):
|
|
@console_ns.doc("generate_rule_config")
|
|
@console_ns.doc(description="Generate rule configuration using LLM")
|
|
@console_ns.expect(console_ns.models[RuleGeneratePayload.__name__])
|
|
@console_ns.response(
|
|
200,
|
|
"Rule configuration generated successfully",
|
|
console_ns.models[GeneratorResponse.__name__],
|
|
)
|
|
@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
|
|
@model_validate(RuleGeneratePayload)
|
|
def post(self, req_data: RuleGeneratePayload, current_tenant_id: str):
|
|
|
|
try:
|
|
rules = LLMGenerator.generate_rule_config(tenant_id=current_tenant_id, args=req_data)
|
|
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 rules
|
|
|
|
|
|
@console_ns.route("/rule-code-generate")
|
|
class RuleCodeGenerateApi(Resource):
|
|
@console_ns.doc("generate_rule_code")
|
|
@console_ns.doc(description="Generate code rules using LLM")
|
|
@console_ns.expect(console_ns.models[RuleCodeGeneratePayload.__name__])
|
|
@console_ns.response(200, "Code rules generated successfully", console_ns.models[GeneratorResponse.__name__])
|
|
@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
|
|
@model_validate(RuleCodeGeneratePayload)
|
|
def post(self, req_data: RuleCodeGeneratePayload, current_tenant_id: str):
|
|
|
|
try:
|
|
code_result = LLMGenerator.generate_code(
|
|
tenant_id=current_tenant_id,
|
|
args=req_data,
|
|
)
|
|
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 code_result
|
|
|
|
|
|
@console_ns.route("/rule-structured-output-generate")
|
|
class RuleStructuredOutputGenerateApi(Resource):
|
|
@console_ns.doc("generate_structured_output")
|
|
@console_ns.doc(description="Generate structured output rules using LLM")
|
|
@console_ns.expect(console_ns.models[RuleStructuredOutputPayload.__name__])
|
|
@console_ns.response(200, "Structured output generated successfully", console_ns.models[GeneratorResponse.__name__])
|
|
@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
|
|
@model_validate(RuleStructuredOutputPayload)
|
|
def post(self, req_data: RuleStructuredOutputPayload, current_tenant_id: str):
|
|
|
|
try:
|
|
structured_output = LLMGenerator.generate_structured_output(
|
|
tenant_id=current_tenant_id,
|
|
args=req_data,
|
|
)
|
|
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 structured_output
|
|
|
|
|
|
@console_ns.route("/instruction-generate")
|
|
class InstructionGenerateApi(Resource):
|
|
@console_ns.doc("generate_instruction")
|
|
@console_ns.doc(description="Generate instruction for workflow nodes or general use")
|
|
@console_ns.expect(console_ns.models[InstructionGeneratePayload.__name__])
|
|
@console_ns.response(200, "Instruction generated successfully", console_ns.models[GeneratorResponse.__name__])
|
|
@console_ns.response(400, "Invalid request parameters or flow/workflow not found")
|
|
@console_ns.response(402, "Provider quota exceeded")
|
|
@setup_required
|
|
@login_required
|
|
@account_initialization_required
|
|
@with_current_tenant_id
|
|
@with_session(write=False)
|
|
@model_validate(InstructionGeneratePayload)
|
|
def post(self, req_data: InstructionGeneratePayload, session: Session, current_tenant_id: str):
|
|
providers: list[type[CodeNodeProvider]] = [Python3CodeProvider, JavascriptCodeProvider]
|
|
code_provider: type[CodeNodeProvider] | None = next(
|
|
(p for p in providers if p.is_accept_language(req_data.language)), None
|
|
)
|
|
code_template = code_provider.get_default_code() if code_provider else ""
|
|
try:
|
|
# Generate from nothing for a workflow node
|
|
if (req_data.current in (code_template, "")) and req_data.node_id != "":
|
|
app = session.scalar(
|
|
select(App).where(App.id == req_data.flow_id, App.tenant_id == current_tenant_id).limit(1)
|
|
)
|
|
if not app:
|
|
return {"error": f"app {req_data.flow_id} not found"}, 400
|
|
workflow = WorkflowService().get_draft_workflow(app_model=app, session=session)
|
|
if not workflow:
|
|
return {"error": f"workflow {req_data.flow_id} not found"}, 400
|
|
nodes: Sequence = workflow.graph_dict["nodes"]
|
|
node = [node for node in nodes if node["id"] == req_data.node_id]
|
|
if len(node) == 0:
|
|
return {"error": f"node {req_data.node_id} not found"}, 400
|
|
node_type = node[0]["data"]["type"]
|
|
match node_type:
|
|
case "llm":
|
|
return LLMGenerator.generate_rule_config(
|
|
current_tenant_id,
|
|
app_id=req_data.flow_id,
|
|
args=RuleGeneratePayload(
|
|
instruction=req_data.instruction,
|
|
model_config=req_data.model_config_data,
|
|
no_variable=True,
|
|
),
|
|
)
|
|
case "agent":
|
|
return LLMGenerator.generate_rule_config(
|
|
current_tenant_id,
|
|
app_id=req_data.flow_id,
|
|
args=RuleGeneratePayload(
|
|
instruction=req_data.instruction,
|
|
model_config=req_data.model_config_data,
|
|
no_variable=True,
|
|
),
|
|
)
|
|
case "code":
|
|
return LLMGenerator.generate_code(
|
|
tenant_id=current_tenant_id,
|
|
app_id=req_data.flow_id,
|
|
args=RuleCodeGeneratePayload(
|
|
instruction=req_data.instruction,
|
|
model_config=req_data.model_config_data,
|
|
code_language=req_data.language,
|
|
),
|
|
)
|
|
case _:
|
|
return {"error": f"invalid node type: {node_type}"}
|
|
if req_data.node_id == "" and req_data.current != "": # For legacy app without a workflow
|
|
return LLMGenerator.instruction_modify_legacy(
|
|
tenant_id=current_tenant_id,
|
|
flow_id=req_data.flow_id,
|
|
current=req_data.current,
|
|
instruction=req_data.instruction,
|
|
model_config=req_data.model_config_data,
|
|
ideal_output=req_data.ideal_output,
|
|
)
|
|
if req_data.node_id != "" and req_data.current != "": # For workflow node
|
|
return LLMGenerator.instruction_modify_workflow(
|
|
tenant_id=current_tenant_id,
|
|
flow_id=req_data.flow_id,
|
|
node_id=req_data.node_id,
|
|
current=req_data.current,
|
|
instruction=req_data.instruction,
|
|
model_config=req_data.model_config_data,
|
|
ideal_output=req_data.ideal_output,
|
|
workflow_service=WorkflowService(),
|
|
session=session,
|
|
)
|
|
return {"error": "incompatible parameters"}, 400
|
|
except ProviderTokenNotInitError as ex:
|
|
raise ProviderNotInitializeError(ex.description)
|
|
except QuotaExceededError:
|
|
raise ProviderQuotaExceededError()
|
|
except ModelCurrentlyNotSupportError:
|
|
raise ProviderModelCurrentlyNotSupportError()
|
|
except InvokeError as e:
|
|
raise CompletionRequestError(e.description)
|
|
|
|
|
|
@console_ns.route("/instruction-generate/template")
|
|
class InstructionGenerationTemplateApi(Resource):
|
|
@console_ns.doc("get_instruction_template")
|
|
@console_ns.doc(description="Get instruction generation template")
|
|
@console_ns.expect(console_ns.models[InstructionTemplatePayload.__name__])
|
|
@console_ns.response(200, "Template retrieved successfully", console_ns.models[SimpleDataResponse.__name__])
|
|
@console_ns.response(400, "Invalid request parameters")
|
|
@setup_required
|
|
@login_required
|
|
@account_initialization_required
|
|
@model_validate(InstructionTemplatePayload)
|
|
def post(self, req_data: InstructionTemplatePayload):
|
|
match req_data.type:
|
|
case "prompt":
|
|
from core.llm_generator.prompts import INSTRUCTION_GENERATE_TEMPLATE_PROMPT
|
|
|
|
return {"data": INSTRUCTION_GENERATE_TEMPLATE_PROMPT}
|
|
case "code":
|
|
from core.llm_generator.prompts import INSTRUCTION_GENERATE_TEMPLATE_CODE
|
|
|
|
return {"data": INSTRUCTION_GENERATE_TEMPLATE_CODE}
|
|
case _:
|
|
raise ValueError(f"Invalid type: {req_data.type}")
|
|
|
|
|
|
def _instruction_text_guard(instruction: str) -> tuple[dict, int] | None:
|
|
"""Reject an empty / oversized instruction with the shared error codes.
|
|
|
|
Separate from ``_workflow_instruction_guard`` because the improvement
|
|
endpoint carries no ``ideal_output`` field, and that guard's wording names
|
|
both. Rejecting here keeps a whitespace-only or pasted-document input from
|
|
spending an LLM round-trip.
|
|
"""
|
|
if not instruction.strip():
|
|
return {
|
|
"error": "Instruction is required",
|
|
"errors": [{"code": WorkflowGenerateErrorCode.EMPTY_INSTRUCTION, "detail": "Instruction is required"}],
|
|
}, 400
|
|
if len(instruction) > _MAX_INSTRUCTION_LENGTH:
|
|
return {
|
|
"error": "Instruction is too long",
|
|
"errors": [
|
|
{
|
|
"code": WorkflowGenerateErrorCode.INSTRUCTION_TOO_LONG,
|
|
"detail": f"Instruction must be at most {_MAX_INSTRUCTION_LENGTH} characters",
|
|
}
|
|
],
|
|
}, 400
|
|
return None
|
|
|
|
|
|
def _workflow_instruction_guard(args: WorkflowGeneratePayload) -> tuple[dict, int] | None:
|
|
"""Shared boundary guard for the workflow-generate endpoints.
|
|
|
|
Returns a ``(body, 400)`` tuple when the instruction is empty / whitespace
|
|
or either free-text field exceeds the cap, else ``None``. Pydantic only
|
|
validates the field is a str; a whitespace-only or pasted-document input
|
|
would otherwise waste a slow planner+builder roundtrip on a response the
|
|
validator rejects anyway. Both the blocking and streaming endpoints call
|
|
this so they reject identical inputs.
|
|
"""
|
|
if not args.instruction.strip():
|
|
return {
|
|
"error": "Instruction is required",
|
|
"errors": [{"code": WorkflowGenerateErrorCode.EMPTY_INSTRUCTION, "detail": "Instruction is required"}],
|
|
}, 400
|
|
if len(args.instruction) > _MAX_INSTRUCTION_LENGTH or len(args.ideal_output) > _MAX_INSTRUCTION_LENGTH:
|
|
return {
|
|
"error": "Instruction is too long",
|
|
"errors": [
|
|
{
|
|
"code": WorkflowGenerateErrorCode.INSTRUCTION_TOO_LONG,
|
|
"detail": f"Instruction and ideal output must each be at most {_MAX_INSTRUCTION_LENGTH} characters",
|
|
}
|
|
],
|
|
}, 400
|
|
return None
|
|
|
|
|
|
@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.models[WorkflowGenerateResponse.__name__],
|
|
)
|
|
@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
|
|
@model_validate(WorkflowGeneratePayload)
|
|
def post(self, req_data: WorkflowGeneratePayload, current_tenant_id: str):
|
|
|
|
# Reject empty / over-length instructions at the boundary (shared with
|
|
# the streaming endpoint) before spending a planner+builder roundtrip.
|
|
guard = _workflow_instruction_guard(req_data)
|
|
if guard is not None:
|
|
return guard
|
|
|
|
try:
|
|
result = WorkflowGeneratorService.generate_workflow_graph(
|
|
tenant_id=current_tenant_id,
|
|
mode=req_data.mode,
|
|
instruction=req_data.instruction,
|
|
model_config=req_data.model_config_data,
|
|
ideal_output=req_data.ideal_output,
|
|
current_graph=req_data.current_graph.model_dump(by_alias=True, exclude_none=True)
|
|
if req_data.current_graph
|
|
else None,
|
|
)
|
|
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 dump_response(WorkflowGenerateResponse, result)
|
|
|
|
|
|
@console_ns.route("/workflow-generate/suggestions")
|
|
class WorkflowInstructionSuggestionsApi(Resource):
|
|
"""Suggest short, buildable example instructions for the cmd+k generator.
|
|
|
|
Runs before a model is selected (uses the tenant's default model). The
|
|
underlying generator never raises, so an empty list is a valid 200 — the
|
|
frontend renders "no suggestions" rather than an error, so no provider-error
|
|
mapping is needed here.
|
|
"""
|
|
|
|
@console_ns.doc("generate_workflow_instruction_suggestions")
|
|
@console_ns.doc(description="Suggest example workflow-generator instructions for the tenant")
|
|
@console_ns.expect(console_ns.models[WorkflowInstructionSuggestionsPayload.__name__])
|
|
@console_ns.response(
|
|
200,
|
|
"Suggestions generated successfully",
|
|
console_ns.models[WorkflowInstructionSuggestionsResponse.__name__],
|
|
)
|
|
@console_ns.response(400, "Invalid request parameters")
|
|
@setup_required
|
|
@login_required
|
|
@account_initialization_required
|
|
@with_current_tenant_id
|
|
@model_validate(WorkflowInstructionSuggestionsPayload)
|
|
def post(self, req_data: WorkflowInstructionSuggestionsPayload, current_tenant_id: str):
|
|
suggestions = LLMGenerator.generate_workflow_instruction_suggestions(
|
|
tenant_id=current_tenant_id,
|
|
mode=req_data.mode,
|
|
language=req_data.language,
|
|
count=req_data.count,
|
|
)
|
|
return dump_response(WorkflowInstructionSuggestionsResponse, {"suggestions": suggestions})
|
|
|
|
|
|
@console_ns.route("/workflow-generate/improve")
|
|
class WorkflowInstructionImproveApi(Resource):
|
|
"""Expand a build instruction with the details it leaves unstated.
|
|
|
|
Backs the composer's "Add missing details" action. Runs before an app exists
|
|
and before a model is selected, so it is tenant-scoped and uses the tenant's
|
|
default model. The underlying generator never raises: when it cannot improve
|
|
the text this returns the caller's own instruction with ``changed: false``
|
|
rather than an error, so the action can never strand what the user typed.
|
|
"""
|
|
|
|
@console_ns.doc("improve_workflow_instruction")
|
|
@console_ns.doc(description="Expand a workflow-generator instruction with the details it leaves unstated")
|
|
@console_ns.expect(console_ns.models[WorkflowInstructionImprovePayload.__name__])
|
|
@console_ns.response(
|
|
200,
|
|
"Instruction returned successfully",
|
|
console_ns.models[WorkflowInstructionImproveResponse.__name__],
|
|
)
|
|
@console_ns.response(400, "Invalid request parameters")
|
|
@setup_required
|
|
@login_required
|
|
@account_initialization_required
|
|
@with_current_tenant_id
|
|
@model_validate(WorkflowInstructionImprovePayload)
|
|
def post(self, req_data: WorkflowInstructionImprovePayload, current_tenant_id: str):
|
|
guard = _instruction_text_guard(req_data.instruction)
|
|
if guard is not None:
|
|
return guard
|
|
|
|
original = req_data.instruction.strip()
|
|
improved = LLMGenerator.improve_workflow_instruction(
|
|
tenant_id=current_tenant_id,
|
|
instruction=original,
|
|
mode=req_data.mode,
|
|
language=req_data.language,
|
|
)
|
|
return dump_response(
|
|
WorkflowInstructionImproveResponse,
|
|
{"instruction": improved or original, "changed": bool(improved)},
|
|
)
|
|
|
|
|
|
@console_ns.route("/workflow-generate/stream")
|
|
class WorkflowGenerateStreamApi(Resource):
|
|
"""Plan-first streaming variant of ``/workflow-generate`` (Server-Sent Events).
|
|
|
|
Emits a ``plan`` event (high-level node list + app metadata) as soon as the
|
|
planner returns, then a final ``result`` event with the full graph — the
|
|
SAME envelope ``/workflow-generate`` returns. Provider-init / invoke errors
|
|
are surfaced as a single ``result`` event (code ``MODEL_ERROR``) so the
|
|
frontend's stream parser always receives a result rather than a non-SSE HTTP
|
|
error.
|
|
"""
|
|
|
|
@console_ns.doc("generate_workflow_graph_stream")
|
|
@console_ns.doc(description="Stream a Dify workflow graph (plan then result) via SSE")
|
|
@console_ns.expect(console_ns.models[WorkflowGeneratePayload.__name__])
|
|
@console_ns.response(
|
|
200,
|
|
"Server-Sent Events stream; each data frame matches this plan/result event schema",
|
|
console_ns.models[WorkflowGenerateStreamEventResponse.__name__],
|
|
)
|
|
@console_ns.response(400, "Invalid request parameters")
|
|
@setup_required
|
|
@login_required
|
|
@account_initialization_required
|
|
@with_current_tenant_id
|
|
@model_validate(WorkflowGeneratePayload)
|
|
def post(self, req_data: WorkflowGeneratePayload, current_tenant_id: str):
|
|
|
|
# Same boundary guards as the blocking endpoint — return a normal 400
|
|
# JSON for these BEFORE opening the stream.
|
|
guard = _workflow_instruction_guard(req_data)
|
|
if guard is not None:
|
|
return guard
|
|
|
|
def generate() -> Generator[str, None, None]:
|
|
try:
|
|
for event_name, payload in WorkflowGeneratorService.generate_workflow_graph_stream(
|
|
tenant_id=current_tenant_id,
|
|
mode=req_data.mode,
|
|
instruction=req_data.instruction,
|
|
model_config=req_data.model_config_data,
|
|
ideal_output=req_data.ideal_output,
|
|
current_graph=(
|
|
req_data.current_graph.model_dump(by_alias=True, exclude_none=True)
|
|
if req_data.current_graph
|
|
else None
|
|
),
|
|
):
|
|
body = {"event": event_name, **payload}
|
|
if event_name == "plan":
|
|
plan_event = WorkflowGeneratePlanEventResponse.model_validate(body)
|
|
yield f"data: {json.dumps(plan_event.model_dump(mode='json'))}\n\n"
|
|
else:
|
|
result_event = WorkflowGenerateResultEventResponse.model_validate(body)
|
|
yield f"data: {json.dumps(result_event.model_dump(mode='json'))}\n\n"
|
|
except (ProviderTokenNotInitError, QuotaExceededError, ModelCurrentlyNotSupportError, InvokeError) as e:
|
|
# The model instance is resolved inside the service (lazily, on
|
|
# first iteration), so a provider / init error surfaces here.
|
|
# Emit it as a single SSE result event rather than a non-SSE
|
|
# error response so the frontend's stream parser always gets a
|
|
# result it can render.
|
|
detail = getattr(e, "description", None) or str(e) or "Model invocation failed"
|
|
error_body = {
|
|
"event": "result",
|
|
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0.0, "y": 0.0, "zoom": 0.7}},
|
|
"error": detail,
|
|
"errors": [{"code": WorkflowGenerateErrorCode.MODEL_ERROR, "detail": detail}],
|
|
}
|
|
error_event = WorkflowGenerateResultEventResponse.model_validate(error_body)
|
|
yield f"data: {json.dumps(error_event.model_dump(mode='json'))}\n\n"
|
|
|
|
return compact_generate_response(generate())
|