From 805a1479f974fdb7b84880a59bdfa375090da49c Mon Sep 17 00:00:00 2001 From: Maries Date: Thu, 13 Nov 2025 10:59:31 +0800 Subject: [PATCH 01/31] fix: simplify graph structure validation in WorkflowService (#28146) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- api/services/workflow_service.py | 52 +++++++++++--------------------- 1 file changed, 18 insertions(+), 34 deletions(-) diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index b6d64d95da..e8088e17c1 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -10,20 +10,17 @@ from sqlalchemy.orm import Session, sessionmaker from core.app.app_config.entities import VariableEntityType from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager from core.app.apps.workflow.app_config_manager import WorkflowAppConfigManager -from core.app.entities.app_invoke_entities import InvokeFrom from core.file import File from core.repositories import DifyCoreRepositoryFactory from core.variables import Variable from core.variables.variables import VariableUnion -from core.workflow.entities import GraphInitParams, GraphRuntimeState, VariablePool, WorkflowNodeExecution +from core.workflow.entities import VariablePool, WorkflowNodeExecution from core.workflow.enums import ErrorStrategy, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus from core.workflow.errors import WorkflowNodeRunFailedError -from core.workflow.graph.graph import Graph from core.workflow.graph_events import GraphNodeEventBase, NodeRunFailedEvent, NodeRunSucceededEvent from core.workflow.node_events import NodeRunResult from core.workflow.nodes import NodeType from core.workflow.nodes.base.node import Node -from core.workflow.nodes.node_factory import DifyNodeFactory from core.workflow.nodes.node_mapping import LATEST_VERSION, NODE_TYPE_CLASSES_MAPPING from core.workflow.nodes.start.entities import StartNodeData from core.workflow.system_variable import SystemVariable @@ -34,7 +31,6 @@ from extensions.ext_storage import storage from factories.file_factory import build_from_mapping, build_from_mappings from libs.datetime_utils import naive_utc_now from models import Account -from models.enums import UserFrom from models.model import App, AppMode from models.tools import WorkflowToolProvider from models.workflow import Workflow, WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom, WorkflowType @@ -215,7 +211,7 @@ class WorkflowService: self.validate_features_structure(app_model=app_model, features=features) # validate graph structure - self.validate_graph_structure(user_id=account.id, app_model=app_model, graph=graph) + self.validate_graph_structure(graph=graph) # create draft workflow if not found if not workflow: @@ -274,7 +270,7 @@ class WorkflowService: self._validate_workflow_credentials(draft_workflow) # validate graph structure - self.validate_graph_structure(user_id=account.id, app_model=app_model, graph=draft_workflow.graph_dict) + self.validate_graph_structure(graph=draft_workflow.graph_dict) # create new workflow workflow = Workflow.new( @@ -905,42 +901,30 @@ class WorkflowService: return new_app - def validate_graph_structure(self, user_id: str, app_model: App, graph: Mapping[str, Any]): + def validate_graph_structure(self, graph: Mapping[str, Any]): """ - Validate workflow graph structure by instantiating the Graph object. + Validate workflow graph structure. - This leverages the built-in graph validators (including trigger/UserInput exclusivity) - and raises any structural errors before persisting the workflow. + This performs a lightweight validation on the graph, checking for structural + inconsistencies such as the coexistence of start and trigger nodes. """ node_configs = graph.get("nodes", []) - node_configs = cast(list[dict[str, object]], node_configs) + node_configs = cast(list[dict[str, Any]], node_configs) # is empty graph if not node_configs: return - workflow_id = app_model.workflow_id or "UNKNOWN" - Graph.init( - graph_config=graph, - # TODO(Mairuis): Add root node id - root_node_id=None, - node_factory=DifyNodeFactory( - graph_init_params=GraphInitParams( - tenant_id=app_model.tenant_id, - app_id=app_model.id, - workflow_id=workflow_id, - graph_config=graph, - user_id=user_id, - user_from=UserFrom.ACCOUNT, - invoke_from=InvokeFrom.VALIDATION, - call_depth=0, - ), - graph_runtime_state=GraphRuntimeState( - variable_pool=VariablePool(), - start_at=time.perf_counter(), - ), - ), - ) + node_types: set[NodeType] = set() + for node in node_configs: + node_type = node.get("data", {}).get("type") + if node_type: + node_types.add(NodeType(node_type)) + + # start node and trigger node cannot coexist + if NodeType.START in node_types: + if any(nt.is_trigger_node for nt in node_types): + raise ValueError("Start node and trigger nodes cannot coexist in the same workflow") def validate_features_structure(self, app_model: App, features: dict): if app_model.mode == AppMode.ADVANCED_CHAT: From 2799b79e8c5a002ed0861a84b4f8552d816ae701 Mon Sep 17 00:00:00 2001 From: mnasrautinno Date: Thu, 13 Nov 2025 06:44:04 +0300 Subject: [PATCH 02/31] fix: app's ai site text to speech api (#28091) --- api/controllers/web/audio.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/api/controllers/web/audio.py b/api/controllers/web/audio.py index 3103851088..b9fef48c4d 100644 --- a/api/controllers/web/audio.py +++ b/api/controllers/web/audio.py @@ -88,12 +88,6 @@ class AudioApi(WebApiResource): @web_ns.route("/text-to-audio") class TextApi(WebApiResource): - text_to_audio_response_fields = { - "audio_url": fields.String, - "duration": fields.Float, - } - - @marshal_with(text_to_audio_response_fields) @web_ns.doc("Text to Audio") @web_ns.doc(description="Convert text to audio using text-to-speech service.") @web_ns.doc( From b0e7e7752f183d0453d214fc27293a4530a12e98 Mon Sep 17 00:00:00 2001 From: yangzheli <43645580+yangzheli@users.noreply.github.com> Date: Thu, 13 Nov 2025 11:44:21 +0800 Subject: [PATCH 03/31] refactor(web): reuse the same edit-custom-collection-modal component, and fix the pop up error (#28003) --- .../edit-custom-collection-modal/index.tsx | 3 + .../edit-custom-collection-modal/modal.tsx | 361 ------------------ .../workflow/block-selector/tool-picker.tsx | 4 +- 3 files changed, 5 insertions(+), 363 deletions(-) delete mode 100644 web/app/components/tools/edit-custom-collection-modal/modal.tsx diff --git a/web/app/components/tools/edit-custom-collection-modal/index.tsx b/web/app/components/tools/edit-custom-collection-modal/index.tsx index 95a204c1ec..48801b018f 100644 --- a/web/app/components/tools/edit-custom-collection-modal/index.tsx +++ b/web/app/components/tools/edit-custom-collection-modal/index.tsx @@ -24,6 +24,7 @@ import Toast from '@/app/components/base/toast' type Props = { positionLeft?: boolean + dialogClassName?: string payload: any onHide: () => void onAdd?: (payload: CustomCollectionBackend) => void @@ -33,6 +34,7 @@ type Props = { // Add and Edit const EditCustomCollectionModal: FC = ({ positionLeft, + dialogClassName = '', payload, onHide, onAdd, @@ -186,6 +188,7 @@ const EditCustomCollectionModal: FC = ({ positionCenter={isAdd && !positionLeft} onHide={onHide} title={t(`tools.createTool.${isAdd ? 'title' : 'editTitle'}`)!} + dialogClassName={dialogClassName} panelClassName='mt-2 !w-[640px]' maxWidthClassName='!max-w-[640px]' height='calc(100vh - 16px)' diff --git a/web/app/components/tools/edit-custom-collection-modal/modal.tsx b/web/app/components/tools/edit-custom-collection-modal/modal.tsx deleted file mode 100644 index 3e278f7b53..0000000000 --- a/web/app/components/tools/edit-custom-collection-modal/modal.tsx +++ /dev/null @@ -1,361 +0,0 @@ -'use client' -import type { FC } from 'react' -import React, { useEffect, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { useDebounce, useGetState } from 'ahooks' -import { produce } from 'immer' -import { LinkExternal02, Settings01 } from '../../base/icons/src/vender/line/general' -import type { Credential, CustomCollectionBackend, CustomParamSchema, Emoji } from '../types' -import { AuthHeaderPrefix, AuthType } from '../types' -import GetSchema from './get-schema' -import ConfigCredentials from './config-credentials' -import TestApi from './test-api' -import cn from '@/utils/classnames' -import Input from '@/app/components/base/input' -import Textarea from '@/app/components/base/textarea' -import EmojiPicker from '@/app/components/base/emoji-picker' -import AppIcon from '@/app/components/base/app-icon' -import { parseParamsSchema } from '@/service/tools' -import LabelSelector from '@/app/components/tools/labels/selector' -import Toast from '@/app/components/base/toast' -import Modal from '../../base/modal' -import Button from '@/app/components/base/button' - -type Props = { - positionLeft?: boolean - payload: any - onHide: () => void - onAdd?: (payload: CustomCollectionBackend) => void - onRemove?: () => void - onEdit?: (payload: CustomCollectionBackend) => void -} -// Add and Edit -const EditCustomCollectionModal: FC = ({ - payload, - onHide, - onAdd, - onEdit, - onRemove, -}) => { - const { t } = useTranslation() - const isAdd = !payload - const isEdit = !!payload - - const [editFirst, setEditFirst] = useState(!isAdd) - const [paramsSchemas, setParamsSchemas] = useState(payload?.tools || []) - const [customCollection, setCustomCollection, getCustomCollection] = useGetState(isAdd - ? { - provider: '', - credentials: { - auth_type: AuthType.none, - api_key_header: 'Authorization', - api_key_header_prefix: AuthHeaderPrefix.basic, - }, - icon: { - content: '🕵️', - background: '#FEF7C3', - }, - schema_type: '', - schema: '', - } - : payload) - - const originalProvider = isEdit ? payload.provider : '' - - const [showEmojiPicker, setShowEmojiPicker] = useState(false) - const emoji = customCollection.icon - const setEmoji = (emoji: Emoji) => { - const newCollection = produce(customCollection, (draft) => { - draft.icon = emoji - }) - setCustomCollection(newCollection) - } - const schema = customCollection.schema - const debouncedSchema = useDebounce(schema, { wait: 500 }) - const setSchema = (schema: any) => { - const newCollection = produce(customCollection, (draft) => { - draft.schema = schema - }) - setCustomCollection(newCollection) - } - - useEffect(() => { - if (!debouncedSchema) - return - if (isEdit && editFirst) { - setEditFirst(false) - return - } - (async () => { - try { - const { parameters_schema, schema_type } = await parseParamsSchema(debouncedSchema) - const customCollection = getCustomCollection() - const newCollection = produce(customCollection, (draft) => { - draft.schema_type = schema_type - }) - setCustomCollection(newCollection) - setParamsSchemas(parameters_schema) - } - catch { - const customCollection = getCustomCollection() - const newCollection = produce(customCollection, (draft) => { - draft.schema_type = '' - }) - setCustomCollection(newCollection) - setParamsSchemas([]) - } - })() - }, [debouncedSchema]) - - const [credentialsModalShow, setCredentialsModalShow] = useState(false) - const credential = customCollection.credentials - const setCredential = (credential: Credential) => { - const newCollection = produce(customCollection, (draft) => { - draft.credentials = credential - }) - setCustomCollection(newCollection) - } - - const [currTool, setCurrTool] = useState(null) - const [isShowTestApi, setIsShowTestApi] = useState(false) - - const [labels, setLabels] = useState(payload?.labels || []) - const handleLabelSelect = (value: string[]) => { - setLabels(value) - } - - const handleSave = () => { - // const postData = clone(customCollection) - const postData = produce(customCollection, (draft) => { - delete draft.tools - - if (draft.credentials.auth_type === AuthType.none) { - delete draft.credentials.api_key_header - delete draft.credentials.api_key_header_prefix - delete draft.credentials.api_key_value - } - - draft.labels = labels - }) - - let errorMessage = '' - if (!postData.provider) - errorMessage = t('common.errorMsg.fieldRequired', { field: t('tools.createTool.name') }) - - if (!postData.schema) - errorMessage = t('common.errorMsg.fieldRequired', { field: t('tools.createTool.schema') }) - - if (errorMessage) { - Toast.notify({ - type: 'error', - message: errorMessage, - }) - return - } - - if (isAdd) { - onAdd?.(postData) - return - } - - onEdit?.({ - ...postData, - original_provider: originalProvider, - }) - } - - const getPath = (url: string) => { - if (!url) - return '' - - try { - const path = decodeURI(new URL(url).pathname) - return path || '' - } - catch { - return url - } - } - - return ( - <> - -
-
- {t('tools.createTool.title')} -
-
-
-
{t('tools.createTool.name')} *
-
- { setShowEmojiPicker(true) }} className='cursor-pointer' icon={emoji.content} background={emoji.background} /> - { - const newCollection = produce(customCollection, (draft) => { - draft.provider = e.target.value - }) - setCustomCollection(newCollection) - }} - /> -
-
- - {/* Schema */} -
-
-
-
{t('tools.createTool.schema')}*
-
- -
{t('tools.createTool.viewSchemaSpec')}
- -
-
- - -
-