From 4707a319e5d163fbe67f2978fcb256a76d36c09a Mon Sep 17 00:00:00 2001 From: zhsama Date: Fri, 23 Jan 2026 06:37:21 +0800 Subject: [PATCH] refactor: use bivariance to normalize node metadata types --- .../hooks/use-available-nodes-meta-data.ts | 49 +++++++++++++----- .../sub-graph/components/sub-graph-main.tsx | 3 +- .../hooks/use-available-nodes-meta-data.ts | 49 +++++++++++++----- .../hooks/use-available-nodes-meta-data.ts | 51 ++++++++++++++----- .../workflow/block-selector/blocks.tsx | 6 +-- .../workflow/block-selector/main.tsx | 4 +- .../workflow/block-selector/tabs.tsx | 4 +- .../components/workflow/hooks-store/store.ts | 14 +++-- .../hooks/use-resizable-panels.ts | 2 +- .../hooks/use-mixed-variable-extractor.ts | 9 ++-- web/app/components/workflow/types.ts | 33 +++++++++--- 11 files changed, 157 insertions(+), 67 deletions(-) diff --git a/web/app/components/rag-pipeline/hooks/use-available-nodes-meta-data.ts b/web/app/components/rag-pipeline/hooks/use-available-nodes-meta-data.ts index 3720ef388b..f653d2dcd2 100644 --- a/web/app/components/rag-pipeline/hooks/use-available-nodes-meta-data.ts +++ b/web/app/components/rag-pipeline/hooks/use-available-nodes-meta-data.ts @@ -1,4 +1,5 @@ import type { AvailableNodesMetaData } from '@/app/components/workflow/hooks-store/store' +import type { CommonNodeType, NodeDefault, NodeDefaultBase } from '@/app/components/workflow/types' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { WORKFLOW_COMMON_NODES } from '@/app/components/workflow/constants/node' @@ -29,25 +30,47 @@ export const useAvailableNodesMetaData = () => { '/use-dify/knowledge/knowledge-pipeline/knowledge-pipeline-orchestration', ), [docLink]) - const availableNodesMetaData = useMemo(() => mergedNodesMetaData.map((node) => { - const { metaData } = node - const title = t(`blocks.${metaData.type}`, { ns: 'workflow' }) - const description = t(`blocksAbout.${metaData.type}`, { ns: 'workflow' }) - return { - ...node, - metaData: { + const availableNodesMetaData = useMemo(() => { + const toNodeDefaultBase = ( + node: NodeDefault, + metaData: NodeDefaultBase['metaData'], + defaultValue: Partial, + ): NodeDefaultBase => { + return { + ...node, + metaData, + defaultValue, + checkValid: (payload: CommonNodeType, translator, moreDataForCheckValid) => { + // normalize validator signature for shared metadata storage. + return node.checkValid(payload, translator, moreDataForCheckValid) + }, + getOutputVars: node.getOutputVars + ? (payload: CommonNodeType, allPluginInfoList, ragVariables, utils) => { + // normalize output var signature for shared metadata storage. + return node.getOutputVars!(payload, allPluginInfoList, ragVariables, utils) + } + : undefined, + } + } + + return mergedNodesMetaData.map((node) => { + // normalize per-node defaults into a shared metadata shape. + const typedNode = node as NodeDefault + const { metaData } = typedNode + const title = t(`blocks.${metaData.type}`, { ns: 'workflow' }) + const description = t(`blocksAbout.${metaData.type}`, { ns: 'workflow' }) + return toNodeDefaultBase(typedNode, { ...metaData, title, description, helpLinkUri, - }, - defaultValue: { - ...node.defaultValue, + }, { + ...typedNode.defaultValue, type: metaData.type, title, - }, - } - }), [mergedNodesMetaData, t]) + }) + }) + }, [mergedNodesMetaData, t, helpLinkUri]) const availableNodesMetaDataMap = useMemo(() => availableNodesMetaData.reduce((acc, node) => { acc![node.metaData.type] = node diff --git a/web/app/components/sub-graph/components/sub-graph-main.tsx b/web/app/components/sub-graph/components/sub-graph-main.tsx index 295e8e065c..c81e1dde62 100644 --- a/web/app/components/sub-graph/components/sub-graph-main.tsx +++ b/web/app/components/sub-graph/components/sub-graph-main.tsx @@ -131,8 +131,7 @@ const SubGraphMain: FC = (props) => { nodes={nodes} edges={edges} viewport={viewport} - // eslint-disable-next-line ts/no-explicit-any -- TODO: remove after typing boundary - hooksStore={hooksStore as any} + hooksStore={hooksStore} allowSelectionWhenReadOnly canvasReadOnly interactionMode={InteractionMode.Subgraph} diff --git a/web/app/components/sub-graph/hooks/use-available-nodes-meta-data.ts b/web/app/components/sub-graph/hooks/use-available-nodes-meta-data.ts index f9a843e7a4..195c099c5a 100644 --- a/web/app/components/sub-graph/hooks/use-available-nodes-meta-data.ts +++ b/web/app/components/sub-graph/hooks/use-available-nodes-meta-data.ts @@ -1,4 +1,5 @@ import type { AvailableNodesMetaData } from '@/app/components/workflow/hooks-store/store' +import type { CommonNodeType, NodeDefault, NodeDefaultBase } from '@/app/components/workflow/types' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { WORKFLOW_COMMON_NODES } from '@/app/components/workflow/constants/node' @@ -7,24 +8,46 @@ import { BlockEnum } from '@/app/components/workflow/types' export const useAvailableNodesMetaData = () => { const { t } = useTranslation() - const availableNodesMetaData = useMemo(() => WORKFLOW_COMMON_NODES.map((node) => { - const { metaData } = node - const title = t(`blocks.${metaData.type}`, { ns: 'workflow' }) - const description = t(`blocksAbout.${metaData.type}`, { ns: 'workflow' }) - return { - ...node, - metaData: { + const availableNodesMetaData = useMemo(() => { + const toNodeDefaultBase = ( + node: NodeDefault, + metaData: NodeDefaultBase['metaData'], + defaultValue: Partial, + ): NodeDefaultBase => { + return { + ...node, + metaData, + defaultValue, + checkValid: (payload: CommonNodeType, translator, moreDataForCheckValid) => { + // normalize validator signature for shared metadata storage. + return node.checkValid(payload, translator, moreDataForCheckValid) + }, + getOutputVars: node.getOutputVars + ? (payload: CommonNodeType, allPluginInfoList, ragVariables, utils) => { + // normalize output var signature for shared metadata storage. + return node.getOutputVars!(payload, allPluginInfoList, ragVariables, utils) + } + : undefined, + } + } + + return WORKFLOW_COMMON_NODES.map((node) => { + // normalize per-node defaults into a shared metadata shape. + const typedNode = node as NodeDefault + const { metaData } = typedNode + const title = t(`blocks.${metaData.type}`, { ns: 'workflow' }) + const description = t(`blocksAbout.${metaData.type}`, { ns: 'workflow' }) + return toNodeDefaultBase(typedNode, { ...metaData, title, description, - }, - defaultValue: { - ...node.defaultValue, + }, { + ...typedNode.defaultValue, type: metaData.type, title, - }, - } - }), [t]) + }) + }) + }, [t]) const availableNodesMetaDataMap = useMemo(() => availableNodesMetaData.reduce((acc, node) => { acc![node.metaData.type] = node diff --git a/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts b/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts index 0c5c1e4a40..e43a58fd14 100644 --- a/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts +++ b/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts @@ -1,4 +1,5 @@ import type { AvailableNodesMetaData } from '@/app/components/workflow/hooks-store/store' +import type { CommonNodeType, NodeDefault, NodeDefaultBase } from '@/app/components/workflow/types' import type { DocPathWithoutLang } from '@/types/doc-paths' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -41,26 +42,48 @@ export const useAvailableNodesMetaData = () => { ), ], [isChatMode, startNodeMetaData]) - const availableNodesMetaData = useMemo(() => mergedNodesMetaData.map((node) => { - const { metaData } = node - const title = t(`blocks.${metaData.type}`, { ns: 'workflow' }) - const description = t(`blocksAbout.${metaData.type}`, { ns: 'workflow' }) - const helpLinkPath = `/use-dify/nodes/${metaData.helpLinkUri}` as DocPathWithoutLang - return { - ...node, - metaData: { + const availableNodesMetaData = useMemo(() => { + const toNodeDefaultBase = ( + node: NodeDefault, + metaData: NodeDefaultBase['metaData'], + defaultValue: Partial, + ): NodeDefaultBase => { + return { + ...node, + metaData, + defaultValue, + checkValid: (payload: CommonNodeType, translator, moreDataForCheckValid) => { + // normalize validator signature for shared metadata storage. + return node.checkValid(payload, translator, moreDataForCheckValid) + }, + getOutputVars: node.getOutputVars + ? (payload: CommonNodeType, allPluginInfoList, ragVariables, utils) => { + // normalize output var signature for shared metadata storage. + return node.getOutputVars!(payload, allPluginInfoList, ragVariables, utils) + } + : undefined, + } + } + + return mergedNodesMetaData.map((node) => { + // normalize per-node defaults into a shared metadata shape. + const typedNode = node as NodeDefault + const { metaData } = typedNode + const title = t(`blocks.${metaData.type}`, { ns: 'workflow' }) + const description = t(`blocksAbout.${metaData.type}`, { ns: 'workflow' }) + const helpLinkPath = `/use-dify/nodes/${metaData.helpLinkUri}` as DocPathWithoutLang + return toNodeDefaultBase(typedNode, { ...metaData, title, description, helpLinkUri: docLink(helpLinkPath), - }, - defaultValue: { - ...node.defaultValue, + }, { + ...typedNode.defaultValue, type: metaData.type, title, - }, - } - }), [mergedNodesMetaData, t, docLink]) + }) + }) + }, [mergedNodesMetaData, t, docLink]) const availableNodesMetaDataMap = useMemo(() => availableNodesMetaData.reduce((acc, node) => { acc![node.metaData.type] = node diff --git a/web/app/components/workflow/block-selector/blocks.tsx b/web/app/components/workflow/block-selector/blocks.tsx index 2425112335..5a9e09eced 100644 --- a/web/app/components/workflow/block-selector/blocks.tsx +++ b/web/app/components/workflow/block-selector/blocks.tsx @@ -1,4 +1,4 @@ -import type { NodeDefault } from '../types' +import type { NodeDefaultBase } from '../types' import type { BlockClassificationEnum } from './types' import { groupBy } from 'es-toolkit/compat' import { @@ -19,7 +19,7 @@ type BlocksProps = { searchText: string onSelect: (type: BlockEnum) => void availableBlocksTypes?: BlockEnum[] - blocks?: NodeDefault[] + blocks?: NodeDefaultBase[] } const Blocks = ({ searchText, @@ -44,7 +44,7 @@ const Blocks = ({ }, defaultValue: {}, checkValid: () => ({ isValid: true }), - }) as NodeDefault) + }) as NodeDefaultBase) const groups = useMemo(() => { return BLOCK_CLASSIFICATIONS.reduce((acc, classification) => { diff --git a/web/app/components/workflow/block-selector/main.tsx b/web/app/components/workflow/block-selector/main.tsx index 5229d273f3..140ea3a9cc 100644 --- a/web/app/components/workflow/block-selector/main.tsx +++ b/web/app/components/workflow/block-selector/main.tsx @@ -8,7 +8,7 @@ import type { } from 'react' import type { CommonNodeType, - NodeDefault, + NodeDefaultBase, OnSelectBlock, ToolWithProvider, } from '../types' @@ -49,7 +49,7 @@ export type NodeSelectorProps = { asChild?: boolean availableBlocksTypes?: BlockEnum[] disabled?: boolean - blocks?: NodeDefault[] + blocks?: NodeDefaultBase[] dataSources?: ToolWithProvider[] noBlocks?: boolean noTools?: boolean diff --git a/web/app/components/workflow/block-selector/tabs.tsx b/web/app/components/workflow/block-selector/tabs.tsx index f1eeba7435..b26980e04b 100644 --- a/web/app/components/workflow/block-selector/tabs.tsx +++ b/web/app/components/workflow/block-selector/tabs.tsx @@ -1,7 +1,7 @@ import type { Dispatch, FC, SetStateAction } from 'react' import type { BlockEnum, - NodeDefault, + NodeDefaultBase, OnSelectBlock, ToolWithProvider, } from '../types' @@ -28,7 +28,7 @@ export type TabsProps = { onTagsChange: Dispatch> onSelect: OnSelectBlock availableBlocksTypes?: BlockEnum[] - blocks: NodeDefault[] + blocks: NodeDefaultBase[] dataSources?: ToolWithProvider[] tabs: Array<{ key: TabsEnum diff --git a/web/app/components/workflow/hooks-store/store.ts b/web/app/components/workflow/hooks-store/store.ts index 716d77b7a7..2f3ae89108 100644 --- a/web/app/components/workflow/hooks-store/store.ts +++ b/web/app/components/workflow/hooks-store/store.ts @@ -2,7 +2,7 @@ import type { FileUpload } from '../../base/features/types' import type { BlockEnum, Node, - NodeDefault, + NodeDefaultBase, ToolWithProvider, ValueSelector, } from '@/app/components/workflow/types' @@ -16,14 +16,17 @@ import { useStore as useZustandStore, } from 'zustand' import { createStore } from 'zustand/vanilla' +import { InteractionMode } from '@/app/components/workflow' import { HooksStoreContext } from './provider' export type AvailableNodesMetaData = { - nodes: NodeDefault[] - nodesMap?: Record> + nodes: NodeDefaultBase[] + nodesMap: Record } + +export type HooksStore = ReturnType export type CommonHooksFnMap = { - interactionMode?: 'default' | 'subgraph' + interactionMode?: InteractionMode doSyncWorkflowDraft: ( notRefreshWhenSyncError?: boolean, callback?: { @@ -78,7 +81,7 @@ export type Shape = { } & CommonHooksFnMap export const createHooksStore = ({ - interactionMode = 'default', + interactionMode = InteractionMode.Default, doSyncWorkflowDraft = async () => noop(), syncWorkflowDraftWhenPageClose = noop, handleRefreshWorkflowDraft = noop, @@ -97,6 +100,7 @@ export const createHooksStore = ({ subGraphSelectableNodeTypes, availableNodesMetaData = { nodes: [], + nodesMap: {} as Record, }, getWorkflowRunAndTraceUrl = () => ({ runUrl: '', diff --git a/web/app/components/workflow/nodes/tool/components/context-generate-modal/hooks/use-resizable-panels.ts b/web/app/components/workflow/nodes/tool/components/context-generate-modal/hooks/use-resizable-panels.ts index 35a5c322f9..075c1f01c3 100644 --- a/web/app/components/workflow/nodes/tool/components/context-generate-modal/hooks/use-resizable-panels.ts +++ b/web/app/components/workflow/nodes/tool/components/context-generate-modal/hooks/use-resizable-panels.ts @@ -24,7 +24,7 @@ const useResizablePanels = () => { const resolvedCodePanelHeight = useMemo(() => { if (!maxCodePanelHeight) return codePanelHeight - // Reason: Clamp the panel height so the output area always has space. + // Clamp the panel height so the output area always has space. return Math.min(codePanelHeight, maxCodePanelHeight) }, [codePanelHeight, maxCodePanelHeight]) diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/hooks/use-mixed-variable-extractor.ts b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/hooks/use-mixed-variable-extractor.ts index f956a0d2f6..d94882c0fb 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/hooks/use-mixed-variable-extractor.ts +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/hooks/use-mixed-variable-extractor.ts @@ -4,7 +4,7 @@ import type { NestedNodeConfig } from '@/app/components/workflow/nodes/_base/typ import type { CodeNodeType, OutputVar } from '@/app/components/workflow/nodes/code/types' import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types' import type { - CommonNodeType, + NodeDefaultBase, PromptItem, PromptTemplateItem, Node as WorkflowNode, @@ -40,7 +40,7 @@ export const getDefaultOutputKey = (outputs?: OutputVar): string => { const keys = Object.keys(outputs) if (keys.length === 0) return '' - // Reason: 'result' is the conventional default output key for code nodes + // 'result' is the conventional default output key for code nodes if (keys.includes('result')) return 'result' return keys[0] @@ -132,10 +132,7 @@ const buildPromptTemplateWithText = ( return [...promptTemplate, defaultUserPrompt] as PromptTemplateItem[] } -type NodesMetaDataMap = Record - checkValid?: (data: CommonNodeType, t: (key: string, options?: Record) => string, moreData?: unknown) => { isValid: boolean, errorMessage?: string } -}> +type NodesMetaDataMap = Record> type ConfigsMap = { flowId?: string diff --git a/web/app/components/workflow/types.ts b/web/app/components/workflow/types.ts index 282e9b6730..3ca7eec947 100644 --- a/web/app/components/workflow/types.ts +++ b/web/app/components/workflow/types.ts @@ -340,7 +340,24 @@ export type NodeOutPutVar = { isFlat?: boolean } -export type NodeDefault = { +// allow node default validators with narrower payload types to be stored in shared collections. +type CheckValidFn = { + bivarianceHack: (payload: T, t: any, moreDataForCheckValid?: any) => { isValid: boolean, errorMessage?: string } +}['bivarianceHack'] + +// allow node output var generators with narrower payload types to be stored in shared collections. +type GetOutputVarsFn = { + bivarianceHack: ( + payload: T, + allPluginInfoList: Record, + ragVariables?: Var[], + utils?: { + schemaTypeDefinitions?: SchemaTypeDefinition[] + }, + ) => Var[] +}['bivarianceHack'] + +export type NodeDefaultBase = { metaData: { classification: BlockClassificationEnum sort: number @@ -355,12 +372,16 @@ export type NodeDefault = { isSingleton?: boolean isTypeFixed?: boolean } - defaultValue: Partial + defaultValue: Partial defaultRunInputData?: Record - checkValid: (payload: T, t: any, moreDataForCheckValid?: any) => { isValid: boolean, errorMessage?: string } - getOutputVars?: (payload: T, allPluginInfoList: Record, ragVariables?: Var[], utils?: { - schemaTypeDefinitions?: SchemaTypeDefinition[] - }) => Var[] + checkValid: CheckValidFn + getOutputVars?: GetOutputVarsFn +} + +export type NodeDefault = Omit & { + defaultValue: Partial + checkValid: CheckValidFn + getOutputVars?: GetOutputVarsFn } export type OnSelectBlock = (type: BlockEnum, pluginDefaultValue?: PluginDefaultValue) => void