From 752cb9e4f46c391fac80d14cc3428b7603b479e5 Mon Sep 17 00:00:00 2001 From: zhsama Date: Wed, 17 Dec 2025 19:52:02 +0800 Subject: [PATCH 01/82] feat: enhance selection context menu with alignment options and grouping functionality - Added alignment buttons for nodes with tooltips in the selection context menu. - Implemented grouping functionality with a new "Make group" option, including keyboard shortcuts. - Updated translations for the new grouping feature in multiple languages. - Refactored node selection logic to improve performance and readability. --- .../workflow/hooks/use-nodes-interactions.ts | 8 + .../workflow/hooks/use-shortcuts.ts | 14 +- .../workflow/selection-contextmenu.tsx | 203 +++++++++++------- web/app/components/workflow/utils/workflow.ts | 36 ++++ web/i18n/en-US/workflow.ts | 1 + web/i18n/ja-JP/workflow.ts | 1 + web/i18n/zh-Hans/workflow.ts | 1 + web/i18n/zh-Hant/workflow.ts | 1 + 8 files changed, 182 insertions(+), 83 deletions(-) diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index d56b85893e..55640e3af4 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -1996,6 +1996,13 @@ export const useNodesInteractions = () => { setEdges(newEdges) }, [store]) + // Check if there are any nodes selected via box selection (框选) + const hasBundledNodes = useCallback(() => { + const { getNodes } = store.getState() + const nodes = getNodes() + return nodes.some(node => node.data._isBundled) + }, [store]) + return { handleNodeDragStart, handleNodeDrag, @@ -2022,5 +2029,6 @@ export const useNodesInteractions = () => { handleHistoryForward, dimOtherNodes, undimAllNodes, + hasBundledNodes, } } diff --git a/web/app/components/workflow/hooks/use-shortcuts.ts b/web/app/components/workflow/hooks/use-shortcuts.ts index 16502c97c4..5e68989aa4 100644 --- a/web/app/components/workflow/hooks/use-shortcuts.ts +++ b/web/app/components/workflow/hooks/use-shortcuts.ts @@ -27,6 +27,7 @@ export const useShortcuts = (): void => { handleHistoryForward, dimOtherNodes, undimAllNodes, + hasBundledNodes, } = useNodesInteractions() const { shortcutsEnabled: workflowHistoryShortcutsEnabled } = useWorkflowHistoryStore() const { handleSyncWorkflowDraft } = useNodesSyncDraft() @@ -73,7 +74,8 @@ export const useShortcuts = (): void => { useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.c`, (e) => { const { showDebugAndPreviewPanel } = workflowStore.getState() - if (shouldHandleShortcut(e) && !showDebugAndPreviewPanel) { + // Only intercept when nodes are selected via box selection + if (shouldHandleShortcut(e) && !showDebugAndPreviewPanel && hasBundledNodes()) { e.preventDefault() handleNodesCopy() } @@ -94,6 +96,16 @@ export const useShortcuts = (): void => { } }, { exactMatch: true, useCapture: true }) + useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.g`, (e) => { + if (shouldHandleShortcut(e) && hasBundledNodes()) { + e.preventDefault() + // Close selection context menu if open + workflowStore.setState({ selectionMenu: undefined }) + // TODO: handleMakeGroup() - Make group functionality to be implemented + console.info('make group') + } + }, { exactMatch: true, useCapture: true }) + useKeyPress(`${getKeyboardKeyCodeBySystem('alt')}.r`, (e) => { if (shouldHandleShortcut(e)) { e.preventDefault() diff --git a/web/app/components/workflow/selection-contextmenu.tsx b/web/app/components/workflow/selection-contextmenu.tsx index 53392f2cd3..ffebe89c26 100644 --- a/web/app/components/workflow/selection-contextmenu.tsx +++ b/web/app/components/workflow/selection-contextmenu.tsx @@ -8,6 +8,8 @@ import { import { useTranslation } from 'react-i18next' import { useClickAway } from 'ahooks' import { useStore as useReactFlowStore, useStoreApi } from 'reactflow' +import { shallow } from 'zustand/shallow' +import type { FC, ReactElement } from 'react' import { RiAlignBottom, RiAlignCenter, @@ -16,7 +18,9 @@ import { RiAlignRight, RiAlignTop, } from '@remixicon/react' -import { useNodesReadOnly, useNodesSyncDraft } from './hooks' +import Tooltip from '@/app/components/base/tooltip' +import ShortcutsName from './shortcuts-name' +import { useNodesInteractions, useNodesReadOnly, useNodesSyncDraft } from './hooks' import { produce } from 'immer' import { WorkflowHistoryEvent, useWorkflowHistory } from './hooks/use-workflow-history' import { useStore } from './store' @@ -34,21 +38,63 @@ enum AlignType { DistributeVertical = 'distributeVertical', } +type AlignButtonConfig = { + type: AlignType + icon: ReactElement + labelKey: string +} + +type AlignButtonProps = { + config: AlignButtonConfig + onClick: (type: AlignType) => void + position?: 'top' | 'bottom' | 'left' | 'right' +} + +const AlignButton: FC = ({ config, onClick, position = 'bottom' }) => { + return ( + +
onClick(config.type)} + > + {config.icon} +
+
+ ) +} + +const ALIGN_BUTTONS: AlignButtonConfig[] = [ + { type: AlignType.Left, icon: , labelKey: 'workflow.operator.alignLeft' }, + { type: AlignType.Center, icon: , labelKey: 'workflow.operator.alignCenter' }, + { type: AlignType.Right, icon: , labelKey: 'workflow.operator.alignRight' }, + { type: AlignType.DistributeHorizontal, icon: , labelKey: 'workflow.operator.distributeHorizontal' }, + { type: AlignType.Top, icon: , labelKey: 'workflow.operator.alignTop' }, + { type: AlignType.Middle, icon: , labelKey: 'workflow.operator.alignMiddle' }, + { type: AlignType.Bottom, icon: , labelKey: 'workflow.operator.alignBottom' }, + { type: AlignType.DistributeVertical, icon: , labelKey: 'workflow.operator.distributeVertical' }, +] + const SelectionContextmenu = () => { const { t } = useTranslation() const ref = useRef(null) - const { getNodesReadOnly } = useNodesReadOnly() + const { getNodesReadOnly, nodesReadOnly } = useNodesReadOnly() const { handleSelectionContextmenuCancel } = useSelectionInteractions() + const { + handleNodesCopy, + handleNodesDuplicate, + handleNodesDelete, + } = useNodesInteractions() const selectionMenu = useStore(s => s.selectionMenu) // Access React Flow methods const store = useStoreApi() const workflowStore = useWorkflowStore() - // Get selected nodes for alignment logic - const selectedNodes = useReactFlowStore(state => - state.getNodes().filter(node => node.selected), - ) + const selectedNodeIds = useReactFlowStore((state) => { + const ids = state.getNodes().filter(node => node.selected).map(node => node.id) + ids.sort() + return ids + }, shallow) const { handleSyncWorkflowDraft } = useNodesSyncDraft() const { saveStateToHistory } = useWorkflowHistory() @@ -65,9 +111,9 @@ const SelectionContextmenu = () => { if (container) { const { width: containerWidth, height: containerHeight } = container.getBoundingClientRect() - const menuWidth = 240 + const menuWidth = 244 - const estimatedMenuHeight = 380 + const estimatedMenuHeight = 203 if (left + menuWidth > containerWidth) left = left - menuWidth @@ -87,9 +133,9 @@ const SelectionContextmenu = () => { }, ref) useEffect(() => { - if (selectionMenu && selectedNodes.length <= 1) + if (selectionMenu && selectedNodeIds.length <= 1) handleSelectionContextmenuCancel() - }, [selectionMenu, selectedNodes.length, handleSelectionContextmenuCancel]) + }, [selectionMenu, selectedNodeIds.length, handleSelectionContextmenuCancel]) // Handle align nodes logic const handleAlignNode = useCallback((currentNode: any, nodeToAlign: any, alignType: AlignType, minX: number, maxX: number, minY: number, maxY: number) => { @@ -247,7 +293,7 @@ const SelectionContextmenu = () => { }, []) const handleAlignNodes = useCallback((alignType: AlignType) => { - if (getNodesReadOnly() || selectedNodes.length <= 1) { + if (getNodesReadOnly() || selectedNodeIds.length <= 1) { handleSelectionContextmenuCancel() return } @@ -258,9 +304,6 @@ const SelectionContextmenu = () => { // Get all current nodes const nodes = store.getState().getNodes() - // Get all selected nodes - const selectedNodeIds = selectedNodes.map(node => node.id) - // Find container nodes and their children // Container nodes (like Iteration and Loop) have child nodes that should not be aligned independently // when the container is selected. This prevents child nodes from being moved outside their containers. @@ -366,7 +409,7 @@ const SelectionContextmenu = () => { catch (err) { console.error('Failed to update nodes:', err) } - }, [store, workflowStore, selectedNodes, getNodesReadOnly, handleSyncWorkflowDraft, saveStateToHistory, handleSelectionContextmenuCancel, handleAlignNode, handleDistributeNodes]) + }, [getNodesReadOnly, handleAlignNode, handleDistributeNodes, handleSelectionContextmenuCancel, handleSyncWorkflowDraft, saveStateToHistory, selectedNodeIds, store, workflowStore]) if (!selectionMenu) return null @@ -380,73 +423,69 @@ const SelectionContextmenu = () => { }} ref={ref} > -
-
-
- {t('workflow.operator.vertical')} -
-
handleAlignNodes(AlignType.Top)} - > - - {t('workflow.operator.alignTop')} -
-
handleAlignNodes(AlignType.Middle)} - > - - {t('workflow.operator.alignMiddle')} -
-
handleAlignNodes(AlignType.Bottom)} - > - - {t('workflow.operator.alignBottom')} -
-
handleAlignNodes(AlignType.DistributeVertical)} - > - - {t('workflow.operator.distributeVertical')} -
-
-
-
-
- {t('workflow.operator.horizontal')} -
-
handleAlignNodes(AlignType.Left)} - > - - {t('workflow.operator.alignLeft')} -
-
handleAlignNodes(AlignType.Center)} - > - - {t('workflow.operator.alignCenter')} -
-
handleAlignNodes(AlignType.Right)} - > - - {t('workflow.operator.alignRight')} -
-
handleAlignNodes(AlignType.DistributeHorizontal)} - > - - {t('workflow.operator.distributeHorizontal')} -
+
+ {!nodesReadOnly && ( + <> +
+
{ + console.log('make group') + // TODO: Make group functionality + handleSelectionContextmenuCancel() + }} + > + {t('workflow.operator.makeGroup')} + +
+
+
+
+
{ + handleNodesCopy() + handleSelectionContextmenuCancel() + }} + > + {t('workflow.common.copy')} + +
+
{ + handleNodesDuplicate() + handleSelectionContextmenuCancel() + }} + > + {t('workflow.common.duplicate')} + +
+
+
+
+
{ + handleNodesDelete() + handleSelectionContextmenuCancel() + }} + > + {t('common.operation.delete')} + +
+
+
+ + )} +
+ {ALIGN_BUTTONS.map(config => ( + + ))}
diff --git a/web/app/components/workflow/utils/workflow.ts b/web/app/components/workflow/utils/workflow.ts index 14b1eb87d5..865391e0b8 100644 --- a/web/app/components/workflow/utils/workflow.ts +++ b/web/app/components/workflow/utils/workflow.ts @@ -157,6 +157,42 @@ export const getValidTreeNodes = (nodes: Node[], edges: Edge[]) => { } } +export const getCommonPredecessorNodeIds = (selectedNodeIds: string[], edges: Edge[]) => { + const uniqSelectedNodeIds = Array.from(new Set(selectedNodeIds)) + if (uniqSelectedNodeIds.length <= 1) + return [] + + const selectedNodeIdSet = new Set(uniqSelectedNodeIds) + const predecessorNodeIdsMap = new Map>() + + edges.forEach((edge) => { + if (!selectedNodeIdSet.has(edge.target)) + return + + const predecessors = predecessorNodeIdsMap.get(edge.target) ?? new Set() + predecessors.add(edge.source) + predecessorNodeIdsMap.set(edge.target, predecessors) + }) + + let commonPredecessorNodeIds: Set | null = null + + uniqSelectedNodeIds.forEach((nodeId) => { + const predecessors = predecessorNodeIdsMap.get(nodeId) ?? new Set() + + if (!commonPredecessorNodeIds) { + commonPredecessorNodeIds = new Set(predecessors) + return + } + + Array.from(commonPredecessorNodeIds).forEach((predecessorNodeId) => { + if (!predecessors.has(predecessorNodeId)) + commonPredecessorNodeIds!.delete(predecessorNodeId) + }) + }) + + return Array.from(commonPredecessorNodeIds ?? []).sort() +} + export const changeNodesAndEdgesId = (nodes: Node[], edges: Edge[]) => { const idMap = nodes.reduce((acc, node) => { acc[node.id] = uuid4() diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index a023ac2b91..c46ad45996 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -359,6 +359,7 @@ const translation = { zoomTo50: 'Zoom to 50%', zoomTo100: 'Zoom to 100%', zoomToFit: 'Zoom to Fit', + makeGroup: 'Make group', alignNodes: 'Align Nodes', alignLeft: 'Left', alignCenter: 'Center', diff --git a/web/i18n/ja-JP/workflow.ts b/web/i18n/ja-JP/workflow.ts index 7f4e7a3009..850796fa48 100644 --- a/web/i18n/ja-JP/workflow.ts +++ b/web/i18n/ja-JP/workflow.ts @@ -359,6 +359,7 @@ const translation = { zoomTo50: '50% サイズ', zoomTo100: '等倍表示', zoomToFit: '画面に合わせる', + makeGroup: 'グループ化', horizontal: '水平', alignBottom: '下', alignNodes: 'ノードを整列', diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index fd86292252..78deb4bf84 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -359,6 +359,7 @@ const translation = { zoomTo50: '缩放到 50%', zoomTo100: '放大到 100%', zoomToFit: '自适应视图', + makeGroup: '创建分组', alignNodes: '对齐节点', alignLeft: '左对齐', alignCenter: '居中对齐', diff --git a/web/i18n/zh-Hant/workflow.ts b/web/i18n/zh-Hant/workflow.ts index 3da4cc172a..da8b4996cd 100644 --- a/web/i18n/zh-Hant/workflow.ts +++ b/web/i18n/zh-Hant/workflow.ts @@ -344,6 +344,7 @@ const translation = { zoomTo50: '縮放到 50%', zoomTo100: '放大到 100%', zoomToFit: '自適應視圖', + makeGroup: '建立群組', alignNodes: '對齊節點', distributeVertical: '垂直等間距', alignLeft: '左對齊', From e3bfb95c52f4dc2183866738201d8e2cb86568bf Mon Sep 17 00:00:00 2001 From: zhsama Date: Thu, 18 Dec 2025 17:11:34 +0800 Subject: [PATCH 02/82] feat: implement grouping availability checks in selection context menu --- .../workflow/hooks/use-make-group.ts | 136 ++++++++++++++++++ .../workflow/hooks/use-nodes-interactions.ts | 24 +++- .../workflow/hooks/use-shortcuts.ts | 4 +- .../workflow/selection-contextmenu.tsx | 13 +- web/app/components/workflow/utils/workflow.ts | 53 +++++++ 5 files changed, 226 insertions(+), 4 deletions(-) create mode 100644 web/app/components/workflow/hooks/use-make-group.ts diff --git a/web/app/components/workflow/hooks/use-make-group.ts b/web/app/components/workflow/hooks/use-make-group.ts new file mode 100644 index 0000000000..2326e10390 --- /dev/null +++ b/web/app/components/workflow/hooks/use-make-group.ts @@ -0,0 +1,136 @@ +import { useMemo } from 'react' +import { useStore as useReactFlowStore } from 'reactflow' +import { getCommonPredecessorHandles } from '../utils' +import type { PredecessorHandle } from '../utils' +import { shallow } from 'zustand/shallow' + +export type MakeGroupAvailability = { + canMakeGroup: boolean + branchEntryNodeIds: string[] + commonPredecessorHandle?: PredecessorHandle +} + +type MinimalEdge = { + id: string + source: string + sourceHandle: string + target: string +} + +/** + * Pure function to check if the selected nodes can be grouped. + * Can be called both from React hooks and imperatively. + */ +export const checkMakeGroupAvailability = ( + selectedNodeIds: string[], + edges: MinimalEdge[], +): MakeGroupAvailability => { + // Make group requires selecting at least 2 nodes. + if (selectedNodeIds.length <= 1) { + return { + canMakeGroup: false, + branchEntryNodeIds: [], + commonPredecessorHandle: undefined, + } + } + + const selectedNodeIdSet = new Set(selectedNodeIds) + const inboundFromOutsideTargets = new Set() + const incomingEdgeCounts = new Map() + const incomingFromSelectedTargets = new Set() + + edges.forEach((edge) => { + // Only consider edges whose target is inside the selected subgraph. + if (!selectedNodeIdSet.has(edge.target)) + return + + incomingEdgeCounts.set(edge.target, (incomingEdgeCounts.get(edge.target) ?? 0) + 1) + + if (selectedNodeIdSet.has(edge.source)) + incomingFromSelectedTargets.add(edge.target) + else + inboundFromOutsideTargets.add(edge.target) + }) + + // Branch head (entry) definition: + // - has at least one incoming edge + // - and all its incoming edges come from outside the selected subgraph + const branchEntryNodeIds = selectedNodeIds.filter((nodeId) => { + const incomingEdgeCount = incomingEdgeCounts.get(nodeId) ?? 0 + if (incomingEdgeCount === 0) + return false + + return !incomingFromSelectedTargets.has(nodeId) + }) + + // No branch head means we cannot tell how many branches are represented by this selection. + if (branchEntryNodeIds.length === 0) { + return { + canMakeGroup: false, + branchEntryNodeIds, + commonPredecessorHandle: undefined, + } + } + + // Guardrail: disallow side entrances into the selected subgraph. + // If an outside node connects to a non-entry node inside the selection, the grouping boundary is ambiguous. + const branchEntryNodeIdSet = new Set(branchEntryNodeIds) + const hasInboundToNonEntryNode = Array.from(inboundFromOutsideTargets).some(nodeId => !branchEntryNodeIdSet.has(nodeId)) + + if (hasInboundToNonEntryNode) { + return { + canMakeGroup: false, + branchEntryNodeIds, + commonPredecessorHandle: undefined, + } + } + + // Compare the branch heads by their common predecessor "handler" (source node + sourceHandle). + // This is required for multi-handle nodes like If-Else / Classifier where different branches use different handles. + const commonPredecessorHandles = getCommonPredecessorHandles( + branchEntryNodeIds, + // Only look at edges coming from outside the selected subgraph when determining the "pre" handler. + edges.filter(edge => !selectedNodeIdSet.has(edge.source)), + ) + + if (commonPredecessorHandles.length !== 1) { + return { + canMakeGroup: false, + branchEntryNodeIds, + commonPredecessorHandle: undefined, + } + } + + return { + canMakeGroup: true, + branchEntryNodeIds, + commonPredecessorHandle: commonPredecessorHandles[0], + } +} + +export const useMakeGroupAvailability = (selectedNodeIds: string[]): MakeGroupAvailability => { + // Subscribe to the minimal edge state we need (source/sourceHandle/target) to avoid + // snowball rerenders caused by subscribing to the entire `edges` objects. + const edgeKeys = useReactFlowStore((state) => { + const delimiter = '\u0000' + const keys = state.edges.map(edge => `${edge.source}${delimiter}${edge.sourceHandle || 'source'}${delimiter}${edge.target}`) + keys.sort() + return keys + }, shallow) + + return useMemo(() => { + // Reconstruct a minimal edge list from `edgeKeys` for downstream graph checks. + const delimiter = '\u0000' + const edges = edgeKeys.map((key) => { + const [source, handleId, target] = key.split(delimiter) + return { + id: key, + source, + sourceHandle: handleId || 'source', + target, + } + }) + + return checkMakeGroupAvailability(selectedNodeIds, edges) + }, [edgeKeys, selectedNodeIds]) +} diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index 55640e3af4..bb908a26e4 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -49,6 +49,7 @@ import { useNodeLoopInteractions } from '../nodes/loop/use-interactions' import { useWorkflowHistoryStore } from '../workflow-history-store' import { useNodesSyncDraft } from './use-nodes-sync-draft' import { useHelpline } from './use-helpline' +import { checkMakeGroupAvailability } from './use-make-group' import { useNodesReadOnly, useWorkflow, @@ -1996,13 +1997,33 @@ export const useNodesInteractions = () => { setEdges(newEdges) }, [store]) - // Check if there are any nodes selected via box selection (框选) + // Check if there are any nodes selected via box selection const hasBundledNodes = useCallback(() => { const { getNodes } = store.getState() const nodes = getNodes() return nodes.some(node => node.data._isBundled) }, [store]) + // Check if the current box selection can be grouped + const getCanMakeGroup = useCallback(() => { + const { getNodes, edges } = store.getState() + const nodes = getNodes() + const bundledNodeIds = nodes.filter(node => node.data._isBundled).map(node => node.id) + + if (bundledNodeIds.length <= 1) + return false + + const minimalEdges = edges.map(edge => ({ + id: edge.id, + source: edge.source, + sourceHandle: edge.sourceHandle || 'source', + target: edge.target, + })) + + const { canMakeGroup } = checkMakeGroupAvailability(bundledNodeIds, minimalEdges) + return canMakeGroup + }, [store]) + return { handleNodeDragStart, handleNodeDrag, @@ -2030,5 +2051,6 @@ export const useNodesInteractions = () => { dimOtherNodes, undimAllNodes, hasBundledNodes, + getCanMakeGroup, } } diff --git a/web/app/components/workflow/hooks/use-shortcuts.ts b/web/app/components/workflow/hooks/use-shortcuts.ts index 5e68989aa4..d4689ebe68 100644 --- a/web/app/components/workflow/hooks/use-shortcuts.ts +++ b/web/app/components/workflow/hooks/use-shortcuts.ts @@ -28,6 +28,7 @@ export const useShortcuts = (): void => { dimOtherNodes, undimAllNodes, hasBundledNodes, + getCanMakeGroup, } = useNodesInteractions() const { shortcutsEnabled: workflowHistoryShortcutsEnabled } = useWorkflowHistoryStore() const { handleSyncWorkflowDraft } = useNodesSyncDraft() @@ -97,7 +98,8 @@ export const useShortcuts = (): void => { }, { exactMatch: true, useCapture: true }) useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.g`, (e) => { - if (shouldHandleShortcut(e) && hasBundledNodes()) { + // Only intercept when the selection can be grouped + if (shouldHandleShortcut(e) && getCanMakeGroup()) { e.preventDefault() // Close selection context menu if open workflowStore.setState({ selectionMenu: undefined }) diff --git a/web/app/components/workflow/selection-contextmenu.tsx b/web/app/components/workflow/selection-contextmenu.tsx index ffebe89c26..e3ef16acc2 100644 --- a/web/app/components/workflow/selection-contextmenu.tsx +++ b/web/app/components/workflow/selection-contextmenu.tsx @@ -25,6 +25,7 @@ import { produce } from 'immer' import { WorkflowHistoryEvent, useWorkflowHistory } from './hooks/use-workflow-history' import { useStore } from './store' import { useSelectionInteractions } from './hooks/use-selection-interactions' +import { useMakeGroupAvailability } from './hooks/use-make-group' import { useWorkflowStore } from './store' enum AlignType { @@ -96,6 +97,8 @@ const SelectionContextmenu = () => { return ids }, shallow) + const { canMakeGroup } = useMakeGroupAvailability(selectedNodeIds) + const { handleSyncWorkflowDraft } = useNodesSyncDraft() const { saveStateToHistory } = useWorkflowHistory() @@ -428,15 +431,21 @@ const SelectionContextmenu = () => { <>
{ + if (!canMakeGroup) + return console.log('make group') // TODO: Make group functionality handleSelectionContextmenuCancel() }} > {t('workflow.operator.makeGroup')} - +
diff --git a/web/app/components/workflow/utils/workflow.ts b/web/app/components/workflow/utils/workflow.ts index 865391e0b8..2c36ef82e7 100644 --- a/web/app/components/workflow/utils/workflow.ts +++ b/web/app/components/workflow/utils/workflow.ts @@ -193,6 +193,59 @@ export const getCommonPredecessorNodeIds = (selectedNodeIds: string[], edges: Ed return Array.from(commonPredecessorNodeIds ?? []).sort() } +export type PredecessorHandle = { + nodeId: string + handleId: string +} + +export const getCommonPredecessorHandles = (targetNodeIds: string[], edges: Edge[]): PredecessorHandle[] => { + const uniqTargetNodeIds = Array.from(new Set(targetNodeIds)) + if (uniqTargetNodeIds.length === 0) + return [] + + // Get the "direct predecessor handler", which is: + // - edge.source (predecessor node) + // - edge.sourceHandle (the specific output handle of the predecessor; defaults to 'source' if not set) + // Used to handle multi-handle branch scenarios like If-Else / Classifier. + const targetNodeIdSet = new Set(uniqTargetNodeIds) + const predecessorHandleMap = new Map>() // targetNodeId -> Set<`${source}\0${handleId}`> + const delimiter = '\u0000' + + edges.forEach((edge) => { + if (!targetNodeIdSet.has(edge.target)) + return + + const predecessors = predecessorHandleMap.get(edge.target) ?? new Set() + const handleId = edge.sourceHandle || 'source' + predecessors.add(`${edge.source}${delimiter}${handleId}`) + predecessorHandleMap.set(edge.target, predecessors) + }) + + // Intersect predecessor handlers of all targets, keeping only handlers common to all targets. + let commonKeys: Set | null = null + + uniqTargetNodeIds.forEach((nodeId) => { + const keys = predecessorHandleMap.get(nodeId) ?? new Set() + + if (!commonKeys) { + commonKeys = new Set(keys) + return + } + + Array.from(commonKeys).forEach((key) => { + if (!keys.has(key)) + commonKeys!.delete(key) + }) + }) + + return Array.from(commonKeys ?? []) + .map((key) => { + const [nodeId, handleId] = key.split(delimiter) + return { nodeId, handleId } + }) + .sort((a, b) => a.nodeId.localeCompare(b.nodeId) || a.handleId.localeCompare(b.handleId)) +} + export const changeNodesAndEdgesId = (nodes: Node[], edges: Edge[]) => { const idMap = nodes.reduce((acc, node) => { acc[node.id] = uuid4() From fc9d5b2a62f8f07dfac2b182e446b9333b87da36 Mon Sep 17 00:00:00 2001 From: zhsama Date: Fri, 19 Dec 2025 15:17:45 +0800 Subject: [PATCH 03/82] feat: implement group node functionality and enhance grouping interactions --- web/app/components/workflow/block-icon.tsx | 3 + .../workflow/hooks/use-nodes-interactions.ts | 155 ++++++++++++++++++ .../workflow/hooks/use-shortcuts.ts | 4 +- .../workflow-panel/last-run/use-last-run.ts | 2 + .../components/workflow/nodes/components.ts | 4 + .../components/workflow/nodes/group/node.tsx | 86 ++++++++++ .../components/workflow/nodes/group/panel.tsx | 9 + .../components/workflow/nodes/group/types.ts | 17 ++ .../workflow/selection-contextmenu.tsx | 6 +- web/app/components/workflow/types.ts | 3 + 10 files changed, 284 insertions(+), 5 deletions(-) create mode 100644 web/app/components/workflow/nodes/group/node.tsx create mode 100644 web/app/components/workflow/nodes/group/panel.tsx create mode 100644 web/app/components/workflow/nodes/group/types.ts diff --git a/web/app/components/workflow/block-icon.tsx b/web/app/components/workflow/block-icon.tsx index a4f53f2a64..ce6098c304 100644 --- a/web/app/components/workflow/block-icon.tsx +++ b/web/app/components/workflow/block-icon.tsx @@ -26,6 +26,7 @@ import { VariableX, WebhookLine, } from '@/app/components/base/icons/src/vender/workflow' +import { Folder as FolderLine } from '@/app/components/base/icons/src/vender/line/files' import AppIcon from '@/app/components/base/app-icon' import cn from '@/utils/classnames' @@ -54,6 +55,7 @@ const DEFAULT_ICON_MAP: Record = { [BlockEnum.VariableAssigner]: 'bg-util-colors-blue-blue-500', [BlockEnum.VariableAggregator]: 'bg-util-colors-blue-blue-500', [BlockEnum.Tool]: 'bg-util-colors-blue-blue-500', + [BlockEnum.Group]: 'bg-util-colors-blue-blue-500', [BlockEnum.Assigner]: 'bg-util-colors-blue-blue-500', [BlockEnum.ParameterExtractor]: 'bg-util-colors-blue-blue-500', [BlockEnum.DocExtractor]: 'bg-util-colors-green-green-500', diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index bb908a26e4..76be3766f4 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -44,6 +44,7 @@ import type { LoopNodeType } from '../nodes/loop/types' import { CUSTOM_ITERATION_START_NODE } from '../nodes/iteration-start/constants' import { CUSTOM_LOOP_START_NODE } from '../nodes/loop-start/constants' import type { VariableAssignerNodeType } from '../nodes/variable-assigner/types' +import type { GroupHandler, GroupMember, GroupNodeData } from '../nodes/group/types' import { useNodeIterationInteractions } from '../nodes/iteration/use-interactions' import { useNodeLoopInteractions } from '../nodes/loop/use-interactions' import { useWorkflowHistoryStore } from '../workflow-history-store' @@ -2024,6 +2025,159 @@ export const useNodesInteractions = () => { return canMakeGroup }, [store]) + const handleMakeGroup = useCallback(() => { + const { getNodes, setNodes, edges, setEdges } = store.getState() + const nodes = getNodes() + const bundledNodes = nodes.filter(node => node.data._isBundled) + const bundledNodeIds = bundledNodes.map(node => node.id) + + if (bundledNodeIds.length <= 1) + return + + const minimalEdges = edges.map(edge => ({ + id: edge.id, + source: edge.source, + sourceHandle: edge.sourceHandle || 'source', + target: edge.target, + })) + + const { canMakeGroup } = checkMakeGroupAvailability(bundledNodeIds, minimalEdges) + if (!canMakeGroup) + return + + const bundledNodeIdSet = new Set(bundledNodeIds) + const bundledNodeIdIsLeaf = new Set() + const inboundEdges = edges.filter(edge => !bundledNodeIdSet.has(edge.source) && bundledNodeIdSet.has(edge.target)) + const outboundEdges = edges.filter(edge => bundledNodeIdSet.has(edge.source) && !bundledNodeIdSet.has(edge.target)) + + // leaf node: no outbound edges to other nodes in the selection + const leafNodeIds = bundledNodes + .filter(node => !edges.some(edge => edge.source === node.id && bundledNodeIdSet.has(edge.target))) + .map(node => node.id) + leafNodeIds.forEach(id => bundledNodeIdIsLeaf.add(id)) + + const members: GroupMember[] = bundledNodes.map((node) => { + return { + id: node.id, + type: node.data.type, + label: node.data.title, + } + }) + const handlers: GroupHandler[] = leafNodeIds.map((nodeId) => { + const node = bundledNodes.find(n => n.id === nodeId) + return { + id: nodeId, + label: node?.data.title || nodeId, + } + }) + + // put the group node at the top-left corner of the selection, slightly offset + const { x: minX, y: minY } = getTopLeftNodePosition(bundledNodes) + + const groupNodeData: GroupNodeData = { + title: t('workflow.operator.makeGroup'), + desc: '', + type: BlockEnum.Group, + members, + handlers, + selected: true, + } + + const { newNode: groupNode } = generateNewNode({ + data: groupNodeData, + position: { + x: minX - 20, + y: minY - 20, + }, + }) + + const nodeTypeMap = new Map(nodes.map(node => [node.id, node.data.type])) + + const newNodes = produce(nodes, (draft) => { + draft.forEach((node) => { + if (bundledNodeIdSet.has(node.id)) { + node.data._isBundled = false + node.selected = false + node.hidden = true + node.data._hiddenInGroupId = groupNode.id + } + else { + node.data._isBundled = false + } + }) + draft.push(groupNode) + }) + + const newEdges = produce(edges, (draft) => { + draft.forEach((edge) => { + if (bundledNodeIdSet.has(edge.source) || bundledNodeIdSet.has(edge.target)) { + edge.hidden = true + edge.data = { + ...edge.data, + _hiddenInGroupId: groupNode.id, + _isBundled: false, + } + } + else if (edge.data?._isBundled) { + edge.data._isBundled = false + } + }) + + // re-add the external inbound edges to the group node (previous order is not lost) + inboundEdges.forEach((edge) => { + draft.push({ + id: `${edge.id}__to-${groupNode.id}`, + type: edge.type || CUSTOM_EDGE, + source: edge.source, + target: groupNode.id, + sourceHandle: edge.sourceHandle, + targetHandle: 'target', + data: { + ...edge.data, + sourceType: nodeTypeMap.get(edge.source)!, + targetType: BlockEnum.Group, + _hiddenInGroupId: undefined, + _isBundled: false, + }, + zIndex: edge.zIndex, + }) + }) + + // outbound edges of the group node: only map the outbound edges of the leaf nodes to the corresponding handlers + outboundEdges.forEach((edge) => { + if (!bundledNodeIdIsLeaf.has(edge.source)) + return + + draft.push({ + id: `${groupNode.id}-${edge.target}-${edge.targetHandle || 'target'}-${edge.source}`, + type: edge.type || CUSTOM_EDGE, + source: groupNode.id, + target: edge.target, + sourceHandle: edge.source, // handler id corresponds to the leaf node id + targetHandle: edge.targetHandle, + data: { + ...edge.data, + sourceType: BlockEnum.Group, + targetType: nodeTypeMap.get(edge.target)!, + _hiddenInGroupId: undefined, + _isBundled: false, + }, + zIndex: edge.zIndex, + }) + }) + }) + + setNodes(newNodes) + setEdges(newEdges) + workflowStore.setState({ + selectionMenu: undefined, + }) + handleSyncWorkflowDraft() + saveStateToHistory(WorkflowHistoryEvent.NodeAdd, { + nodeId: groupNode.id, + }) + }, [handleSyncWorkflowDraft, saveStateToHistory, store, t, workflowStore]) + return { handleNodeDragStart, handleNodeDrag, @@ -2044,6 +2198,7 @@ export const useNodesInteractions = () => { handleNodesPaste, handleNodesDuplicate, handleNodesDelete, + handleMakeGroup, handleNodeResize, handleNodeDisconnect, handleHistoryBack, diff --git a/web/app/components/workflow/hooks/use-shortcuts.ts b/web/app/components/workflow/hooks/use-shortcuts.ts index d4689ebe68..f8c3fad700 100644 --- a/web/app/components/workflow/hooks/use-shortcuts.ts +++ b/web/app/components/workflow/hooks/use-shortcuts.ts @@ -29,6 +29,7 @@ export const useShortcuts = (): void => { undimAllNodes, hasBundledNodes, getCanMakeGroup, + handleMakeGroup, } = useNodesInteractions() const { shortcutsEnabled: workflowHistoryShortcutsEnabled } = useWorkflowHistoryStore() const { handleSyncWorkflowDraft } = useNodesSyncDraft() @@ -103,8 +104,7 @@ export const useShortcuts = (): void => { e.preventDefault() // Close selection context menu if open workflowStore.setState({ selectionMenu: undefined }) - // TODO: handleMakeGroup() - Make group functionality to be implemented - console.info('make group') + handleMakeGroup() } }, { exactMatch: true, useCapture: true }) diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts index ac9f2051c3..4b4d3b77c6 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts @@ -56,6 +56,7 @@ const singleRunFormParamsHooks: Record = { [BlockEnum.VariableAggregator]: useVariableAggregatorSingleRunFormParams, [BlockEnum.Assigner]: useVariableAssignerSingleRunFormParams, [BlockEnum.KnowledgeBase]: useKnowledgeBaseSingleRunFormParams, + [BlockEnum.Group]: undefined, [BlockEnum.VariableAssigner]: undefined, [BlockEnum.End]: undefined, [BlockEnum.Answer]: undefined, @@ -103,6 +104,7 @@ const getDataForCheckMoreHooks: Record = { [BlockEnum.DataSource]: undefined, [BlockEnum.DataSourceEmpty]: undefined, [BlockEnum.KnowledgeBase]: undefined, + [BlockEnum.Group]: undefined, [BlockEnum.TriggerWebhook]: undefined, [BlockEnum.TriggerSchedule]: undefined, [BlockEnum.TriggerPlugin]: useTriggerPluginGetDataForCheckMore, diff --git a/web/app/components/workflow/nodes/components.ts b/web/app/components/workflow/nodes/components.ts index d8da8b9dae..8b17141560 100644 --- a/web/app/components/workflow/nodes/components.ts +++ b/web/app/components/workflow/nodes/components.ts @@ -48,6 +48,8 @@ import TriggerWebhookNode from './trigger-webhook/node' import TriggerWebhookPanel from './trigger-webhook/panel' import TriggerPluginNode from './trigger-plugin/node' import TriggerPluginPanel from './trigger-plugin/panel' +import GroupNode from './group/node' +import GroupPanel from './group/panel' export const NodeComponentMap: Record> = { [BlockEnum.Start]: StartNode, @@ -75,6 +77,7 @@ export const NodeComponentMap: Record> = { [BlockEnum.TriggerSchedule]: TriggerScheduleNode, [BlockEnum.TriggerWebhook]: TriggerWebhookNode, [BlockEnum.TriggerPlugin]: TriggerPluginNode, + [BlockEnum.Group]: GroupNode, } export const PanelComponentMap: Record> = { @@ -103,4 +106,5 @@ export const PanelComponentMap: Record> = { [BlockEnum.TriggerSchedule]: TriggerSchedulePanel, [BlockEnum.TriggerWebhook]: TriggerWebhookPanel, [BlockEnum.TriggerPlugin]: TriggerPluginPanel, + [BlockEnum.Group]: GroupPanel, } diff --git a/web/app/components/workflow/nodes/group/node.tsx b/web/app/components/workflow/nodes/group/node.tsx new file mode 100644 index 0000000000..0d59b3fbb5 --- /dev/null +++ b/web/app/components/workflow/nodes/group/node.tsx @@ -0,0 +1,86 @@ +import { memo, useMemo } from 'react' +import { RiArrowRightSLine } from '@remixicon/react' +import BlockIcon from '@/app/components/workflow/block-icon' +import type { NodeProps } from '@/app/components/workflow/types' +import type { GroupHandler, GroupMember, GroupNodeData } from './types' +import type { BlockEnum } from '@/app/components/workflow/types' +import cn from '@/utils/classnames' + +const MAX_MEMBER_ICONS = 12 + +const GroupNode = (props: NodeProps) => { + const { data } = props + + // show the explicitly passed members first; otherwise use the _children information to fill the type + const members: GroupMember[] = useMemo(() => ( + data.members?.length + ? data.members + : data._children?.length + ? data._children.map(child => ({ + id: child.nodeId, + type: child.nodeType as BlockEnum, + label: child.nodeType, + })) + : [] + ), [data._children, data.members]) + + // handler 列表:优先使用传入的 handlers,缺省时用 members 的 label 填充。 + const handlers: GroupHandler[] = useMemo(() => ( + data.handlers?.length + ? data.handlers + : members.length + ? members.map(member => ({ + id: member.id, + label: member.label || member.id, + })) + : [] + ), [data.handlers, members]) + + return ( +
+ {members.length > 0 && ( +
+
+ {members.slice(0, MAX_MEMBER_ICONS).map(member => ( +
+ +
+ ))} + {members.length > MAX_MEMBER_ICONS && ( +
+ +{members.length - MAX_MEMBER_ICONS} +
+ )} +
+ +
+ )} + {handlers.length > 0 && ( +
+ {handlers.map(handler => ( +
+ {handler.label || handler.id} +
+ ))} +
+ )} +
+ ) +} + +GroupNode.displayName = 'GroupNode' + +export default memo(GroupNode) diff --git a/web/app/components/workflow/nodes/group/panel.tsx b/web/app/components/workflow/nodes/group/panel.tsx new file mode 100644 index 0000000000..a36d074e9d --- /dev/null +++ b/web/app/components/workflow/nodes/group/panel.tsx @@ -0,0 +1,9 @@ +import { memo } from 'react' + +const GroupPanel = () => { + return null +} + +GroupPanel.displayName = 'GroupPanel' + +export default memo(GroupPanel) diff --git a/web/app/components/workflow/nodes/group/types.ts b/web/app/components/workflow/nodes/group/types.ts new file mode 100644 index 0000000000..e54119381e --- /dev/null +++ b/web/app/components/workflow/nodes/group/types.ts @@ -0,0 +1,17 @@ +import type { BlockEnum, CommonNodeType } from '../../types' + +export type GroupMember = { + id: string + type: BlockEnum + label?: string +} + +export type GroupHandler = { + id: string + label?: string +} + +export type GroupNodeData = CommonNodeType<{ + members?: GroupMember[] + handlers?: GroupHandler[] +}> diff --git a/web/app/components/workflow/selection-contextmenu.tsx b/web/app/components/workflow/selection-contextmenu.tsx index e3ef16acc2..309870243b 100644 --- a/web/app/components/workflow/selection-contextmenu.tsx +++ b/web/app/components/workflow/selection-contextmenu.tsx @@ -24,7 +24,7 @@ import { useNodesInteractions, useNodesReadOnly, useNodesSyncDraft } from './hoo import { produce } from 'immer' import { WorkflowHistoryEvent, useWorkflowHistory } from './hooks/use-workflow-history' import { useStore } from './store' -import { useSelectionInteractions } from './hooks/use-selection-interactions' +import { useSelectionInteractions } from '@/app/components/workflow/hooks' import { useMakeGroupAvailability } from './hooks/use-make-group' import { useWorkflowStore } from './store' @@ -84,6 +84,7 @@ const SelectionContextmenu = () => { handleNodesCopy, handleNodesDuplicate, handleNodesDelete, + handleMakeGroup, } = useNodesInteractions() const selectionMenu = useStore(s => s.selectionMenu) @@ -439,8 +440,7 @@ const SelectionContextmenu = () => { onClick={() => { if (!canMakeGroup) return - console.log('make group') - // TODO: Make group functionality + handleMakeGroup() handleSelectionContextmenuCancel() }} > diff --git a/web/app/components/workflow/types.ts b/web/app/components/workflow/types.ts index 5ae8d530a8..77fabd038c 100644 --- a/web/app/components/workflow/types.ts +++ b/web/app/components/workflow/types.ts @@ -31,6 +31,7 @@ export enum BlockEnum { Code = 'code', TemplateTransform = 'template-transform', HttpRequest = 'http-request', + Group = 'group', VariableAssigner = 'variable-assigner', VariableAggregator = 'variable-aggregator', Tool = 'tool', @@ -80,6 +81,7 @@ export type CommonNodeType = { _isEntering?: boolean _showAddVariablePopup?: boolean _holdAddVariablePopup?: boolean + _hiddenInGroupId?: string _iterationLength?: number _iterationIndex?: number _waitingRun?: boolean @@ -114,6 +116,7 @@ export type CommonEdgeType = { _connectedNodeIsHovering?: boolean _connectedNodeIsSelected?: boolean _isBundled?: boolean + _hiddenInGroupId?: string _sourceRunningStatus?: NodeRunningStatus _targetRunningStatus?: NodeRunningStatus _waitingRun?: boolean From 93b516a4ec4ab6d4846c3a631b7dc02bb72ec98b Mon Sep 17 00:00:00 2001 From: zhsama Date: Mon, 22 Dec 2025 17:35:33 +0800 Subject: [PATCH 04/82] feat: add UI-only group node types and enhance workflow graph processing --- .gitignore | 1 + api/core/workflow/graph/graph.py | 12 +- .../workflow/custom-group-node/constants.ts | 11 ++ .../custom-group-exit-port-node.tsx | 54 ++++++++ .../custom-group-input-node.tsx | 55 ++++++++ .../custom-group-node/custom-group-node.tsx | 49 +++++++ .../workflow/custom-group-node/index.ts | 19 +++ .../workflow/custom-group-node/types.ts | 80 +++++++++++ .../workflow/design/ui-only-group.md | 127 ++++++++++++++++++ web/app/components/workflow/index.tsx | 11 ++ .../workflow/utils/workflow-init.ts | 68 +++++++++- 11 files changed, 483 insertions(+), 4 deletions(-) create mode 100644 web/app/components/workflow/custom-group-node/constants.ts create mode 100644 web/app/components/workflow/custom-group-node/custom-group-exit-port-node.tsx create mode 100644 web/app/components/workflow/custom-group-node/custom-group-input-node.tsx create mode 100644 web/app/components/workflow/custom-group-node/custom-group-node.tsx create mode 100644 web/app/components/workflow/custom-group-node/index.ts create mode 100644 web/app/components/workflow/custom-group-node/types.ts create mode 100644 web/app/components/workflow/design/ui-only-group.md diff --git a/.gitignore b/.gitignore index 5ad728c3da..ca0aa3e48f 100644 --- a/.gitignore +++ b/.gitignore @@ -210,6 +210,7 @@ web/.vscode .history .idea/ +web/migration/ # pnpm /.pnpm-store diff --git a/api/core/workflow/graph/graph.py b/api/core/workflow/graph/graph.py index ba5a01fc94..4eb5d94b20 100644 --- a/api/core/workflow/graph/graph.py +++ b/api/core/workflow/graph/graph.py @@ -130,12 +130,15 @@ class Graph: @classmethod def _build_edges( - cls, edge_configs: list[dict[str, object]] + cls, + edge_configs: list[dict[str, object]], + valid_node_ids: set[str] | None = None, ) -> tuple[dict[str, Edge], dict[str, list[str]], dict[str, list[str]]]: """ Build edge objects and mappings from edge configurations. :param edge_configs: list of edge configurations + :param valid_node_ids: optional set of valid node IDs to filter edges :return: tuple of (edges dict, in_edges dict, out_edges dict) """ edges: dict[str, Edge] = {} @@ -305,7 +308,12 @@ class Graph: if not node_configs: raise ValueError("Graph must have at least one node") - node_configs = [node_config for node_config in node_configs if node_config.get("type", "") != "custom-note"] + # Filter out UI-only nodes that should not participate in workflow execution + # These include ReactFlow node types (custom-*) and data types that UI-only nodes may use + ui_only_node_types = {"custom-note", "custom-group", "custom-group-input", "custom-group-exit-port", "group"} + node_configs = [ + node_config for node_config in node_configs if node_config.get("type", "") not in ui_only_node_types + ] # Parse node configurations node_configs_map = cls._parse_node_configs(node_configs) diff --git a/web/app/components/workflow/custom-group-node/constants.ts b/web/app/components/workflow/custom-group-node/constants.ts new file mode 100644 index 0000000000..5b65aaa80b --- /dev/null +++ b/web/app/components/workflow/custom-group-node/constants.ts @@ -0,0 +1,11 @@ +export const CUSTOM_GROUP_NODE = 'custom-group' +export const CUSTOM_GROUP_INPUT_NODE = 'custom-group-input' +export const CUSTOM_GROUP_EXIT_PORT_NODE = 'custom-group-exit-port' + +export const GROUP_CHILDREN_Z_INDEX = 1002 + +export const UI_ONLY_GROUP_NODE_TYPES = new Set([ + CUSTOM_GROUP_NODE, + CUSTOM_GROUP_INPUT_NODE, + CUSTOM_GROUP_EXIT_PORT_NODE, +]) diff --git a/web/app/components/workflow/custom-group-node/custom-group-exit-port-node.tsx b/web/app/components/workflow/custom-group-node/custom-group-exit-port-node.tsx new file mode 100644 index 0000000000..45af811618 --- /dev/null +++ b/web/app/components/workflow/custom-group-node/custom-group-exit-port-node.tsx @@ -0,0 +1,54 @@ +'use client' + +import type { FC } from 'react' +import { memo } from 'react' +import { Handle, Position } from 'reactflow' +import type { CustomGroupExitPortNodeData } from './types' +import cn from '@/utils/classnames' + +type CustomGroupExitPortNodeProps = { + id: string + data: CustomGroupExitPortNodeData +} + +const CustomGroupExitPortNode: FC = ({ id: _id, data }) => { + return ( +
+ {/* Target handle - receives internal connections from leaf nodes */} + + + {/* Source handle - connects to external nodes */} + + + {/* Icon */} + + + +
+ ) +} + +export default memo(CustomGroupExitPortNode) diff --git a/web/app/components/workflow/custom-group-node/custom-group-input-node.tsx b/web/app/components/workflow/custom-group-node/custom-group-input-node.tsx new file mode 100644 index 0000000000..18af6f698d --- /dev/null +++ b/web/app/components/workflow/custom-group-node/custom-group-input-node.tsx @@ -0,0 +1,55 @@ +'use client' + +import type { FC } from 'react' +import { memo } from 'react' +import { Handle, Position } from 'reactflow' +import type { CustomGroupInputNodeData } from './types' +import cn from '@/utils/classnames' + +type CustomGroupInputNodeProps = { + id: string + data: CustomGroupInputNodeData +} + +const CustomGroupInputNode: FC = ({ id: _id, data }) => { + return ( +
+ {/* Target handle - receives external connections */} + + + {/* Source handle - connects to entry nodes */} + + + {/* Icon */} + + + + +
+ ) +} + +export default memo(CustomGroupInputNode) diff --git a/web/app/components/workflow/custom-group-node/custom-group-node.tsx b/web/app/components/workflow/custom-group-node/custom-group-node.tsx new file mode 100644 index 0000000000..f41eb4bad3 --- /dev/null +++ b/web/app/components/workflow/custom-group-node/custom-group-node.tsx @@ -0,0 +1,49 @@ +'use client' + +import type { FC } from 'react' +import { memo } from 'react' +import { Handle, Position } from 'reactflow' +import type { CustomGroupNodeData } from './types' +import cn from '@/utils/classnames' + +type CustomGroupNodeProps = { + id: string + data: CustomGroupNodeData +} + +const CustomGroupNode: FC = ({ id: _id, data }) => { + const { group } = data + + return ( +
+ {/* Group Header */} +
+ + {group.title} + +
+ + {/* Target handle for incoming connections */} + + + {/* Source handles will be rendered by exit port nodes */} +
+ ) +} + +export default memo(CustomGroupNode) diff --git a/web/app/components/workflow/custom-group-node/index.ts b/web/app/components/workflow/custom-group-node/index.ts new file mode 100644 index 0000000000..a85384f1a0 --- /dev/null +++ b/web/app/components/workflow/custom-group-node/index.ts @@ -0,0 +1,19 @@ +export { + CUSTOM_GROUP_NODE, + CUSTOM_GROUP_INPUT_NODE, + CUSTOM_GROUP_EXIT_PORT_NODE, + GROUP_CHILDREN_Z_INDEX, + UI_ONLY_GROUP_NODE_TYPES, +} from './constants' + +export type { + CustomGroupNodeData, + CustomGroupInputNodeData, + CustomGroupExitPortNodeData, + ExitPortInfo, + GroupMember, +} from './types' + +export { default as CustomGroupNode } from './custom-group-node' +export { default as CustomGroupInputNode } from './custom-group-input-node' +export { default as CustomGroupExitPortNode } from './custom-group-exit-port-node' diff --git a/web/app/components/workflow/custom-group-node/types.ts b/web/app/components/workflow/custom-group-node/types.ts new file mode 100644 index 0000000000..7e30d6f630 --- /dev/null +++ b/web/app/components/workflow/custom-group-node/types.ts @@ -0,0 +1,80 @@ +import type { BlockEnum } from '../types' + +/** + * Exit port info stored in Group node + */ +export type ExitPortInfo = { + portNodeId: string + leafNodeId: string + sourceHandle: string + name: string +} + +/** + * Group node data structure + * node.type = 'custom-group' + * node.data.type = '' (empty string to bypass backend NodeType validation) + */ +export type CustomGroupNodeData = { + type: '' // Empty string bypasses backend NodeType validation + title: string + desc?: string + group: { + groupId: string + title: string + memberNodeIds: string[] + entryNodeIds: string[] + inputNodeId: string + exitPorts: ExitPortInfo[] + collapsed: boolean + } + width?: number + height?: number + selected?: boolean + _isTempNode?: boolean +} + +/** + * Group Input node data structure + * node.type = 'custom-group-input' + * node.data.type = '' + */ +export type CustomGroupInputNodeData = { + type: '' + title: string + desc?: string + groupInput: { + groupId: string + title: string + } + selected?: boolean + _isTempNode?: boolean +} + +/** + * Exit Port node data structure + * node.type = 'custom-group-exit-port' + * node.data.type = '' + */ +export type CustomGroupExitPortNodeData = { + type: '' + title: string + desc?: string + exitPort: { + groupId: string + leafNodeId: string + sourceHandle: string + name: string + } + selected?: boolean + _isTempNode?: boolean +} + +/** + * Member node info for display + */ +export type GroupMember = { + id: string + type: BlockEnum + label?: string +} diff --git a/web/app/components/workflow/design/ui-only-group.md b/web/app/components/workflow/design/ui-only-group.md new file mode 100644 index 0000000000..f7c7f981f0 --- /dev/null +++ b/web/app/components/workflow/design/ui-only-group.md @@ -0,0 +1,127 @@ +# UI-only Group(含 Group Input / Exit Port)方案 + +## 设计方案 + +### 目标 + +- Group 可持久化:刷新后仍保留分组/命名/布局。 +- Group 不影响执行:Run Workflow 时不执行 Group/Input/ExitPort,也不改变真实执行图语义。 +- 新增入边:任意外部节点连到 Group(或 Group Input)时,等价于“通过 Group Input fan-out 到每个 entry”。 +- handler 粒度:以 leaf 节点的 `sourceHandle` 为粒度生成 Exit Port(If-Else / Classifier 等多 handler 需要拆分)。 +- 支持改名:Group 标题、每个 Exit Port 名称可编辑并保存。 +- 最小化副作用:真实节点/真实边不被“重接到 Group”,只做 UI 折叠;状态订阅尽量只取最小字段,避免雪崩式 rerender。 + +### 核心模型(两层图) + +1) **真实图(可执行、可保存)** + +- 真实 workflow nodes + 真实 edges(执行图语义只由它们决定)。 +- Group 相关 UI 节点也会被保存到 graph.nodes,但后端运行时会过滤掉(不进入执行图)。 + +2) **展示图(仅 UI)** + +- 组内成员节点与其相关真实边标记 `hidden=true`(保存,用于刷新后仍保持折叠)。 +- 额外生成 **临时 UI 边**(`edge.data._isTemp = true`,不会 sync 到后端),用于: + - 外部 → Group Input(表示外部连到该组的入边) + - Exit Port → 外部(表示该组 handler 的出边) + +## 影响范围 + +### 前端(`web/`) + +- 新增 3 个 UI-only node type:`custom-group` / `custom-group-input` / `custom-group-exit-port`(组件、样式、panel/rename 交互)。 +- `workflow/index.tsx` 与 `workflow-preview/index.tsx`:注册 nodeTypes。 +- `hooks/use-nodes-interactions.ts`: + - 重做 `handleMakeGroup`:创建 group + input + exit ports;隐藏成员节点/相关真实边;不做“重接真实边到 group”。 + - 扩展 `handleNodeConnect`:遇到 group/input/exitPort 时做连线翻译。 + - 扩展 edge delete:若删除的是临时 UI 边,反向删除对应真实边。 +- 新增派生 UI 边的 hook(示例):`hooks/use-group-ui-edges.ts`(从真实图派生临时 UI 边并写入 ReactFlow edges state)。 +- 新增 `utils/get-node-source-handles.ts`:从节点数据提取可用 `sourceHandle`(If-Else/Classifer 等)。 +- 复用现有 `use-make-group.ts`:继续以“共同 pre node handler(直接前序 handler)”控制 `Make group` disabled。 + +### 后端(`api/`) + +- `api/core/workflow/graph/graph.py`:运行时过滤 `type in {'custom-note','custom-group','custom-group-input','custom-group-exit-port'}`,确保 UI 节点不进入执行图。 + +## 具体实施 + +### 1) 节点类型与数据结构(可持久化、无 `_` 前缀) + +#### Group 容器节点(UI-only) + +- `node.type = 'custom-group'` +- `node.data.type = ''` +- `node.data.group`: + - `groupId: string`(可等于 node.id) + - `title: string` + - `memberNodeIds: string[]` + - `entryNodeIds: string[]` + - `inputNodeId: string` + - `exitPorts: Array<{ portNodeId: string; leafNodeId: string; sourceHandle: string; name: string }>` + - `collapsed: boolean` + +#### Group Input 节点(UI-only) + +- `node.type = 'custom-group-input'` +- `node.data.type = ''` +- `node.data.groupInput`: + - `groupId: string` + - `title: string` + +#### Exit Port 节点(UI-only) + +- `node.type = 'custom-group-exit-port'` +- `node.data.type = ''` +- `node.data.exitPort`: + - `groupId: string` + - `leafNodeId: string` + - `sourceHandle: string` + - `name: string` + +### 2) entry / leaf / handler 计算 + +- entry(branch 头结点):选区内“有入边且所有入边 source 在选区外”的节点。 +- 禁止 side-entrance:若存在 `outside -> selectedNonEntry` 入边,则不可 group。 +- 共同 pre node handler(直接前序 handler): + - 对每个 entry,收集其来自选区外的所有入边的 `(source, sourceHandle)` 集合 + - 要求每个 entry 的集合 `size === 1`,且所有 entry 的该值完全一致 + - 否则 `Make group` disabled +- leaf:选区内“没有指向选区内节点的出边”的节点。 +- leaf sourceHandles:通过 `getNodeSourceHandles(node)` 枚举(普通 `'source'`、If-Else/Classifier 等拆分)。 + +### 3) Make group + +- 创建 `custom-group` + `custom-group-input` + 多个 `custom-group-exit-port` 节点: + - group/input/exitPort 坐标按选区包围盒计算,input 在左侧,exitPort 右侧按 handler 列表排列 +- 隐藏成员节点:对 `memberNodeIds` 设 `node.hidden = true`(持久化) +- 隐藏相关真实边:凡是 `edge.source/edge.target` 在 `memberNodeIds` 的真实边设 `edge.hidden = true`(持久化) +- 不创建/不重接任何“指向 group/input/exitPort 的真实边” + +### 4) UI edge 派生 + +- 从“真实边 + group 定义”派生临时 UI 边并写入 edges state: + - inbound:真实 `outside -> entry` 映射为 `outside -> groupInput` + - outbound:真实 `leaf(sourceHandle) -> outside` 映射为 `exitPort -> outside` +- 临时 UI 边统一标记 `edge.data._isTemp = true`,并在需要时写入用于反向映射的最小字段(`groupId / leafNodeId / sourceHandle / target / targetHandle` 等)。 +- 为避免雪崩 rerender: + - 派生逻辑只订阅最小字段(edges 的 `source/sourceHandle/target/targetHandle/hidden` + group 定义),用 `shallow` 比较 key 列表 + - UI 边增量更新:仅当派生 key 变化时才 `setEdges` + +### 5) 连线翻译(拖线到 UI 节点最终只改真实边) + +- `onConnect(target is custom-group or custom-group-input)`: + - 翻译为:对该 group 的每个 `entryNodeId` 创建真实边 `source -> entryNodeId`(fan-out) + - 复用现有合法性校验(available blocks + cycle check),要求每条 fan-out 都合法 +- `onConnect(source is custom-group-exit-port)`: + - 翻译为:创建真实边 `leafNodeId(sourceHandle) -> target` + +### 6) 删除 UI 边(反向翻译) + +- 若选中并删除的是临时 inbound UI 边:删除所有匹配的真实边 `source -> entryNodeId`(entryNodeIds 来自 group 定义,source/sourceHandle 来自 UI 边) +- 若选中并删除的是临时 outbound UI 边:删除对应真实边 `leafNodeId(sourceHandle) -> target` + +### 7) 可编辑 + +- Group 标题:更新 `custom-group.data.group.title` +- Exit Port 名称:更新 `custom-group-exit-port.data.exitPort.name` +- 通过 `useNodeDataUpdateWithSyncDraft` 写回并 sync draft diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx index 880f652026..87338626b8 100644 --- a/web/app/components/workflow/index.tsx +++ b/web/app/components/workflow/index.tsx @@ -60,6 +60,14 @@ import CustomSimpleNode from './simple-node' import { CUSTOM_SIMPLE_NODE } from './simple-node/constants' import CustomDataSourceEmptyNode from './nodes/data-source-empty' import { CUSTOM_DATA_SOURCE_EMPTY_NODE } from './nodes/data-source-empty/constants' +import { + CUSTOM_GROUP_EXIT_PORT_NODE, + CUSTOM_GROUP_INPUT_NODE, + CUSTOM_GROUP_NODE, + CustomGroupExitPortNode, + CustomGroupInputNode, + CustomGroupNode, +} from './custom-group-node' import Operator from './operator' import { useWorkflowSearch } from './hooks/use-workflow-search' import Control from './operator/control' @@ -111,6 +119,9 @@ const nodeTypes = { [CUSTOM_ITERATION_START_NODE]: CustomIterationStartNode, [CUSTOM_LOOP_START_NODE]: CustomLoopStartNode, [CUSTOM_DATA_SOURCE_EMPTY_NODE]: CustomDataSourceEmptyNode, + [CUSTOM_GROUP_NODE]: CustomGroupNode, + [CUSTOM_GROUP_INPUT_NODE]: CustomGroupInputNode, + [CUSTOM_GROUP_EXIT_PORT_NODE]: CustomGroupExitPortNode, } const edgeTypes = { [CUSTOM_EDGE]: CustomEdge, diff --git a/web/app/components/workflow/utils/workflow-init.ts b/web/app/components/workflow/utils/workflow-init.ts index 08d0d82e79..8bb3bd1209 100644 --- a/web/app/components/workflow/utils/workflow-init.ts +++ b/web/app/components/workflow/utils/workflow-init.ts @@ -34,6 +34,11 @@ import { getLoopStartNode, } from '.' import { correctModelProvider } from '@/utils' +import { + CUSTOM_GROUP_NODE, + GROUP_CHILDREN_Z_INDEX, +} from '../custom-group-node' +import type { CustomGroupNodeData } from '../custom-group-node' const WHITE = 'WHITE' const GRAY = 'GRAY' @@ -91,8 +96,9 @@ const getCycleEdges = (nodes: Node[], edges: Edge[]) => { export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => { const hasIterationNode = nodes.some(node => node.data.type === BlockEnum.Iteration) const hasLoopNode = nodes.some(node => node.data.type === BlockEnum.Loop) + const hasGroupNode = nodes.some(node => node.type === CUSTOM_GROUP_NODE) - if (!hasIterationNode && !hasLoopNode) { + if (!hasIterationNode && !hasLoopNode && !hasGroupNode) { return { nodes, edges, @@ -189,9 +195,67 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => { (node.data as LoopNodeType).start_node_id = newLoopStartNodesMap[node.id].id }) + // Derive Group internal edges (input → entries, leaves → exits) + const groupInternalEdges: Edge[] = [] + const groupNodes = nodes.filter(node => node.type === CUSTOM_GROUP_NODE) + + for (const groupNode of groupNodes) { + const groupData = groupNode.data as unknown as CustomGroupNodeData + const { group } = groupData + + if (!group) + continue + + const { inputNodeId, entryNodeIds, exitPorts } = group + + // Derive edges: input → each entry node + for (const entryId of entryNodeIds) { + const entryNode = nodesMap[entryId] + if (entryNode) { + groupInternalEdges.push({ + id: `group-internal-${inputNodeId}-source-${entryId}-target`, + type: 'custom', + source: inputNodeId, + sourceHandle: 'source', + target: entryId, + targetHandle: 'target', + data: { + sourceType: '' as any, // Group input has empty type + targetType: entryNode.data.type, + _isGroupInternal: true, + _groupId: groupNode.id, + }, + zIndex: GROUP_CHILDREN_Z_INDEX, + } as Edge) + } + } + + // Derive edges: each leaf node → exit port + for (const exitPort of exitPorts) { + const leafNode = nodesMap[exitPort.leafNodeId] + if (leafNode) { + groupInternalEdges.push({ + id: `group-internal-${exitPort.leafNodeId}-${exitPort.sourceHandle}-${exitPort.portNodeId}-target`, + type: 'custom', + source: exitPort.leafNodeId, + sourceHandle: exitPort.sourceHandle, + target: exitPort.portNodeId, + targetHandle: 'target', + data: { + sourceType: leafNode.data.type, + targetType: '' as any, // Exit port has empty type + _isGroupInternal: true, + _groupId: groupNode.id, + }, + zIndex: GROUP_CHILDREN_Z_INDEX, + } as Edge) + } + } + } + return { nodes: [...nodes, ...newIterationStartNodes, ...newLoopStartNodes], - edges: [...edges, ...newEdges], + edges: [...edges, ...newEdges, ...groupInternalEdges], } } From e9795bd772eec76bed3bc1a4f66439402d4bc945 Mon Sep 17 00:00:00 2001 From: zhsama Date: Mon, 22 Dec 2025 18:17:25 +0800 Subject: [PATCH 05/82] feat: refine workflow graph processing to exclude additional UI-only node types --- api/core/workflow/graph/graph.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/api/core/workflow/graph/graph.py b/api/core/workflow/graph/graph.py index 4eb5d94b20..c1a7e43b3e 100644 --- a/api/core/workflow/graph/graph.py +++ b/api/core/workflow/graph/graph.py @@ -309,10 +309,14 @@ class Graph: raise ValueError("Graph must have at least one node") # Filter out UI-only nodes that should not participate in workflow execution - # These include ReactFlow node types (custom-*) and data types that UI-only nodes may use - ui_only_node_types = {"custom-note", "custom-group", "custom-group-input", "custom-group-exit-port", "group"} + # Check both node.type (ReactFlow type) and node.data.type (business type) + ui_only_node_types = {"custom-note"} + ui_only_data_types = {"group"} node_configs = [ - node_config for node_config in node_configs if node_config.get("type", "") not in ui_only_node_types + node_config + for node_config in node_configs + if node_config.get("type", "") not in ui_only_node_types + and node_config.get("data", {}).get("type", "") not in ui_only_data_types ] # Parse node configurations From 22f25731e847099dbb214b71a3533af4bab98334 Mon Sep 17 00:00:00 2001 From: zhsama Date: Mon, 22 Dec 2025 18:59:08 +0800 Subject: [PATCH 06/82] refactor: streamline edge building and node filtering in workflow graph --- api/core/workflow/graph/graph.py | 16 ++-------------- .../custom-group-exit-port-node.tsx | 2 +- .../custom-group-input-node.tsx | 2 +- .../custom-group-node/custom-group-node.tsx | 2 +- web/app/components/workflow/nodes/group/node.tsx | 2 +- 5 files changed, 6 insertions(+), 18 deletions(-) diff --git a/api/core/workflow/graph/graph.py b/api/core/workflow/graph/graph.py index c1a7e43b3e..ba5a01fc94 100644 --- a/api/core/workflow/graph/graph.py +++ b/api/core/workflow/graph/graph.py @@ -130,15 +130,12 @@ class Graph: @classmethod def _build_edges( - cls, - edge_configs: list[dict[str, object]], - valid_node_ids: set[str] | None = None, + cls, edge_configs: list[dict[str, object]] ) -> tuple[dict[str, Edge], dict[str, list[str]], dict[str, list[str]]]: """ Build edge objects and mappings from edge configurations. :param edge_configs: list of edge configurations - :param valid_node_ids: optional set of valid node IDs to filter edges :return: tuple of (edges dict, in_edges dict, out_edges dict) """ edges: dict[str, Edge] = {} @@ -308,16 +305,7 @@ class Graph: if not node_configs: raise ValueError("Graph must have at least one node") - # Filter out UI-only nodes that should not participate in workflow execution - # Check both node.type (ReactFlow type) and node.data.type (business type) - ui_only_node_types = {"custom-note"} - ui_only_data_types = {"group"} - node_configs = [ - node_config - for node_config in node_configs - if node_config.get("type", "") not in ui_only_node_types - and node_config.get("data", {}).get("type", "") not in ui_only_data_types - ] + node_configs = [node_config for node_config in node_configs if node_config.get("type", "") != "custom-note"] # Parse node configurations node_configs_map = cls._parse_node_configs(node_configs) diff --git a/web/app/components/workflow/custom-group-node/custom-group-exit-port-node.tsx b/web/app/components/workflow/custom-group-node/custom-group-exit-port-node.tsx index 45af811618..fda7cab63d 100644 --- a/web/app/components/workflow/custom-group-node/custom-group-exit-port-node.tsx +++ b/web/app/components/workflow/custom-group-node/custom-group-exit-port-node.tsx @@ -4,7 +4,7 @@ import type { FC } from 'react' import { memo } from 'react' import { Handle, Position } from 'reactflow' import type { CustomGroupExitPortNodeData } from './types' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' type CustomGroupExitPortNodeProps = { id: string diff --git a/web/app/components/workflow/custom-group-node/custom-group-input-node.tsx b/web/app/components/workflow/custom-group-node/custom-group-input-node.tsx index 18af6f698d..90e840c472 100644 --- a/web/app/components/workflow/custom-group-node/custom-group-input-node.tsx +++ b/web/app/components/workflow/custom-group-node/custom-group-input-node.tsx @@ -4,7 +4,7 @@ import type { FC } from 'react' import { memo } from 'react' import { Handle, Position } from 'reactflow' import type { CustomGroupInputNodeData } from './types' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' type CustomGroupInputNodeProps = { id: string diff --git a/web/app/components/workflow/custom-group-node/custom-group-node.tsx b/web/app/components/workflow/custom-group-node/custom-group-node.tsx index f41eb4bad3..9d137a36c6 100644 --- a/web/app/components/workflow/custom-group-node/custom-group-node.tsx +++ b/web/app/components/workflow/custom-group-node/custom-group-node.tsx @@ -4,7 +4,7 @@ import type { FC } from 'react' import { memo } from 'react' import { Handle, Position } from 'reactflow' import type { CustomGroupNodeData } from './types' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' type CustomGroupNodeProps = { id: string diff --git a/web/app/components/workflow/nodes/group/node.tsx b/web/app/components/workflow/nodes/group/node.tsx index 0d59b3fbb5..0d891d69dd 100644 --- a/web/app/components/workflow/nodes/group/node.tsx +++ b/web/app/components/workflow/nodes/group/node.tsx @@ -4,7 +4,7 @@ import BlockIcon from '@/app/components/workflow/block-icon' import type { NodeProps } from '@/app/components/workflow/types' import type { GroupHandler, GroupMember, GroupNodeData } from './types' import type { BlockEnum } from '@/app/components/workflow/types' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' const MAX_MEMBER_ICONS = 12 From 3d61496d25a5717c645e24ce73da565383b82eee Mon Sep 17 00:00:00 2001 From: zhsama Date: Tue, 23 Dec 2025 15:36:53 +0800 Subject: [PATCH 07/82] feat: Enhance CustomGroupNode with exit ports and visual indicators --- .../custom-group-node/custom-group-node.tsx | 53 +++++++++++++++++-- .../workflow/custom-group-node/types.ts | 2 + .../workflow/hooks/use-nodes-interactions.ts | 51 +++++++++++++++--- .../components/workflow/nodes/_base/node.tsx | 2 +- .../components/workflow/nodes/group/node.tsx | 7 +++ .../components/workflow/nodes/group/types.ts | 2 + 6 files changed, 104 insertions(+), 13 deletions(-) diff --git a/web/app/components/workflow/custom-group-node/custom-group-node.tsx b/web/app/components/workflow/custom-group-node/custom-group-node.tsx index 9d137a36c6..b53864bad7 100644 --- a/web/app/components/workflow/custom-group-node/custom-group-node.tsx +++ b/web/app/components/workflow/custom-group-node/custom-group-node.tsx @@ -3,6 +3,7 @@ import type { FC } from 'react' import { memo } from 'react' import { Handle, Position } from 'reactflow' +import { Plus02 } from '@/app/components/base/icons/src/vender/line/general' import type { CustomGroupNodeData } from './types' import { cn } from '@/utils/classnames' @@ -11,13 +12,15 @@ type CustomGroupNodeProps = { data: CustomGroupNodeData } -const CustomGroupNode: FC = ({ id: _id, data }) => { +const CustomGroupNode: FC = ({ data }) => { const { group } = data + const exitPorts = group.exitPorts ?? [] + const connectedSourceHandleIds = data._connectedSourceHandleIds ?? [] return (
= ({ id: _id, data }) => { id="target" type="target" position={Position.Left} - className="!h-4 !w-4 !border-2 !border-white !bg-primary-500" + className={cn( + '!h-4 !w-4 !rounded-none !border-none !bg-transparent !outline-none', + 'after:absolute after:left-1.5 after:top-1 after:h-2 after:w-0.5 after:bg-workflow-link-line-handle', + 'transition-all hover:scale-125', + )} style={{ top: '50%' }} /> - {/* Source handles will be rendered by exit port nodes */} +
+ {exitPorts.map((port, index) => { + const connected = connectedSourceHandleIds.includes(port.portNodeId) + + return ( +
+
+ {port.name} +
+ + + + {/* Visual "+" indicator (styling aligned with existing branch handles) */} + +
+ ) + })} +
) } diff --git a/web/app/components/workflow/custom-group-node/types.ts b/web/app/components/workflow/custom-group-node/types.ts index 7e30d6f630..baf7b2362a 100644 --- a/web/app/components/workflow/custom-group-node/types.ts +++ b/web/app/components/workflow/custom-group-node/types.ts @@ -19,6 +19,8 @@ export type CustomGroupNodeData = { type: '' // Empty string bypasses backend NodeType validation title: string desc?: string + _connectedSourceHandleIds?: string[] + _connectedTargetHandleIds?: string[] group: { groupId: string title: string diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index 76be3766f4..72658a8aae 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -2063,14 +2063,43 @@ export const useNodesInteractions = () => { label: node.data.title, } }) - const handlers: GroupHandler[] = leafNodeIds.map((nodeId) => { + // Build handlers from all leaf nodes + // For multi-branch nodes (if-else, classifier), create one handler per branch + // For regular nodes, create one handler with 'source' handle + const handlerMap = new Map() + + leafNodeIds.forEach((nodeId) => { const node = bundledNodes.find(n => n.id === nodeId) - return { - id: nodeId, - label: node?.data.title || nodeId, + if (!node) + return + + const targetBranches = node.data._targetBranches + if (targetBranches && targetBranches.length > 0) { + // Multi-branch node: create handler for each branch + targetBranches.forEach((branch: { id: string; name?: string }) => { + const handlerId = `${nodeId}-${branch.id}` + handlerMap.set(handlerId, { + id: handlerId, + label: branch.name || node.data.title || nodeId, + nodeId, + sourceHandle: branch.id, + }) + }) + } + else { + // Regular node: single 'source' handler + const handlerId = `${nodeId}-source` + handlerMap.set(handlerId, { + id: handlerId, + label: node.data.title || nodeId, + nodeId, + sourceHandle: 'source', + }) } }) + const handlers: GroupHandler[] = Array.from(handlerMap.values()) + // put the group node at the top-left corner of the selection, slightly offset const { x: minX, y: minY } = getTopLeftNodePosition(bundledNodes) @@ -2123,7 +2152,7 @@ export const useNodesInteractions = () => { } }) - // re-add the external inbound edges to the group node (previous order is not lost) + // re-add the external inbound edges to the group node as UI-only edges (not persisted to backend) inboundEdges.forEach((edge) => { draft.push({ id: `${edge.id}__to-${groupNode.id}`, @@ -2138,22 +2167,27 @@ export const useNodesInteractions = () => { targetType: BlockEnum.Group, _hiddenInGroupId: undefined, _isBundled: false, + _isTemp: true, // UI-only edge, not persisted to backend }, zIndex: edge.zIndex, }) }) - // outbound edges of the group node: only map the outbound edges of the leaf nodes to the corresponding handlers + // outbound edges of the group node as UI-only edges (not persisted to backend) outboundEdges.forEach((edge) => { if (!bundledNodeIdIsLeaf.has(edge.source)) return + // Use the same handler id format: nodeId-sourceHandle + const originalSourceHandle = edge.sourceHandle || 'source' + const handlerId = `${edge.source}-${originalSourceHandle}` + draft.push({ - id: `${groupNode.id}-${edge.target}-${edge.targetHandle || 'target'}-${edge.source}`, + id: `${groupNode.id}-${edge.target}-${edge.targetHandle || 'target'}-${handlerId}`, type: edge.type || CUSTOM_EDGE, source: groupNode.id, target: edge.target, - sourceHandle: edge.source, // handler id corresponds to the leaf node id + sourceHandle: handlerId, // handler id: nodeId-sourceHandle targetHandle: edge.targetHandle, data: { ...edge.data, @@ -2161,6 +2195,7 @@ export const useNodesInteractions = () => { targetType: nodeTypeMap.get(edge.target)!, _hiddenInGroupId: undefined, _isBundled: false, + _isTemp: true, // UI-only edge, not persisted to backend }, zIndex: edge.zIndex, }) diff --git a/web/app/components/workflow/nodes/_base/node.tsx b/web/app/components/workflow/nodes/_base/node.tsx index 263732cd70..5ddc997d8a 100644 --- a/web/app/components/workflow/nodes/_base/node.tsx +++ b/web/app/components/workflow/nodes/_base/node.tsx @@ -221,7 +221,7 @@ const BaseNode: FC = ({ ) } { - data.type !== BlockEnum.IfElse && data.type !== BlockEnum.QuestionClassifier && !data._isCandidate && ( + data.type !== BlockEnum.IfElse && data.type !== BlockEnum.QuestionClassifier && data.type !== BlockEnum.Group && !data._isCandidate && ( ) => {
{handler.label || handler.id} +
))}
diff --git a/web/app/components/workflow/nodes/group/types.ts b/web/app/components/workflow/nodes/group/types.ts index e54119381e..92838357cf 100644 --- a/web/app/components/workflow/nodes/group/types.ts +++ b/web/app/components/workflow/nodes/group/types.ts @@ -9,6 +9,8 @@ export type GroupMember = { export type GroupHandler = { id: string label?: string + nodeId?: string // leaf node id for multi-branch nodes + sourceHandle?: string // original sourceHandle (e.g., case_id for if-else) } export type GroupNodeData = CommonNodeType<{ From d3c6b093543e89c10609a1b5cf73e07f55cf899f Mon Sep 17 00:00:00 2001 From: zhsama Date: Tue, 23 Dec 2025 16:37:42 +0800 Subject: [PATCH 08/82] feat: Implement group node edge handling in useNodesInteractions hook --- .../workflow/hooks/use-nodes-interactions.ts | 320 ++++++++++++++++-- 1 file changed, 283 insertions(+), 37 deletions(-) diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index 72658a8aae..d4052f0081 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -424,6 +424,109 @@ export const useNodesInteractions = () => { ) return + // Check if source is a group node - need special handling + const isSourceGroup = sourceNode?.data.type === BlockEnum.Group + + if (isSourceGroup && sourceHandle) { + // Parse handler id to get original node id and sourceHandle + // Handler id format: `${nodeId}-${sourceHandle}` + const lastDashIndex = sourceHandle.lastIndexOf('-') + const originalNodeId = sourceHandle.substring(0, lastDashIndex) + const originalSourceHandle = sourceHandle.substring(lastDashIndex + 1) + const originalNode = nodes.find(node => node.id === originalNodeId) + + if (!originalNode) + return + + // Check if real edge already exists + if ( + edges.find( + edge => + edge.source === originalNodeId + && edge.sourceHandle === originalSourceHandle + && edge.target === target + && edge.targetHandle === targetHandle, + ) + ) + return + + const parendNode = nodes.find(node => node.id === targetNode?.parentId) + const isInIteration = parendNode && parendNode.data.type === BlockEnum.Iteration + const isInLoop = !!parendNode && parendNode.data.type === BlockEnum.Loop + + // Create the real edge (from original node to target) - hidden because it's inside group + const realEdge = { + id: `${originalNodeId}-${originalSourceHandle}-${target}-${targetHandle}`, + type: CUSTOM_EDGE, + source: originalNodeId, + target: target!, + sourceHandle: originalSourceHandle, + targetHandle, + hidden: true, // Hide the real edge since original node is in group + data: { + sourceType: originalNode.data.type, + targetType: targetNode!.data.type, + isInIteration, + iteration_id: isInIteration ? targetNode?.parentId : undefined, + isInLoop, + loop_id: isInLoop ? targetNode?.parentId : undefined, + _hiddenInGroupId: source ?? undefined, // Mark which group hides this edge + }, + zIndex: 0, + } + + // Create the UI edge (from group to target) - temporary, not persisted + const uiEdge = { + id: `${source}-${sourceHandle}-${target}-${targetHandle}`, + type: CUSTOM_EDGE, + source: source!, + target: target!, + sourceHandle, + targetHandle, + data: { + sourceType: originalNode.data.type, // Use original node type, not group + targetType: targetNode!.data.type, + isInIteration, + iteration_id: isInIteration ? targetNode?.parentId : undefined, + isInLoop, + loop_id: isInLoop ? targetNode?.parentId : undefined, + _isTemp: true, // UI-only edge, not persisted to backend + }, + zIndex: 0, + } + + // Update connected handle ids for the original node + const nodesConnectedSourceOrTargetHandleIdsMap + = getNodesConnectedSourceOrTargetHandleIdsMap( + [{ type: 'add', edge: realEdge }], + nodes, + ) + const newNodes = produce(nodes, (draft: Node[]) => { + draft.forEach((node) => { + if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) { + node.data = { + ...node.data, + ...nodesConnectedSourceOrTargetHandleIdsMap[node.id], + } + } + }) + }) + const newEdges = produce(edges, (draft) => { + draft.push(realEdge) + draft.push(uiEdge) + }) + + setNodes(newNodes) + setEdges(newEdges) + + handleSyncWorkflowDraft() + saveStateToHistory(WorkflowHistoryEvent.NodeConnect, { + nodeId: targetNode?.id, + }) + return + } + + // Normal edge connection (non-group source) if ( edges.find( edge => @@ -873,8 +976,68 @@ export const useNodesInteractions = () => { } } - let newEdge = null - if (nodeType !== BlockEnum.DataSource) { + // Check if prevNode is a group node - need special handling + const isPrevNodeGroup = prevNode.data.type === BlockEnum.Group + let newEdge: any = null + let newUiEdge: any = null + + if (isPrevNodeGroup && prevNodeSourceHandle) { + // Parse handler id to get original node id and sourceHandle + // Handler id format: `${nodeId}-${sourceHandle}` + const lastDashIndex = prevNodeSourceHandle.lastIndexOf('-') + const originalNodeId = prevNodeSourceHandle.substring(0, lastDashIndex) + const originalSourceHandle = prevNodeSourceHandle.substring(lastDashIndex + 1) + const originalNode = nodes.find(node => node.id === originalNodeId) + + if (originalNode) { + if (nodeType !== BlockEnum.DataSource) { + // Create the real edge (from original node to new node) - hidden + newEdge = { + id: `${originalNodeId}-${originalSourceHandle}-${newNode.id}-${targetHandle}`, + type: CUSTOM_EDGE, + source: originalNodeId, + sourceHandle: originalSourceHandle, + target: newNode.id, + targetHandle, + hidden: true, // Hide the real edge since original node is in group + data: { + sourceType: originalNode.data.type, + targetType: newNode.data.type, + isInIteration, + isInLoop, + iteration_id: isInIteration ? prevNode.parentId : undefined, + loop_id: isInLoop ? prevNode.parentId : undefined, + _connectedNodeIsSelected: true, + _hiddenInGroupId: prevNodeId, + }, + zIndex: 0, + } + + // Create the UI edge (from group to new node) - temporary + newUiEdge = { + id: `${prevNodeId}-${prevNodeSourceHandle}-${newNode.id}-${targetHandle}`, + type: CUSTOM_EDGE, + source: prevNodeId, + sourceHandle: prevNodeSourceHandle, + target: newNode.id, + targetHandle, + data: { + sourceType: originalNode.data.type, // Use original node type, not group + targetType: newNode.data.type, + isInIteration, + isInLoop, + iteration_id: isInIteration ? prevNode.parentId : undefined, + loop_id: isInLoop ? prevNode.parentId : undefined, + _connectedNodeIsSelected: true, + _isTemp: true, // UI-only edge, not persisted to backend + }, + zIndex: 0, + } + } + } + } + else if (nodeType !== BlockEnum.DataSource) { + // Normal case: prevNode is not a group newEdge = { id: `${prevNodeId}-${prevNodeSourceHandle}-${newNode.id}-${targetHandle}`, type: CUSTOM_EDGE, @@ -899,9 +1062,10 @@ export const useNodesInteractions = () => { } } + const edgesToAdd = [newEdge, newUiEdge].filter(Boolean).map(edge => ({ type: 'add' as const, edge })) const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap( - (newEdge ? [{ type: 'add', edge: newEdge }] : []), + edgesToAdd, nodes, ) const newNodes = produce(nodes, (draft: Node[]) => { @@ -967,6 +1131,7 @@ export const useNodesInteractions = () => { } }) if (newEdge) draft.push(newEdge) + if (newUiEdge) draft.push(newUiEdge) }) setNodes(newNodes) @@ -1158,33 +1323,108 @@ export const useNodesInteractions = () => { } } - const currentEdgeIndex = edges.findIndex( - edge => edge.source === prevNodeId && edge.target === nextNodeId, - ) - let newPrevEdge = null + // Check if prevNode is a group node - need special handling + const isPrevNodeGroup = prevNode.data.type === BlockEnum.Group + let newPrevEdge: any = null + let newPrevUiEdge: any = null + const edgesToRemove: string[] = [] - if (nodeType !== BlockEnum.DataSource) { - newPrevEdge = { - id: `${prevNodeId}-${prevNodeSourceHandle}-${newNode.id}-${targetHandle}`, - type: CUSTOM_EDGE, - source: prevNodeId, - sourceHandle: prevNodeSourceHandle, - target: newNode.id, - targetHandle, - data: { - sourceType: prevNode.data.type, - targetType: newNode.data.type, - isInIteration, - isInLoop, - iteration_id: isInIteration ? prevNode.parentId : undefined, - loop_id: isInLoop ? prevNode.parentId : undefined, - _connectedNodeIsSelected: true, - }, - zIndex: prevNode.parentId - ? isInIteration - ? ITERATION_CHILDREN_Z_INDEX - : LOOP_CHILDREN_Z_INDEX - : 0, + if (isPrevNodeGroup && prevNodeSourceHandle) { + // Parse handler id to get original node id and sourceHandle + const lastDashIndex = prevNodeSourceHandle.lastIndexOf('-') + const originalNodeId = prevNodeSourceHandle.substring(0, lastDashIndex) + const originalSourceHandle = prevNodeSourceHandle.substring(lastDashIndex + 1) + const originalNode = nodes.find(node => node.id === originalNodeId) + + if (originalNode && nodeType !== BlockEnum.DataSource) { + // Find edges to remove: both hidden real edge and UI temp edge from group to nextNode + const hiddenEdge = edges.find( + edge => edge.source === originalNodeId + && edge.sourceHandle === originalSourceHandle + && edge.target === nextNodeId, + ) + const uiEdge = edges.find( + edge => edge.source === prevNodeId + && edge.sourceHandle === prevNodeSourceHandle + && edge.target === nextNodeId, + ) + if (hiddenEdge) edgesToRemove.push(hiddenEdge.id) + if (uiEdge) edgesToRemove.push(uiEdge.id) + + // Create the real edge (from original node to new node) - hidden + newPrevEdge = { + id: `${originalNodeId}-${originalSourceHandle}-${newNode.id}-${targetHandle}`, + type: CUSTOM_EDGE, + source: originalNodeId, + sourceHandle: originalSourceHandle, + target: newNode.id, + targetHandle, + hidden: true, + data: { + sourceType: originalNode.data.type, + targetType: newNode.data.type, + isInIteration, + isInLoop, + iteration_id: isInIteration ? prevNode.parentId : undefined, + loop_id: isInLoop ? prevNode.parentId : undefined, + _connectedNodeIsSelected: true, + _hiddenInGroupId: prevNodeId, + }, + zIndex: 0, + } + + // Create the UI edge (from group to new node) - temporary + newPrevUiEdge = { + id: `${prevNodeId}-${prevNodeSourceHandle}-${newNode.id}-${targetHandle}`, + type: CUSTOM_EDGE, + source: prevNodeId, + sourceHandle: prevNodeSourceHandle, + target: newNode.id, + targetHandle, + data: { + sourceType: originalNode.data.type, + targetType: newNode.data.type, + isInIteration, + isInLoop, + iteration_id: isInIteration ? prevNode.parentId : undefined, + loop_id: isInLoop ? prevNode.parentId : undefined, + _connectedNodeIsSelected: true, + _isTemp: true, + }, + zIndex: 0, + } + } + } + else { + // Normal case: find edge to remove + const currentEdge = edges.find( + edge => edge.source === prevNodeId && edge.target === nextNodeId, + ) + if (currentEdge) edgesToRemove.push(currentEdge.id) + + if (nodeType !== BlockEnum.DataSource) { + newPrevEdge = { + id: `${prevNodeId}-${prevNodeSourceHandle}-${newNode.id}-${targetHandle}`, + type: CUSTOM_EDGE, + source: prevNodeId, + sourceHandle: prevNodeSourceHandle, + target: newNode.id, + targetHandle, + data: { + sourceType: prevNode.data.type, + targetType: newNode.data.type, + isInIteration, + isInLoop, + iteration_id: isInIteration ? prevNode.parentId : undefined, + loop_id: isInLoop ? prevNode.parentId : undefined, + _connectedNodeIsSelected: true, + }, + zIndex: prevNode.parentId + ? isInIteration + ? ITERATION_CHILDREN_Z_INDEX + : LOOP_CHILDREN_Z_INDEX + : 0, + } } } @@ -1229,13 +1469,15 @@ export const useNodesInteractions = () => { : 0, } } + const edgeChanges = [ + ...edgesToRemove.map(id => ({ type: 'remove' as const, edge: edges.find(e => e.id === id)! })).filter(c => c.edge), + ...(newPrevEdge ? [{ type: 'add' as const, edge: newPrevEdge }] : []), + ...(newPrevUiEdge ? [{ type: 'add' as const, edge: newPrevUiEdge }] : []), + ...(newNextEdge ? [{ type: 'add' as const, edge: newNextEdge }] : []), + ] const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap( - [ - { type: 'remove', edge: edges[currentEdgeIndex] }, - ...(newPrevEdge ? [{ type: 'add', edge: newPrevEdge }] : []), - ...(newNextEdge ? [{ type: 'add', edge: newNextEdge }] : []), - ], + edgeChanges, [...nodes, newNode], ) @@ -1298,7 +1540,11 @@ export const useNodesInteractions = () => { }) } const newEdges = produce(edges, (draft) => { - draft.splice(currentEdgeIndex, 1) + // Remove edges by id (supports removing multiple edges for group case) + const filteredDraft = draft.filter(edge => !edgesToRemove.includes(edge.id)) + draft.length = 0 + draft.push(...filteredDraft) + draft.forEach((item) => { item.data = { ...item.data, @@ -1306,7 +1552,7 @@ export const useNodesInteractions = () => { } }) if (newPrevEdge) draft.push(newPrevEdge) - + if (newPrevUiEdge) draft.push(newPrevUiEdge) if (newNextEdge) draft.push(newNextEdge) }) setEdges(newEdges) @@ -2191,7 +2437,7 @@ export const useNodesInteractions = () => { targetHandle: edge.targetHandle, data: { ...edge.data, - sourceType: BlockEnum.Group, + sourceType: edge.data.sourceType, // Keep original node type, not group targetType: nodeTypeMap.get(edge.target)!, _hiddenInGroupId: undefined, _isBundled: false, From 783a49bd973d8c552bf655b356bc543fd5a59a6e Mon Sep 17 00:00:00 2001 From: zhsama Date: Tue, 23 Dec 2025 16:44:11 +0800 Subject: [PATCH 09/82] feat: Refactor group node edge creation logic in useNodesInteractions hook --- .../workflow/hooks/use-nodes-interactions.ts | 226 +++++++++--------- 1 file changed, 116 insertions(+), 110 deletions(-) diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index d4052f0081..f4afd82fb4 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -75,6 +75,79 @@ const ENTRY_NODE_WRAPPER_OFFSET = { y: 21, // Adjusted based on visual testing feedback } as const +/** + * Parse group handler id to get original node id and sourceHandle + * Handler id format: `${nodeId}-${sourceHandle}` + */ +function parseGroupHandlerId(handlerId: string): { originalNodeId: string, originalSourceHandle: string } { + const lastDashIndex = handlerId.lastIndexOf('-') + return { + originalNodeId: handlerId.substring(0, lastDashIndex), + originalSourceHandle: handlerId.substring(lastDashIndex + 1), + } +} + +/** + * Create a pair of edges for group node connections: + * - realEdge: hidden edge from original node to target (persisted to backend) + * - uiEdge: visible temp edge from group to target (UI-only, not persisted) + */ +function createGroupEdgePair(params: { + groupNodeId: string + handlerId: string + targetNodeId: string + targetHandle: string + nodes: Node[] + baseEdgeData?: Partial + zIndex?: number +}): { realEdge: Edge, uiEdge: Edge } | null { + const { groupNodeId, handlerId, targetNodeId, targetHandle, nodes, baseEdgeData = {}, zIndex = 0 } = params + + const { originalNodeId, originalSourceHandle } = parseGroupHandlerId(handlerId) + const originalNode = nodes.find(node => node.id === originalNodeId) + const targetNode = nodes.find(node => node.id === targetNodeId) + + if (!originalNode || !targetNode) + return null + + // Create the real edge (from original node to target) - hidden because original node is in group + const realEdge: Edge = { + id: `${originalNodeId}-${originalSourceHandle}-${targetNodeId}-${targetHandle}`, + type: CUSTOM_EDGE, + source: originalNodeId, + sourceHandle: originalSourceHandle, + target: targetNodeId, + targetHandle, + hidden: true, + data: { + ...baseEdgeData, + sourceType: originalNode.data.type, + targetType: targetNode.data.type, + _hiddenInGroupId: groupNodeId, + }, + zIndex, + } + + // Create the UI edge (from group to target) - temporary, not persisted to backend + const uiEdge: Edge = { + id: `${groupNodeId}-${handlerId}-${targetNodeId}-${targetHandle}`, + type: CUSTOM_EDGE, + source: groupNodeId, + sourceHandle: handlerId, + target: targetNodeId, + targetHandle, + data: { + ...baseEdgeData, + sourceType: originalNode.data.type, // Use original node type, not group + targetType: targetNode.data.type, + _isTemp: true, + }, + zIndex, + } + + return { realEdge, uiEdge } +} + export const useNodesInteractions = () => { const { t } = useTranslation() const store = useStoreApi() @@ -427,73 +500,40 @@ export const useNodesInteractions = () => { // Check if source is a group node - need special handling const isSourceGroup = sourceNode?.data.type === BlockEnum.Group - if (isSourceGroup && sourceHandle) { - // Parse handler id to get original node id and sourceHandle - // Handler id format: `${nodeId}-${sourceHandle}` - const lastDashIndex = sourceHandle.lastIndexOf('-') - const originalNodeId = sourceHandle.substring(0, lastDashIndex) - const originalSourceHandle = sourceHandle.substring(lastDashIndex + 1) - const originalNode = nodes.find(node => node.id === originalNodeId) - - if (!originalNode) - return + if (isSourceGroup && sourceHandle && target && targetHandle) { + const { originalNodeId, originalSourceHandle } = parseGroupHandlerId(sourceHandle) // Check if real edge already exists - if ( - edges.find( - edge => - edge.source === originalNodeId - && edge.sourceHandle === originalSourceHandle - && edge.target === target - && edge.targetHandle === targetHandle, - ) - ) + if (edges.find(edge => + edge.source === originalNodeId + && edge.sourceHandle === originalSourceHandle + && edge.target === target + && edge.targetHandle === targetHandle, + )) return - const parendNode = nodes.find(node => node.id === targetNode?.parentId) - const isInIteration = parendNode && parendNode.data.type === BlockEnum.Iteration - const isInLoop = !!parendNode && parendNode.data.type === BlockEnum.Loop + const parentNode = nodes.find(node => node.id === targetNode?.parentId) + const isInIteration = parentNode && parentNode.data.type === BlockEnum.Iteration + const isInLoop = !!parentNode && parentNode.data.type === BlockEnum.Loop - // Create the real edge (from original node to target) - hidden because it's inside group - const realEdge = { - id: `${originalNodeId}-${originalSourceHandle}-${target}-${targetHandle}`, - type: CUSTOM_EDGE, - source: originalNodeId, - target: target!, - sourceHandle: originalSourceHandle, + const edgePair = createGroupEdgePair({ + groupNodeId: source!, + handlerId: sourceHandle, + targetNodeId: target, targetHandle, - hidden: true, // Hide the real edge since original node is in group - data: { - sourceType: originalNode.data.type, - targetType: targetNode!.data.type, + nodes, + baseEdgeData: { isInIteration, iteration_id: isInIteration ? targetNode?.parentId : undefined, isInLoop, loop_id: isInLoop ? targetNode?.parentId : undefined, - _hiddenInGroupId: source ?? undefined, // Mark which group hides this edge }, - zIndex: 0, - } + }) - // Create the UI edge (from group to target) - temporary, not persisted - const uiEdge = { - id: `${source}-${sourceHandle}-${target}-${targetHandle}`, - type: CUSTOM_EDGE, - source: source!, - target: target!, - sourceHandle, - targetHandle, - data: { - sourceType: originalNode.data.type, // Use original node type, not group - targetType: targetNode!.data.type, - isInIteration, - iteration_id: isInIteration ? targetNode?.parentId : undefined, - isInLoop, - loop_id: isInLoop ? targetNode?.parentId : undefined, - _isTemp: true, // UI-only edge, not persisted to backend - }, - zIndex: 0, - } + if (!edgePair) + return + + const { realEdge, uiEdge } = edgePair // Update connected handle ids for the original node const nodesConnectedSourceOrTargetHandleIdsMap @@ -978,62 +1018,28 @@ export const useNodesInteractions = () => { // Check if prevNode is a group node - need special handling const isPrevNodeGroup = prevNode.data.type === BlockEnum.Group - let newEdge: any = null - let newUiEdge: any = null + let newEdge: Edge | null = null + let newUiEdge: Edge | null = null - if (isPrevNodeGroup && prevNodeSourceHandle) { - // Parse handler id to get original node id and sourceHandle - // Handler id format: `${nodeId}-${sourceHandle}` - const lastDashIndex = prevNodeSourceHandle.lastIndexOf('-') - const originalNodeId = prevNodeSourceHandle.substring(0, lastDashIndex) - const originalSourceHandle = prevNodeSourceHandle.substring(lastDashIndex + 1) - const originalNode = nodes.find(node => node.id === originalNodeId) + if (isPrevNodeGroup && prevNodeSourceHandle && nodeType !== BlockEnum.DataSource) { + const edgePair = createGroupEdgePair({ + groupNodeId: prevNodeId, + handlerId: prevNodeSourceHandle, + targetNodeId: newNode.id, + targetHandle, + nodes: [...nodes, newNode], + baseEdgeData: { + isInIteration, + isInLoop, + iteration_id: isInIteration ? prevNode.parentId : undefined, + loop_id: isInLoop ? prevNode.parentId : undefined, + _connectedNodeIsSelected: true, + }, + }) - if (originalNode) { - if (nodeType !== BlockEnum.DataSource) { - // Create the real edge (from original node to new node) - hidden - newEdge = { - id: `${originalNodeId}-${originalSourceHandle}-${newNode.id}-${targetHandle}`, - type: CUSTOM_EDGE, - source: originalNodeId, - sourceHandle: originalSourceHandle, - target: newNode.id, - targetHandle, - hidden: true, // Hide the real edge since original node is in group - data: { - sourceType: originalNode.data.type, - targetType: newNode.data.type, - isInIteration, - isInLoop, - iteration_id: isInIteration ? prevNode.parentId : undefined, - loop_id: isInLoop ? prevNode.parentId : undefined, - _connectedNodeIsSelected: true, - _hiddenInGroupId: prevNodeId, - }, - zIndex: 0, - } - - // Create the UI edge (from group to new node) - temporary - newUiEdge = { - id: `${prevNodeId}-${prevNodeSourceHandle}-${newNode.id}-${targetHandle}`, - type: CUSTOM_EDGE, - source: prevNodeId, - sourceHandle: prevNodeSourceHandle, - target: newNode.id, - targetHandle, - data: { - sourceType: originalNode.data.type, // Use original node type, not group - targetType: newNode.data.type, - isInIteration, - isInLoop, - iteration_id: isInIteration ? prevNode.parentId : undefined, - loop_id: isInLoop ? prevNode.parentId : undefined, - _connectedNodeIsSelected: true, - _isTemp: true, // UI-only edge, not persisted to backend - }, - zIndex: 0, - } - } + if (edgePair) { + newEdge = edgePair.realEdge + newUiEdge = edgePair.uiEdge } } else if (nodeType !== BlockEnum.DataSource) { @@ -1062,7 +1068,7 @@ export const useNodesInteractions = () => { } } - const edgesToAdd = [newEdge, newUiEdge].filter(Boolean).map(edge => ({ type: 'add' as const, edge })) + const edgesToAdd = [newEdge, newUiEdge].filter(Boolean).map(edge => ({ type: 'add' as const, edge: edge! })) const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap( edgesToAdd, From 7b660a9ebc3f53ab9bc602ab0a2d66d204f7d535 Mon Sep 17 00:00:00 2001 From: zhsama Date: Tue, 23 Dec 2025 17:12:09 +0800 Subject: [PATCH 10/82] feat: Simplify edge creation for group nodes in useNodesInteractions hook --- .../workflow/hooks/use-nodes-interactions.ts | 97 +++++++------------ 1 file changed, 34 insertions(+), 63 deletions(-) diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index f4afd82fb4..e707d66967 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -1331,74 +1331,45 @@ export const useNodesInteractions = () => { // Check if prevNode is a group node - need special handling const isPrevNodeGroup = prevNode.data.type === BlockEnum.Group - let newPrevEdge: any = null - let newPrevUiEdge: any = null + let newPrevEdge: Edge | null = null + let newPrevUiEdge: Edge | null = null const edgesToRemove: string[] = [] - if (isPrevNodeGroup && prevNodeSourceHandle) { - // Parse handler id to get original node id and sourceHandle - const lastDashIndex = prevNodeSourceHandle.lastIndexOf('-') - const originalNodeId = prevNodeSourceHandle.substring(0, lastDashIndex) - const originalSourceHandle = prevNodeSourceHandle.substring(lastDashIndex + 1) - const originalNode = nodes.find(node => node.id === originalNodeId) + if (isPrevNodeGroup && prevNodeSourceHandle && nodeType !== BlockEnum.DataSource) { + const { originalNodeId, originalSourceHandle } = parseGroupHandlerId(prevNodeSourceHandle) - if (originalNode && nodeType !== BlockEnum.DataSource) { - // Find edges to remove: both hidden real edge and UI temp edge from group to nextNode - const hiddenEdge = edges.find( - edge => edge.source === originalNodeId - && edge.sourceHandle === originalSourceHandle - && edge.target === nextNodeId, - ) - const uiEdge = edges.find( - edge => edge.source === prevNodeId - && edge.sourceHandle === prevNodeSourceHandle - && edge.target === nextNodeId, - ) - if (hiddenEdge) edgesToRemove.push(hiddenEdge.id) - if (uiEdge) edgesToRemove.push(uiEdge.id) + // Find edges to remove: both hidden real edge and UI temp edge from group to nextNode + const hiddenEdge = edges.find( + edge => edge.source === originalNodeId + && edge.sourceHandle === originalSourceHandle + && edge.target === nextNodeId, + ) + const uiTempEdge = edges.find( + edge => edge.source === prevNodeId + && edge.sourceHandle === prevNodeSourceHandle + && edge.target === nextNodeId, + ) + if (hiddenEdge) edgesToRemove.push(hiddenEdge.id) + if (uiTempEdge) edgesToRemove.push(uiTempEdge.id) - // Create the real edge (from original node to new node) - hidden - newPrevEdge = { - id: `${originalNodeId}-${originalSourceHandle}-${newNode.id}-${targetHandle}`, - type: CUSTOM_EDGE, - source: originalNodeId, - sourceHandle: originalSourceHandle, - target: newNode.id, - targetHandle, - hidden: true, - data: { - sourceType: originalNode.data.type, - targetType: newNode.data.type, - isInIteration, - isInLoop, - iteration_id: isInIteration ? prevNode.parentId : undefined, - loop_id: isInLoop ? prevNode.parentId : undefined, - _connectedNodeIsSelected: true, - _hiddenInGroupId: prevNodeId, - }, - zIndex: 0, - } + const edgePair = createGroupEdgePair({ + groupNodeId: prevNodeId, + handlerId: prevNodeSourceHandle, + targetNodeId: newNode.id, + targetHandle, + nodes: [...nodes, newNode], + baseEdgeData: { + isInIteration, + isInLoop, + iteration_id: isInIteration ? prevNode.parentId : undefined, + loop_id: isInLoop ? prevNode.parentId : undefined, + _connectedNodeIsSelected: true, + }, + }) - // Create the UI edge (from group to new node) - temporary - newPrevUiEdge = { - id: `${prevNodeId}-${prevNodeSourceHandle}-${newNode.id}-${targetHandle}`, - type: CUSTOM_EDGE, - source: prevNodeId, - sourceHandle: prevNodeSourceHandle, - target: newNode.id, - targetHandle, - data: { - sourceType: originalNode.data.type, - targetType: newNode.data.type, - isInIteration, - isInLoop, - iteration_id: isInIteration ? prevNode.parentId : undefined, - loop_id: isInLoop ? prevNode.parentId : undefined, - _connectedNodeIsSelected: true, - _isTemp: true, - }, - zIndex: 0, - } + if (edgePair) { + newPrevEdge = edgePair.realEdge + newPrevUiEdge = edgePair.uiEdge } } else { From 18ea9d3f1879fe537b0e4c8a7501a1ca83d3b1c1 Mon Sep 17 00:00:00 2001 From: zhsama Date: Tue, 23 Dec 2025 20:44:36 +0800 Subject: [PATCH 11/82] feat: Add GROUP node type and update node configuration filtering in Graph class --- api/core/workflow/enums.py | 1 + api/core/workflow/graph/graph.py | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/api/core/workflow/enums.py b/api/core/workflow/enums.py index cf12d5ec1f..2d62b00ecf 100644 --- a/api/core/workflow/enums.py +++ b/api/core/workflow/enums.py @@ -63,6 +63,7 @@ class NodeType(StrEnum): TRIGGER_SCHEDULE = "trigger-schedule" TRIGGER_PLUGIN = "trigger-plugin" HUMAN_INPUT = "human-input" + GROUP = "group" @property def is_trigger_node(self) -> bool: diff --git a/api/core/workflow/graph/graph.py b/api/core/workflow/graph/graph.py index ba5a01fc94..38912c57b7 100644 --- a/api/core/workflow/graph/graph.py +++ b/api/core/workflow/graph/graph.py @@ -305,7 +305,14 @@ class Graph: if not node_configs: raise ValueError("Graph must have at least one node") - node_configs = [node_config for node_config in node_configs if node_config.get("type", "") != "custom-note"] + # Filter out UI-only node types: + # - custom-note: top-level type (node_config.type == "custom-note") + # - group: data-level type (node_config.data.type == "group") + node_configs = [ + node_config for node_config in node_configs + if node_config.get("type", "") != "custom-note" + and node_config.get("data", {}).get("type", "") != "group" + ] # Parse node configurations node_configs_map = cls._parse_node_configs(node_configs) From add89807907c9b3bea342934198222ec76c46556 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Tue, 30 Dec 2025 10:06:49 +0800 Subject: [PATCH 12/82] add missing translation --- web/i18n/en-US/workflow.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index fc07b8446d..92396199d1 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -7,6 +7,7 @@ "blocks.datasource-empty": "Empty Data Source", "blocks.document-extractor": "Doc Extractor", "blocks.end": "Output", + "blocks.group": "Group", "blocks.http-request": "HTTP Request", "blocks.if-else": "IF/ELSE", "blocks.iteration": "Iteration", @@ -37,6 +38,7 @@ "blocksAbout.datasource-empty": "Empty Data Source placeholder", "blocksAbout.document-extractor": "Used to parse uploaded documents into text content that is easily understandable by LLM.", "blocksAbout.end": "Define the output and result type of a workflow", + "blocksAbout.group": "Group multiple nodes together for better organization", "blocksAbout.http-request": "Allow server requests to be sent over the HTTP protocol", "blocksAbout.if-else": "Allows you to split the workflow into two branches based on if/else conditions", "blocksAbout.iteration": "Perform multiple steps on a list object until all results are outputted.", From 8834e6e5318cfc64f9db4e32dc846620c147cdde Mon Sep 17 00:00:00 2001 From: zhsama Date: Sun, 4 Jan 2026 20:45:42 +0800 Subject: [PATCH 13/82] feat(workflow): enhance group node functionality with head and leaf node tracking - Added headNodeIds and leafNodeIds to GroupNodeData to track nodes that receive input and send output outside the group. - Updated useNodesInteractions hook to include headNodeIds in the group node data. - Modified isValidConnection logic in useWorkflow to validate connections based on leaf node types for group nodes. - Enhanced preprocessNodesAndEdges to rebuild temporary edges for group nodes, connecting them to external nodes for visual representation. --- .../workflow/hooks/use-nodes-interactions.ts | 5 ++ .../components/workflow/hooks/use-workflow.ts | 26 ++++--- .../components/workflow/nodes/group/types.ts | 2 + .../workflow/utils/workflow-init.ts | 68 ++++++++++++++++++- 4 files changed, 91 insertions(+), 10 deletions(-) diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index 6ac1fcd655..6addd299b9 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -2411,6 +2411,9 @@ export const useNodesInteractions = () => { const handlers: GroupHandler[] = Array.from(handlerMap.values()) + // head nodes: nodes that receive input from outside the group + const headNodeIds = [...new Set(inboundEdges.map(edge => edge.target))] + // put the group node at the top-left corner of the selection, slightly offset const { x: minX, y: minY } = getTopLeftNodePosition(bundledNodes) @@ -2420,6 +2423,8 @@ export const useNodesInteractions = () => { type: BlockEnum.Group, members, handlers, + headNodeIds, + leafNodeIds, selected: true, } diff --git a/web/app/components/workflow/hooks/use-workflow.ts b/web/app/components/workflow/hooks/use-workflow.ts index 990c8c950d..6c776f4815 100644 --- a/web/app/components/workflow/hooks/use-workflow.ts +++ b/web/app/components/workflow/hooks/use-workflow.ts @@ -4,7 +4,6 @@ import type { import type { IterationNodeType } from '../nodes/iteration/types' import type { LoopNodeType } from '../nodes/loop/types' import type { - BlockEnum, Edge, Node, ValueSelector, @@ -28,14 +27,12 @@ import { } from '../constants' import { findUsedVarNodes, getNodeOutputVars, updateNodeVars } from '../nodes/_base/components/variable/utils' import { CUSTOM_NOTE_NODE } from '../note-node/constants' - import { useStore, useWorkflowStore, } from '../store' -import { - WorkflowRunningStatus, -} from '../types' + +import { BlockEnum, WorkflowRunningStatus } from '../types' import { getWorkflowEntryNode, isWorkflowEntryNode, @@ -381,7 +378,7 @@ export const useWorkflow = () => { return startNodes }, [nodesMap, getRootNodesById]) - const isValidConnection = useCallback(({ source, sourceHandle: _sourceHandle, target }: Connection) => { + const isValidConnection = useCallback(({ source, sourceHandle, target }: Connection) => { const { edges, getNodes, @@ -396,14 +393,27 @@ export const useWorkflow = () => { if (sourceNode.parentId !== targetNode.parentId) return false + // For Group nodes, use the leaf node's type for validation + // sourceHandle format: "${leafNodeId}-${originalSourceHandle}" + let actualSourceType = sourceNode.data.type + if (sourceNode.data.type === BlockEnum.Group && sourceHandle) { + const lastDashIndex = sourceHandle.lastIndexOf('-') + if (lastDashIndex > 0) { + const leafNodeId = sourceHandle.substring(0, lastDashIndex) + const leafNode = nodes.find(node => node.id === leafNodeId) + if (leafNode) + actualSourceType = leafNode.data.type + } + } + if (sourceNode && targetNode) { - const sourceNodeAvailableNextNodes = getAvailableBlocks(sourceNode.data.type, !!sourceNode.parentId).availableNextBlocks + const sourceNodeAvailableNextNodes = getAvailableBlocks(actualSourceType, !!sourceNode.parentId).availableNextBlocks const targetNodeAvailablePrevNodes = getAvailableBlocks(targetNode.data.type, !!targetNode.parentId).availablePrevBlocks if (!sourceNodeAvailableNextNodes.includes(targetNode.data.type)) return false - if (!targetNodeAvailablePrevNodes.includes(sourceNode.data.type)) + if (!targetNodeAvailablePrevNodes.includes(actualSourceType)) return false } diff --git a/web/app/components/workflow/nodes/group/types.ts b/web/app/components/workflow/nodes/group/types.ts index 92838357cf..5f16b0e981 100644 --- a/web/app/components/workflow/nodes/group/types.ts +++ b/web/app/components/workflow/nodes/group/types.ts @@ -16,4 +16,6 @@ export type GroupHandler = { export type GroupNodeData = CommonNodeType<{ members?: GroupMember[] handlers?: GroupHandler[] + headNodeIds?: string[] // nodes that receive input from outside the group + leafNodeIds?: string[] // nodes that send output to outside the group }> diff --git a/web/app/components/workflow/utils/workflow-init.ts b/web/app/components/workflow/utils/workflow-init.ts index 1f6c0151ac..7b39758106 100644 --- a/web/app/components/workflow/utils/workflow-init.ts +++ b/web/app/components/workflow/utils/workflow-init.ts @@ -1,4 +1,5 @@ import type { CustomGroupNodeData } from '../custom-group-node' +import type { GroupNodeData } from '../nodes/group/types' import type { IfElseNodeType } from '../nodes/if-else/types' import type { IterationNodeType } from '../nodes/iteration/types' import type { LoopNodeType } from '../nodes/loop/types' @@ -92,8 +93,9 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => { const hasIterationNode = nodes.some(node => node.data.type === BlockEnum.Iteration) const hasLoopNode = nodes.some(node => node.data.type === BlockEnum.Loop) const hasGroupNode = nodes.some(node => node.type === CUSTOM_GROUP_NODE) + const hasBusinessGroupNode = nodes.some(node => node.data.type === BlockEnum.Group) - if (!hasIterationNode && !hasLoopNode && !hasGroupNode) { + if (!hasIterationNode && !hasLoopNode && !hasGroupNode && !hasBusinessGroupNode) { return { nodes, edges, @@ -248,9 +250,71 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => { } } + // Rebuild isTemp edges for business Group nodes (BlockEnum.Group) + // These edges connect the group node to external nodes for visual display + const groupTempEdges: Edge[] = [] + const inboundEdgeIds = new Set() + + nodes.forEach((groupNode) => { + if (groupNode.data.type !== BlockEnum.Group) + return + + const groupData = groupNode.data as GroupNodeData + const { members = [], headNodeIds = [], leafNodeIds = [], handlers = [] } = groupData + const memberSet = new Set(members.map(m => m.id)) + const headSet = new Set(headNodeIds) + const leafSet = new Set(leafNodeIds) + + edges.forEach((edge) => { + // Inbound edge: source outside group, target is a head node + // Use Set to dedupe since multiple head nodes may share same external source + if (!memberSet.has(edge.source) && headSet.has(edge.target)) { + const edgeId = `${edge.source}-${edge.sourceHandle}-${groupNode.id}-target` + if (!inboundEdgeIds.has(edgeId)) { + inboundEdgeIds.add(edgeId) + groupTempEdges.push({ + id: edgeId, + type: 'custom', + source: edge.source, + sourceHandle: edge.sourceHandle, + target: groupNode.id, + targetHandle: 'target', + data: { + sourceType: edge.data?.sourceType, + targetType: BlockEnum.Group, + _isTemp: true, + }, + } as Edge) + } + } + + // Outbound edge: source is a leaf node, target outside group + if (leafSet.has(edge.source) && !memberSet.has(edge.target)) { + const handler = handlers.find( + h => h.nodeId === edge.source && h.sourceHandle === edge.sourceHandle, + ) + if (handler) { + groupTempEdges.push({ + id: `${groupNode.id}-${handler.id}-${edge.target}-${edge.targetHandle}`, + type: 'custom', + source: groupNode.id, + sourceHandle: handler.id, + target: edge.target!, + targetHandle: edge.targetHandle, + data: { + sourceType: BlockEnum.Group, + targetType: edge.data?.targetType, + _isTemp: true, + }, + } as Edge) + } + } + }) + }) + return { nodes: [...nodes, ...newIterationStartNodes, ...newLoopStartNodes], - edges: [...edges, ...newEdges, ...groupInternalEdges], + edges: [...edges, ...newEdges, ...groupInternalEdges, ...groupTempEdges], } } From a6ce6a249b8a9bf83a5d57538e92ee4c3f8c1e2d Mon Sep 17 00:00:00 2001 From: zhsama Date: Sun, 4 Jan 2026 20:59:33 +0800 Subject: [PATCH 14/82] feat(workflow): refine strokeDasharray logic for temporary edges --- web/app/components/workflow/custom-edge.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/app/components/workflow/custom-edge.tsx b/web/app/components/workflow/custom-edge.tsx index 0440ca0c3e..d88c37e35d 100644 --- a/web/app/components/workflow/custom-edge.tsx +++ b/web/app/components/workflow/custom-edge.tsx @@ -25,7 +25,7 @@ import { useAvailableBlocks, useNodesInteractions, } from './hooks' -import { NodeRunningStatus } from './types' +import { BlockEnum, NodeRunningStatus } from './types' import { getEdgeColor } from './utils' const CustomEdge = ({ @@ -136,7 +136,7 @@ const CustomEdge = ({ stroke, strokeWidth: 2, opacity: data._dimmed ? 0.3 : (data._waitingRun ? 0.7 : 1), - strokeDasharray: data._isTemp ? '8 8' : undefined, + strokeDasharray: (data._isTemp && data.sourceType !== BlockEnum.Group && data.targetType !== BlockEnum.Group) ? '8 8' : undefined, }} /> From b7a2957340ef816a124343acbe94cdc9ac4822c0 Mon Sep 17 00:00:00 2001 From: zhsama Date: Sun, 4 Jan 2026 21:40:34 +0800 Subject: [PATCH 15/82] feat(workflow): implement ungroup functionality for group nodes - Added `handleUngroup`, `getCanUngroup`, and `getSelectedGroupId` methods to manage ungrouping of selected group nodes. - Integrated ungrouping logic into the `useShortcuts` hook for keyboard shortcut support (Ctrl + Shift + G). - Updated UI to include ungroup option in the panel operator popup for group nodes. - Added translations for the ungroup action in multiple languages. --- .../workflow/hooks/use-nodes-interactions.ts | 74 +++++++++++++++++++ .../workflow/hooks/use-shortcuts.ts | 13 ++++ .../panel-operator/panel-operator-popup.tsx | 22 +++++- web/i18n/en-US/workflow.json | 1 + web/i18n/ja-JP/workflow.json | 1 + web/i18n/zh-Hans/workflow.json | 1 + web/i18n/zh-Hant/workflow.json | 1 + 7 files changed, 112 insertions(+), 1 deletion(-) diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index 6addd299b9..8866ff622b 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -2529,6 +2529,77 @@ export const useNodesInteractions = () => { }) }, [handleSyncWorkflowDraft, saveStateToHistory, store, t, workflowStore]) + // check if the current selection can be ungrouped (single selected Group node) + const getCanUngroup = useCallback(() => { + const { getNodes } = store.getState() + const nodes = getNodes() + const selectedNodes = nodes.filter(node => node.selected) + + if (selectedNodes.length !== 1) + return false + + return selectedNodes[0].data.type === BlockEnum.Group + }, [store]) + + // get the selected group node id for ungroup operation + const getSelectedGroupId = useCallback(() => { + const { getNodes } = store.getState() + const nodes = getNodes() + const selectedNodes = nodes.filter(node => node.selected) + + if (selectedNodes.length === 1 && selectedNodes[0].data.type === BlockEnum.Group) + return selectedNodes[0].id + + return undefined + }, [store]) + + const handleUngroup = useCallback((groupId: string) => { + const { getNodes, setNodes, edges, setEdges } = store.getState() + const nodes = getNodes() + const groupNode = nodes.find(n => n.id === groupId) + + if (!groupNode || groupNode.data.type !== BlockEnum.Group) + return + + const memberIds = new Set((groupNode.data.members || []).map((m: { id: string }) => m.id)) + + // restore hidden member nodes + const newNodes = produce(nodes, (draft) => { + draft.forEach((node) => { + if (memberIds.has(node.id)) { + node.hidden = false + delete node.data._hiddenInGroupId + } + }) + // remove group node + const groupIndex = draft.findIndex(n => n.id === groupId) + if (groupIndex !== -1) + draft.splice(groupIndex, 1) + }) + + // restore hidden edges and remove temp edges + const newEdges = produce(edges, (draft) => { + // restore hidden edges that involve member nodes + draft.forEach((edge) => { + if (edge.hidden && (memberIds.has(edge.source) || memberIds.has(edge.target))) + edge.hidden = false + }) + // remove temp edges connected to group (iterate backwards to safely splice) + for (let i = draft.length - 1; i >= 0; i--) { + const edge = draft[i] + if (edge.data?._isTemp && (edge.source === groupId || edge.target === groupId)) + draft.splice(i, 1) + } + }) + + setNodes(newNodes) + setEdges(newEdges) + handleSyncWorkflowDraft() + saveStateToHistory(WorkflowHistoryEvent.NodeDelete, { + nodeId: groupId, + }) + }, [handleSyncWorkflowDraft, saveStateToHistory, store]) + return { handleNodeDragStart, handleNodeDrag, @@ -2550,6 +2621,7 @@ export const useNodesInteractions = () => { handleNodesDuplicate, handleNodesDelete, handleMakeGroup, + handleUngroup, handleNodeResize, handleNodeDisconnect, handleHistoryBack, @@ -2558,5 +2630,7 @@ export const useNodesInteractions = () => { undimAllNodes, hasBundledNodes, getCanMakeGroup, + getCanUngroup, + getSelectedGroupId, } } diff --git a/web/app/components/workflow/hooks/use-shortcuts.ts b/web/app/components/workflow/hooks/use-shortcuts.ts index e9479473d9..0845dbd2d9 100644 --- a/web/app/components/workflow/hooks/use-shortcuts.ts +++ b/web/app/components/workflow/hooks/use-shortcuts.ts @@ -30,6 +30,9 @@ export const useShortcuts = (): void => { hasBundledNodes, getCanMakeGroup, handleMakeGroup, + getCanUngroup, + getSelectedGroupId, + handleUngroup, } = useNodesInteractions() const { shortcutsEnabled: workflowHistoryShortcutsEnabled } = useWorkflowHistoryStore() const { handleSyncWorkflowDraft } = useNodesSyncDraft() @@ -113,6 +116,16 @@ export const useShortcuts = (): void => { } }, { exactMatch: true, useCapture: true }) + useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.g`, (e) => { + // Only intercept when the selection can be ungrouped + if (shouldHandleShortcut(e) && getCanUngroup()) { + e.preventDefault() + const groupId = getSelectedGroupId() + if (groupId) + handleUngroup(groupId) + } + }, { exactMatch: true, useCapture: true }) + useKeyPress(`${getKeyboardKeyCodeBySystem('alt')}.r`, (e) => { if (shouldHandleShortcut(e)) { e.preventDefault() diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx b/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx index b460aa651c..c095f7fcb3 100644 --- a/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx +++ b/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx @@ -41,13 +41,14 @@ const PanelOperatorPopup = ({ handleNodesDuplicate, handleNodeSelect, handleNodesCopy, + handleUngroup, } = useNodesInteractions() const { handleNodeDataUpdate } = useNodeDataUpdate() const { handleSyncWorkflowDraft } = useNodesSyncDraft() const { nodesReadOnly } = useNodesReadOnly() const edge = edges.find(edge => edge.target === id) const nodeMetaData = useNodeMetaData({ id, data } as Node) - const showChangeBlock = !nodeMetaData.isTypeFixed && !nodesReadOnly + const showChangeBlock = !nodeMetaData.isTypeFixed && !nodesReadOnly && data.type !== BlockEnum.Group const isChildNode = !!(data.isInIteration || data.isInLoop) const { data: workflowTools } = useAllWorkflowTools() @@ -61,6 +62,25 @@ const PanelOperatorPopup = ({ return (
+ { + !nodesReadOnly && data.type === BlockEnum.Group && ( + <> +
+
{ + onClosePopup() + handleUngroup(id) + }} + > + {t('panel.ungroup', { ns: 'workflow' })} + +
+
+
+ + ) + } { (showChangeBlock || canRunBySingle(data.type, isChildNode)) && ( <> diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index 92396199d1..11700722d3 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -967,6 +967,7 @@ "panel.scrollToSelectedNode": "Scroll to selected node", "panel.selectNextStep": "Select Next Step", "panel.startNode": "Start Node", + "panel.ungroup": "Ungroup", "panel.userInputField": "User Input Field", "publishLimit.startNodeDesc": "You’ve reached the limit of 2 triggers per workflow for this plan. Upgrade to publish this workflow.", "publishLimit.startNodeTitlePrefix": "Upgrade to", diff --git a/web/i18n/ja-JP/workflow.json b/web/i18n/ja-JP/workflow.json index df8fb56dd0..dd811a3858 100644 --- a/web/i18n/ja-JP/workflow.json +++ b/web/i18n/ja-JP/workflow.json @@ -964,6 +964,7 @@ "panel.scrollToSelectedNode": "選択したノードまでスクロール", "panel.selectNextStep": "次ノード選択", "panel.startNode": "開始ノード", + "panel.ungroup": "グループ解除", "panel.userInputField": "ユーザー入力欄", "publishLimit.startNodeDesc": "このプランでは、各ワークフローのトリガー数は最大 2 個まで設定できます。公開するにはアップグレードが必要です。", "publishLimit.startNodeTitlePrefix": "アップグレードして、", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index 30a380a032..569407aafa 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -965,6 +965,7 @@ "panel.scrollToSelectedNode": "滚动至选中节点", "panel.selectNextStep": "选择下一个节点", "panel.startNode": "开始节点", + "panel.ungroup": "取消编组", "panel.userInputField": "用户输入字段", "publishLimit.startNodeDesc": "您已达到此计划上每个工作流最多 2 个触发器的限制。请升级后再发布此工作流。", "publishLimit.startNodeTitlePrefix": "升级以", diff --git a/web/i18n/zh-Hant/workflow.json b/web/i18n/zh-Hant/workflow.json index b16ba1fcd9..80e72e42ff 100644 --- a/web/i18n/zh-Hant/workflow.json +++ b/web/i18n/zh-Hant/workflow.json @@ -964,6 +964,7 @@ "panel.scrollToSelectedNode": "捲動至選取的節點", "panel.selectNextStep": "選擇下一個節點", "panel.startNode": "起始節點", + "panel.ungroup": "取消群組", "panel.userInputField": "用戶輸入字段", "publishLimit.startNodeDesc": "目前方案最多允許 2 個開始節點,升級後才能發布此工作流程。", "publishLimit.startNodeTitlePrefix": "升級以", From 37c748192d471bedcf615c515b4b7f07d614abd9 Mon Sep 17 00:00:00 2001 From: zhsama Date: Sun, 4 Jan 2026 21:54:15 +0800 Subject: [PATCH 16/82] feat(workflow): implement UI-only group functionality - Added support for UI-only group nodes, including custom-group, custom-group-input, and custom-group-exit-port types. - Enhanced edge interactions to manage temporary edges connected to groups, ensuring corresponding real edges are deleted when temp edges are removed. - Updated node interaction hooks to restore hidden edges and remove temp edges efficiently. - Implemented logic for creating and managing group structures, including entry and exit ports, while maintaining execution graph integrity. --- .../workflow/design/ui-only-group.md | 127 ------------------ .../workflow/hooks/use-edges-interactions.ts | 50 ++++++- .../workflow/hooks/use-nodes-interactions.ts | 20 +-- 3 files changed, 61 insertions(+), 136 deletions(-) delete mode 100644 web/app/components/workflow/design/ui-only-group.md diff --git a/web/app/components/workflow/design/ui-only-group.md b/web/app/components/workflow/design/ui-only-group.md deleted file mode 100644 index f7c7f981f0..0000000000 --- a/web/app/components/workflow/design/ui-only-group.md +++ /dev/null @@ -1,127 +0,0 @@ -# UI-only Group(含 Group Input / Exit Port)方案 - -## 设计方案 - -### 目标 - -- Group 可持久化:刷新后仍保留分组/命名/布局。 -- Group 不影响执行:Run Workflow 时不执行 Group/Input/ExitPort,也不改变真实执行图语义。 -- 新增入边:任意外部节点连到 Group(或 Group Input)时,等价于“通过 Group Input fan-out 到每个 entry”。 -- handler 粒度:以 leaf 节点的 `sourceHandle` 为粒度生成 Exit Port(If-Else / Classifier 等多 handler 需要拆分)。 -- 支持改名:Group 标题、每个 Exit Port 名称可编辑并保存。 -- 最小化副作用:真实节点/真实边不被“重接到 Group”,只做 UI 折叠;状态订阅尽量只取最小字段,避免雪崩式 rerender。 - -### 核心模型(两层图) - -1) **真实图(可执行、可保存)** - -- 真实 workflow nodes + 真实 edges(执行图语义只由它们决定)。 -- Group 相关 UI 节点也会被保存到 graph.nodes,但后端运行时会过滤掉(不进入执行图)。 - -2) **展示图(仅 UI)** - -- 组内成员节点与其相关真实边标记 `hidden=true`(保存,用于刷新后仍保持折叠)。 -- 额外生成 **临时 UI 边**(`edge.data._isTemp = true`,不会 sync 到后端),用于: - - 外部 → Group Input(表示外部连到该组的入边) - - Exit Port → 外部(表示该组 handler 的出边) - -## 影响范围 - -### 前端(`web/`) - -- 新增 3 个 UI-only node type:`custom-group` / `custom-group-input` / `custom-group-exit-port`(组件、样式、panel/rename 交互)。 -- `workflow/index.tsx` 与 `workflow-preview/index.tsx`:注册 nodeTypes。 -- `hooks/use-nodes-interactions.ts`: - - 重做 `handleMakeGroup`:创建 group + input + exit ports;隐藏成员节点/相关真实边;不做“重接真实边到 group”。 - - 扩展 `handleNodeConnect`:遇到 group/input/exitPort 时做连线翻译。 - - 扩展 edge delete:若删除的是临时 UI 边,反向删除对应真实边。 -- 新增派生 UI 边的 hook(示例):`hooks/use-group-ui-edges.ts`(从真实图派生临时 UI 边并写入 ReactFlow edges state)。 -- 新增 `utils/get-node-source-handles.ts`:从节点数据提取可用 `sourceHandle`(If-Else/Classifer 等)。 -- 复用现有 `use-make-group.ts`:继续以“共同 pre node handler(直接前序 handler)”控制 `Make group` disabled。 - -### 后端(`api/`) - -- `api/core/workflow/graph/graph.py`:运行时过滤 `type in {'custom-note','custom-group','custom-group-input','custom-group-exit-port'}`,确保 UI 节点不进入执行图。 - -## 具体实施 - -### 1) 节点类型与数据结构(可持久化、无 `_` 前缀) - -#### Group 容器节点(UI-only) - -- `node.type = 'custom-group'` -- `node.data.type = ''` -- `node.data.group`: - - `groupId: string`(可等于 node.id) - - `title: string` - - `memberNodeIds: string[]` - - `entryNodeIds: string[]` - - `inputNodeId: string` - - `exitPorts: Array<{ portNodeId: string; leafNodeId: string; sourceHandle: string; name: string }>` - - `collapsed: boolean` - -#### Group Input 节点(UI-only) - -- `node.type = 'custom-group-input'` -- `node.data.type = ''` -- `node.data.groupInput`: - - `groupId: string` - - `title: string` - -#### Exit Port 节点(UI-only) - -- `node.type = 'custom-group-exit-port'` -- `node.data.type = ''` -- `node.data.exitPort`: - - `groupId: string` - - `leafNodeId: string` - - `sourceHandle: string` - - `name: string` - -### 2) entry / leaf / handler 计算 - -- entry(branch 头结点):选区内“有入边且所有入边 source 在选区外”的节点。 -- 禁止 side-entrance:若存在 `outside -> selectedNonEntry` 入边,则不可 group。 -- 共同 pre node handler(直接前序 handler): - - 对每个 entry,收集其来自选区外的所有入边的 `(source, sourceHandle)` 集合 - - 要求每个 entry 的集合 `size === 1`,且所有 entry 的该值完全一致 - - 否则 `Make group` disabled -- leaf:选区内“没有指向选区内节点的出边”的节点。 -- leaf sourceHandles:通过 `getNodeSourceHandles(node)` 枚举(普通 `'source'`、If-Else/Classifier 等拆分)。 - -### 3) Make group - -- 创建 `custom-group` + `custom-group-input` + 多个 `custom-group-exit-port` 节点: - - group/input/exitPort 坐标按选区包围盒计算,input 在左侧,exitPort 右侧按 handler 列表排列 -- 隐藏成员节点:对 `memberNodeIds` 设 `node.hidden = true`(持久化) -- 隐藏相关真实边:凡是 `edge.source/edge.target` 在 `memberNodeIds` 的真实边设 `edge.hidden = true`(持久化) -- 不创建/不重接任何“指向 group/input/exitPort 的真实边” - -### 4) UI edge 派生 - -- 从“真实边 + group 定义”派生临时 UI 边并写入 edges state: - - inbound:真实 `outside -> entry` 映射为 `outside -> groupInput` - - outbound:真实 `leaf(sourceHandle) -> outside` 映射为 `exitPort -> outside` -- 临时 UI 边统一标记 `edge.data._isTemp = true`,并在需要时写入用于反向映射的最小字段(`groupId / leafNodeId / sourceHandle / target / targetHandle` 等)。 -- 为避免雪崩 rerender: - - 派生逻辑只订阅最小字段(edges 的 `source/sourceHandle/target/targetHandle/hidden` + group 定义),用 `shallow` 比较 key 列表 - - UI 边增量更新:仅当派生 key 变化时才 `setEdges` - -### 5) 连线翻译(拖线到 UI 节点最终只改真实边) - -- `onConnect(target is custom-group or custom-group-input)`: - - 翻译为:对该 group 的每个 `entryNodeId` 创建真实边 `source -> entryNodeId`(fan-out) - - 复用现有合法性校验(available blocks + cycle check),要求每条 fan-out 都合法 -- `onConnect(source is custom-group-exit-port)`: - - 翻译为:创建真实边 `leafNodeId(sourceHandle) -> target` - -### 6) 删除 UI 边(反向翻译) - -- 若选中并删除的是临时 inbound UI 边:删除所有匹配的真实边 `source -> entryNodeId`(entryNodeIds 来自 group 定义,source/sourceHandle 来自 UI 边) -- 若选中并删除的是临时 outbound UI 边:删除对应真实边 `leafNodeId(sourceHandle) -> target` - -### 7) 可编辑 - -- Group 标题:更新 `custom-group.data.group.title` -- Exit Port 名称:更新 `custom-group-exit-port.data.exitPort.name` -- 通过 `useNodeDataUpdateWithSyncDraft` 写回并 sync draft diff --git a/web/app/components/workflow/hooks/use-edges-interactions.ts b/web/app/components/workflow/hooks/use-edges-interactions.ts index 5104b47ef4..6d17f3ce75 100644 --- a/web/app/components/workflow/hooks/use-edges-interactions.ts +++ b/web/app/components/workflow/hooks/use-edges-interactions.ts @@ -10,6 +10,7 @@ import { useCallback } from 'react' import { useStoreApi, } from 'reactflow' +import { BlockEnum } from '../types' import { getNodesConnectedSourceOrTargetHandleIdsMap } from '../utils' import { useNodesSyncDraft } from './use-nodes-sync-draft' import { useNodesReadOnly } from './use-workflow' @@ -108,6 +109,50 @@ export const useEdgesInteractions = () => { return const currentEdge = edges[currentEdgeIndex] const nodes = getNodes() + + // collect edges to delete (including corresponding real edges for temp edges) + const edgesToDelete: Set = new Set([currentEdge.id]) + + // if deleting a temp edge connected to a group, also delete the corresponding real hidden edge + if (currentEdge.data?._isTemp) { + const groupNode = nodes.find(n => + n.data.type === BlockEnum.Group + && (n.id === currentEdge.source || n.id === currentEdge.target), + ) + + if (groupNode) { + const memberIds = new Set((groupNode.data.members || []).map((m: { id: string }) => m.id)) + + if (currentEdge.target === groupNode.id) { + // inbound temp edge: find real edge with same source, target is a head node + edges.forEach((edge) => { + if (edge.source === currentEdge.source + && memberIds.has(edge.target) + && edge.sourceHandle === currentEdge.sourceHandle) { + edgesToDelete.add(edge.id) + } + }) + } + else if (currentEdge.source === groupNode.id) { + // outbound temp edge: sourceHandle format is "leafNodeId-originalHandle" + const sourceHandle = currentEdge.sourceHandle || '' + const lastDashIndex = sourceHandle.lastIndexOf('-') + if (lastDashIndex > 0) { + const leafNodeId = sourceHandle.substring(0, lastDashIndex) + const originalHandle = sourceHandle.substring(lastDashIndex + 1) + + edges.forEach((edge) => { + if (edge.source === leafNodeId + && edge.target === currentEdge.target + && (edge.sourceHandle || 'source') === originalHandle) { + edgesToDelete.add(edge.id) + } + }) + } + } + } + } + const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap( [ { type: 'remove', edge: currentEdge }, @@ -126,7 +171,10 @@ export const useEdgesInteractions = () => { }) setNodes(newNodes) const newEdges = produce(edges, (draft) => { - draft.splice(currentEdgeIndex, 1) + for (let i = draft.length - 1; i >= 0; i--) { + if (edgesToDelete.has(draft[i].id)) + draft.splice(i, 1) + } }) setEdges(newEdges) handleSyncWorkflowDraft() diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index 8866ff622b..1355600d8a 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -2577,19 +2577,23 @@ export const useNodesInteractions = () => { draft.splice(groupIndex, 1) }) - // restore hidden edges and remove temp edges + // restore hidden edges and remove temp edges in single pass O(E) const newEdges = produce(edges, (draft) => { - // restore hidden edges that involve member nodes - draft.forEach((edge) => { + const indicesToRemove: number[] = [] + + for (let i = 0; i < draft.length; i++) { + const edge = draft[i] + // restore hidden edges that involve member nodes if (edge.hidden && (memberIds.has(edge.source) || memberIds.has(edge.target))) edge.hidden = false - }) - // remove temp edges connected to group (iterate backwards to safely splice) - for (let i = draft.length - 1; i >= 0; i--) { - const edge = draft[i] + // collect temp edges connected to group for removal if (edge.data?._isTemp && (edge.source === groupId || edge.target === groupId)) - draft.splice(i, 1) + indicesToRemove.push(i) } + + // remove collected indices in reverse order to avoid index shift + for (let i = indicesToRemove.length - 1; i >= 0; i--) + draft.splice(indicesToRemove[i], 1) }) setNodes(newNodes) From 75afc2dc0eba6f84a8e11f62ab10ae51d83c3f17 Mon Sep 17 00:00:00 2001 From: zhsama Date: Mon, 5 Jan 2026 14:42:48 +0800 Subject: [PATCH 17/82] chore: update packageManager version in package.json to pnpm@10.27.0 --- web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/package.json b/web/package.json index b595d433f9..3f05f90e09 100644 --- a/web/package.json +++ b/web/package.json @@ -3,7 +3,7 @@ "type": "module", "version": "1.11.2", "private": true, - "packageManager": "pnpm@10.26.2+sha512.0e308ff2005fc7410366f154f625f6631ab2b16b1d2e70238444dd6ae9d630a8482d92a451144debc492416896ed16f7b114a86ec68b8404b2443869e68ffda6", + "packageManager": "pnpm@10.27.0+sha512.72d699da16b1179c14ba9e64dc71c9a40988cbdc65c264cb0e489db7de917f20dcf4d64d8723625f2969ba52d4b7e2a1170682d9ac2a5dcaeaab732b7e16f04a", "engines": { "node": ">=v22.11.0" }, From 60250355cb5286d8980734fc39d077e06d9f6b29 Mon Sep 17 00:00:00 2001 From: zhsama Date: Mon, 5 Jan 2026 15:48:26 +0800 Subject: [PATCH 18/82] feat(workflow): enhance group edge management and validation - Introduced `createGroupInboundEdges` function to manage edges for group nodes, ensuring proper connections to head nodes. - Updated edge creation logic to handle group nodes in both inbound and outbound scenarios, including temporary edges. - Enhanced validation in `useWorkflow` to check connections for group nodes based on their head nodes. - Refined edge processing in `preprocessNodesAndEdges` to ensure correct handling of source handles for group edges. --- .../workflow/hooks/use-nodes-interactions.ts | 288 +++++++++++++++--- .../components/workflow/hooks/use-workflow.ts | 23 +- .../workflow/utils/workflow-init.ts | 8 +- 3 files changed, 277 insertions(+), 42 deletions(-) diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index 1355600d8a..6d80024796 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -138,7 +138,7 @@ function createGroupEdgePair(params: { targetHandle, data: { ...baseEdgeData, - sourceType: originalNode.data.type, // Use original node type, not group + sourceType: BlockEnum.Group, targetType: targetNode.data.type, _isTemp: true, }, @@ -148,6 +148,62 @@ function createGroupEdgePair(params: { return { realEdge, uiEdge } } +function createGroupInboundEdges(params: { + sourceNodeId: string + sourceHandle: string + groupNodeId: string + groupData: GroupNodeData + nodes: Node[] + baseEdgeData?: Partial + zIndex?: number +}): { realEdges: Edge[], uiEdge: Edge } | null { + const { sourceNodeId, sourceHandle, groupNodeId, groupData, nodes, baseEdgeData = {}, zIndex = 0 } = params + + const sourceNode = nodes.find(node => node.id === sourceNodeId) + const headNodeIds = groupData.headNodeIds || [] + + if (!sourceNode || headNodeIds.length === 0) + return null + + const realEdges: Edge[] = headNodeIds.map((headNodeId) => { + const headNode = nodes.find(node => node.id === headNodeId) + return { + id: `${sourceNodeId}-${sourceHandle}-${headNodeId}-target`, + type: CUSTOM_EDGE, + source: sourceNodeId, + sourceHandle, + target: headNodeId, + targetHandle: 'target', + hidden: true, + data: { + ...baseEdgeData, + sourceType: sourceNode.data.type, + targetType: headNode?.data.type, + _hiddenInGroupId: groupNodeId, + }, + zIndex, + } as Edge + }) + + const uiEdge: Edge = { + id: `${sourceNodeId}-${sourceHandle}-${groupNodeId}-target`, + type: CUSTOM_EDGE, + source: sourceNodeId, + sourceHandle, + target: groupNodeId, + targetHandle: 'target', + data: { + ...baseEdgeData, + sourceType: sourceNode.data.type, + targetType: BlockEnum.Group, + _isTemp: true, + }, + zIndex, + } + + return { realEdges, uiEdge } +} + export const useNodesInteractions = () => { const { t } = useTranslation() const store = useStoreApi() @@ -593,7 +649,76 @@ export const useNodesInteractions = () => { return } - // Normal edge connection (non-group source) + const isTargetGroup = targetNode?.data.type === BlockEnum.Group + + if (isTargetGroup && source && sourceHandle) { + const groupData = targetNode.data as GroupNodeData + const headNodeIds = groupData.headNodeIds || [] + + if (edges.find(edge => + edge.source === source + && edge.sourceHandle === sourceHandle + && edge.target === target + && edge.targetHandle === targetHandle, + )) { + return + } + + const parentNode = nodes.find(node => node.id === sourceNode?.parentId) + const isInIteration = parentNode && parentNode.data.type === BlockEnum.Iteration + const isInLoop = !!parentNode && parentNode.data.type === BlockEnum.Loop + + const inboundResult = createGroupInboundEdges({ + sourceNodeId: source, + sourceHandle, + groupNodeId: target!, + groupData, + nodes, + baseEdgeData: { + isInIteration, + iteration_id: isInIteration ? sourceNode?.parentId : undefined, + isInLoop, + loop_id: isInLoop ? sourceNode?.parentId : undefined, + }, + }) + + if (!inboundResult) + return + + const { realEdges, uiEdge } = inboundResult + + const edgeChanges = realEdges.map(edge => ({ type: 'add' as const, edge })) + const nodesConnectedSourceOrTargetHandleIdsMap + = getNodesConnectedSourceOrTargetHandleIdsMap(edgeChanges, nodes) + + const newNodes = produce(nodes, (draft: Node[]) => { + draft.forEach((node) => { + if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) { + node.data = { + ...node.data, + ...nodesConnectedSourceOrTargetHandleIdsMap[node.id], + } + } + }) + }) + + const newEdges = produce(edges, (draft) => { + realEdges.forEach((edge) => { + draft.push(edge) + }) + draft.push(uiEdge) + }) + + setNodes(newNodes) + setEdges(newEdges) + + handleSyncWorkflowDraft() + saveStateToHistory(WorkflowHistoryEvent.NodeConnect, { + nodeId: headNodeIds[0], + }) + return + } + if ( edges.find( edge => @@ -1421,12 +1546,37 @@ export const useNodesInteractions = () => { } } else { - // Normal case: find edge to remove - const currentEdge = edges.find( - edge => edge.source === prevNodeId && edge.target === nextNodeId, - ) - if (currentEdge) - edgesToRemove.push(currentEdge.id) + const isNextNodeGroupForRemoval = nextNode.data.type === BlockEnum.Group + + if (isNextNodeGroupForRemoval) { + const groupData = nextNode.data as GroupNodeData + const headNodeIds = groupData.headNodeIds || [] + + headNodeIds.forEach((headNodeId) => { + const realEdge = edges.find( + edge => edge.source === prevNodeId + && edge.sourceHandle === prevNodeSourceHandle + && edge.target === headNodeId, + ) + if (realEdge) + edgesToRemove.push(realEdge.id) + }) + + const uiEdge = edges.find( + edge => edge.source === prevNodeId + && edge.sourceHandle === prevNodeSourceHandle + && edge.target === nextNodeId, + ) + if (uiEdge) + edgesToRemove.push(uiEdge.id) + } + else { + const currentEdge = edges.find( + edge => edge.source === prevNodeId && edge.target === nextNodeId, + ) + if (currentEdge) + edgesToRemove.push(currentEdge.id) + } if (nodeType !== BlockEnum.DataSource) { newPrevEdge = { @@ -1455,6 +1605,8 @@ export const useNodesInteractions = () => { } let newNextEdge: Edge | null = null + let newNextUiEdge: Edge | null = null + const newNextRealEdges: Edge[] = [] const nextNodeParentNode = nodes.find(node => node.id === nextNode.parentId) || null @@ -1465,34 +1617,94 @@ export const useNodesInteractions = () => { = !!nextNodeParentNode && nextNodeParentNode.data.type === BlockEnum.Loop + const isNextNodeGroup = nextNode.data.type === BlockEnum.Group + if ( nodeType !== BlockEnum.IfElse && nodeType !== BlockEnum.QuestionClassifier && nodeType !== BlockEnum.LoopEnd ) { - newNextEdge = { - id: `${newNode.id}-${sourceHandle}-${nextNodeId}-${nextNodeTargetHandle}`, - type: CUSTOM_EDGE, - source: newNode.id, - sourceHandle, - target: nextNodeId, - targetHandle: nextNodeTargetHandle, - data: { - sourceType: newNode.data.type, - targetType: nextNode.data.type, - isInIteration: isNextNodeInIteration, - isInLoop: isNextNodeInLoop, - iteration_id: isNextNodeInIteration - ? nextNode.parentId - : undefined, - loop_id: isNextNodeInLoop ? nextNode.parentId : undefined, - _connectedNodeIsSelected: true, - }, - zIndex: nextNode.parentId - ? isNextNodeInIteration - ? ITERATION_CHILDREN_Z_INDEX - : LOOP_CHILDREN_Z_INDEX - : 0, + if (isNextNodeGroup) { + const groupData = nextNode.data as GroupNodeData + const headNodeIds = groupData.headNodeIds || [] + + headNodeIds.forEach((headNodeId) => { + const headNode = nodes.find(node => node.id === headNodeId) + newNextRealEdges.push({ + id: `${newNode.id}-${sourceHandle}-${headNodeId}-target`, + type: CUSTOM_EDGE, + source: newNode.id, + sourceHandle, + target: headNodeId, + targetHandle: 'target', + hidden: true, + data: { + sourceType: newNode.data.type, + targetType: headNode?.data.type, + isInIteration: isNextNodeInIteration, + isInLoop: isNextNodeInLoop, + iteration_id: isNextNodeInIteration ? nextNode.parentId : undefined, + loop_id: isNextNodeInLoop ? nextNode.parentId : undefined, + _hiddenInGroupId: nextNodeId, + _connectedNodeIsSelected: true, + }, + zIndex: nextNode.parentId + ? isNextNodeInIteration + ? ITERATION_CHILDREN_Z_INDEX + : LOOP_CHILDREN_Z_INDEX + : 0, + } as Edge) + }) + + newNextUiEdge = { + id: `${newNode.id}-${sourceHandle}-${nextNodeId}-target`, + type: CUSTOM_EDGE, + source: newNode.id, + sourceHandle, + target: nextNodeId, + targetHandle: 'target', + data: { + sourceType: newNode.data.type, + targetType: BlockEnum.Group, + isInIteration: isNextNodeInIteration, + isInLoop: isNextNodeInLoop, + iteration_id: isNextNodeInIteration ? nextNode.parentId : undefined, + loop_id: isNextNodeInLoop ? nextNode.parentId : undefined, + _isTemp: true, + _connectedNodeIsSelected: true, + }, + zIndex: nextNode.parentId + ? isNextNodeInIteration + ? ITERATION_CHILDREN_Z_INDEX + : LOOP_CHILDREN_Z_INDEX + : 0, + } + } + else { + newNextEdge = { + id: `${newNode.id}-${sourceHandle}-${nextNodeId}-${nextNodeTargetHandle}`, + type: CUSTOM_EDGE, + source: newNode.id, + sourceHandle, + target: nextNodeId, + targetHandle: nextNodeTargetHandle, + data: { + sourceType: newNode.data.type, + targetType: nextNode.data.type, + isInIteration: isNextNodeInIteration, + isInLoop: isNextNodeInLoop, + iteration_id: isNextNodeInIteration + ? nextNode.parentId + : undefined, + loop_id: isNextNodeInLoop ? nextNode.parentId : undefined, + _connectedNodeIsSelected: true, + }, + zIndex: nextNode.parentId + ? isNextNodeInIteration + ? ITERATION_CHILDREN_Z_INDEX + : LOOP_CHILDREN_Z_INDEX + : 0, + } } } const edgeChanges = [ @@ -1500,6 +1712,8 @@ export const useNodesInteractions = () => { ...(newPrevEdge ? [{ type: 'add' as const, edge: newPrevEdge }] : []), ...(newPrevUiEdge ? [{ type: 'add' as const, edge: newPrevUiEdge }] : []), ...(newNextEdge ? [{ type: 'add' as const, edge: newNextEdge }] : []), + ...newNextRealEdges.map(edge => ({ type: 'add' as const, edge })), + ...(newNextUiEdge ? [{ type: 'add' as const, edge: newNextUiEdge }] : []), ] const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap( @@ -1568,7 +1782,6 @@ export const useNodesInteractions = () => { }) } const newEdges = produce(edges, (draft) => { - // Remove edges by id (supports removing multiple edges for group case) const filteredDraft = draft.filter(edge => !edgesToRemove.includes(edge.id)) draft.length = 0 draft.push(...filteredDraft) @@ -1585,6 +1798,11 @@ export const useNodesInteractions = () => { draft.push(newPrevUiEdge) if (newNextEdge) draft.push(newNextEdge) + newNextRealEdges.forEach((edge) => { + draft.push(edge) + }) + if (newNextUiEdge) + draft.push(newNextUiEdge) }) setEdges(newEdges) } @@ -2503,15 +2721,15 @@ export const useNodesInteractions = () => { type: edge.type || CUSTOM_EDGE, source: groupNode.id, target: edge.target, - sourceHandle: handlerId, // handler id: nodeId-sourceHandle + sourceHandle: handlerId, targetHandle: edge.targetHandle, data: { ...edge.data, - sourceType: edge.data.sourceType, // Keep original node type, not group + sourceType: BlockEnum.Group, targetType: nodeTypeMap.get(edge.target)!, _hiddenInGroupId: undefined, _isBundled: false, - _isTemp: true, // UI-only edge, not persisted to backend + _isTemp: true, }, zIndex: edge.zIndex, }) diff --git a/web/app/components/workflow/hooks/use-workflow.ts b/web/app/components/workflow/hooks/use-workflow.ts index 6c776f4815..5910cef913 100644 --- a/web/app/components/workflow/hooks/use-workflow.ts +++ b/web/app/components/workflow/hooks/use-workflow.ts @@ -1,6 +1,7 @@ import type { Connection, } from 'reactflow' +import type { GroupNodeData } from '../nodes/group/types' import type { IterationNodeType } from '../nodes/iteration/types' import type { LoopNodeType } from '../nodes/loop/types' import type { @@ -410,11 +411,25 @@ export const useWorkflow = () => { const sourceNodeAvailableNextNodes = getAvailableBlocks(actualSourceType, !!sourceNode.parentId).availableNextBlocks const targetNodeAvailablePrevNodes = getAvailableBlocks(targetNode.data.type, !!targetNode.parentId).availablePrevBlocks - if (!sourceNodeAvailableNextNodes.includes(targetNode.data.type)) - return false + if (targetNode.data.type === BlockEnum.Group) { + const groupData = targetNode.data as GroupNodeData + const headNodeIds = groupData.headNodeIds || [] + if (headNodeIds.length > 0) { + const headNode = nodes.find(node => node.id === headNodeIds[0]) + if (headNode) { + const headNodeAvailablePrevNodes = getAvailableBlocks(headNode.data.type, !!targetNode.parentId).availablePrevBlocks + if (!headNodeAvailablePrevNodes.includes(actualSourceType)) + return false + } + } + } + else { + if (!sourceNodeAvailableNextNodes.includes(targetNode.data.type)) + return false - if (!targetNodeAvailablePrevNodes.includes(actualSourceType)) - return false + if (!targetNodeAvailablePrevNodes.includes(actualSourceType)) + return false + } } const hasCycle = (node: Node, visited = new Set()) => { diff --git a/web/app/components/workflow/utils/workflow-init.ts b/web/app/components/workflow/utils/workflow-init.ts index 7b39758106..c236049c64 100644 --- a/web/app/components/workflow/utils/workflow-init.ts +++ b/web/app/components/workflow/utils/workflow-init.ts @@ -269,14 +269,15 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => { // Inbound edge: source outside group, target is a head node // Use Set to dedupe since multiple head nodes may share same external source if (!memberSet.has(edge.source) && headSet.has(edge.target)) { - const edgeId = `${edge.source}-${edge.sourceHandle}-${groupNode.id}-target` + const sourceHandle = edge.sourceHandle || 'source' + const edgeId = `${edge.source}-${sourceHandle}-${groupNode.id}-target` if (!inboundEdgeIds.has(edgeId)) { inboundEdgeIds.add(edgeId) groupTempEdges.push({ id: edgeId, type: 'custom', source: edge.source, - sourceHandle: edge.sourceHandle, + sourceHandle, target: groupNode.id, targetHandle: 'target', data: { @@ -290,8 +291,9 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => { // Outbound edge: source is a leaf node, target outside group if (leafSet.has(edge.source) && !memberSet.has(edge.target)) { + const edgeSourceHandle = edge.sourceHandle || 'source' const handler = handlers.find( - h => h.nodeId === edge.source && h.sourceHandle === edge.sourceHandle, + h => h.nodeId === edge.source && h.sourceHandle === edgeSourceHandle, ) if (handler) { groupTempEdges.push({ From 50bed78d7a43cf1320358e140f5373d301ede25e Mon Sep 17 00:00:00 2001 From: zhsama Date: Mon, 5 Jan 2026 16:29:00 +0800 Subject: [PATCH 19/82] feat(workflow): add group node support and translations - Introduced GroupDefault node with metadata and default values for group nodes. - Enhanced useNodeMetaData hook to handle group node author and description using translations. - Added translations for group node functionality in English, Japanese, Simplified Chinese, and Traditional Chinese. --- .../workflow/hooks/use-nodes-meta-data.ts | 11 +++++++- .../workflow/nodes/group/default.ts | 26 +++++++++++++++++++ web/i18n/en-US/workflow.ts | 1 + web/i18n/ja-JP/workflow.ts | 1 + web/i18n/zh-Hans/workflow.ts | 1 + web/i18n/zh-Hant/workflow.ts | 1 + 6 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 web/app/components/workflow/nodes/group/default.ts diff --git a/web/app/components/workflow/hooks/use-nodes-meta-data.ts b/web/app/components/workflow/hooks/use-nodes-meta-data.ts index 2ea2fd9e9f..36c071f4d4 100644 --- a/web/app/components/workflow/hooks/use-nodes-meta-data.ts +++ b/web/app/components/workflow/hooks/use-nodes-meta-data.ts @@ -1,8 +1,10 @@ import type { AvailableNodesMetaData } from '@/app/components/workflow/hooks-store' import type { Node } from '@/app/components/workflow/types' import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' import { CollectionType } from '@/app/components/tools/types' import { useHooksStore } from '@/app/components/workflow/hooks-store' +import GroupDefault from '@/app/components/workflow/nodes/group/default' import { useStore } from '@/app/components/workflow/store' import { BlockEnum } from '@/app/components/workflow/types' import { useGetLanguage } from '@/context/i18n' @@ -25,6 +27,7 @@ export const useNodesMetaData = () => { } export const useNodeMetaData = (node: Node) => { + const { t } = useTranslation() const language = useGetLanguage() const { data: buildInTools } = useAllBuiltInTools() const { data: customTools } = useAllCustomTools() @@ -34,6 +37,9 @@ export const useNodeMetaData = (node: Node) => { const { data } = node const nodeMetaData = availableNodesMetaData.nodesMap?.[data.type] const author = useMemo(() => { + if (data.type === BlockEnum.Group) + return GroupDefault.metaData.author + if (data.type === BlockEnum.DataSource) return dataSourceList?.find(dataSource => dataSource.plugin_id === data.plugin_id)?.author @@ -48,6 +54,9 @@ export const useNodeMetaData = (node: Node) => { }, [data, buildInTools, customTools, workflowTools, nodeMetaData, dataSourceList]) const description = useMemo(() => { + if (data.type === BlockEnum.Group) + return t('blocksAbout.group', { ns: 'workflow' }) + if (data.type === BlockEnum.DataSource) return dataSourceList?.find(dataSource => dataSource.plugin_id === data.plugin_id)?.description[language] if (data.type === BlockEnum.Tool) { @@ -58,7 +67,7 @@ export const useNodeMetaData = (node: Node) => { return customTools?.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.description[language] } return nodeMetaData?.metaData.description - }, [data, buildInTools, customTools, workflowTools, nodeMetaData, dataSourceList, language]) + }, [data, buildInTools, customTools, workflowTools, nodeMetaData, dataSourceList, language, t]) return useMemo(() => { return { diff --git a/web/app/components/workflow/nodes/group/default.ts b/web/app/components/workflow/nodes/group/default.ts new file mode 100644 index 0000000000..b46d3544b6 --- /dev/null +++ b/web/app/components/workflow/nodes/group/default.ts @@ -0,0 +1,26 @@ +import type { NodeDefault } from '../../types' +import type { GroupNodeData } from './types' +import { BlockEnum } from '@/app/components/workflow/types' +import { genNodeMetaData } from '@/app/components/workflow/utils' + +const metaData = genNodeMetaData({ + sort: 100, + type: BlockEnum.Group, +}) + +const nodeDefault: NodeDefault = { + metaData, + defaultValue: { + members: [], + handlers: [], + headNodeIds: [], + leafNodeIds: [], + }, + checkValid() { + return { + isValid: true, + } + }, +} + +export default nodeDefault diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index c46ad45996..76050edabb 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -351,6 +351,7 @@ const translation = { 'trigger-schedule': 'Time-based workflow trigger that starts workflows on a schedule', 'trigger-webhook': 'Webhook Trigger receives HTTP pushes from third-party systems to automatically trigger workflows.', 'trigger-plugin': 'Third-party integration trigger that starts workflows from external platform events', + 'group': 'Group multiple nodes together for better organization and reusability.', }, difyTeam: 'Dify Team', operator: { diff --git a/web/i18n/ja-JP/workflow.ts b/web/i18n/ja-JP/workflow.ts index 850796fa48..1a5283f5b3 100644 --- a/web/i18n/ja-JP/workflow.ts +++ b/web/i18n/ja-JP/workflow.ts @@ -351,6 +351,7 @@ const translation = { 'trigger-schedule': 'スケジュールに基づいてワークフローを開始する時間ベースのトリガー', 'trigger-webhook': 'Webhook トリガーは第三者システムからの HTTP プッシュを受信してワークフローを自動的に開始します。', 'trigger-plugin': 'サードパーティ統合トリガー、外部プラットフォームのイベントによってワークフローを開始します', + 'group': '複数のノードをグループ化して整理・管理しやすくします', }, difyTeam: 'Dify チーム', operator: { diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index 78deb4bf84..6d00f279a3 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -351,6 +351,7 @@ const translation = { 'trigger-webhook': 'Webhook 触发器接收来自第三方系统的 HTTP 推送以自动触发工作流。', 'trigger-schedule': '基于时间的工作流触发器,按计划启动工作流', 'trigger-plugin': '从外部平台事件启动工作流的第三方集成触发器', + 'group': '将多个节点组合在一起,以便更好地组织和管理', }, difyTeam: 'Dify 团队', operator: { diff --git a/web/i18n/zh-Hant/workflow.ts b/web/i18n/zh-Hant/workflow.ts index da8b4996cd..bd4bc720f6 100644 --- a/web/i18n/zh-Hant/workflow.ts +++ b/web/i18n/zh-Hant/workflow.ts @@ -337,6 +337,7 @@ const translation = { 'trigger-schedule': '基於時間的工作流程觸發器,可按計劃啟動工作流程', 'trigger-webhook': 'Webhook 觸發器接收來自第三方系統的 HTTP 推送,以自動觸發工作流程。', 'trigger-plugin': '第三方整合觸發器,從外部平台事件啟動工作流程', + 'group': '將多個節點組合在一起,以便更好地組織和管理', }, operator: { zoomIn: '放大', From 9012dced6ab881954cb7a03cde69c9d120dce0fa Mon Sep 17 00:00:00 2001 From: zhsama Date: Mon, 5 Jan 2026 17:42:31 +0800 Subject: [PATCH 20/82] feat(workflow): improve group node interaction handling - Enhanced `useNodesInteractions` to better manage group node handlers and connections, ensuring accurate identification of leaf nodes and their branches. - Updated logic to create handlers based on node connections, differentiating between internal and external connections. - Refined initial node setup to include target branches for group nodes, improving the overall interaction model for grouped elements. --- .../workflow/hooks/use-nodes-interactions.ts | 95 +++++++++++-------- .../components/workflow/nodes/group/node.tsx | 5 +- .../workflow/utils/workflow-init.ts | 10 ++ 3 files changed, 68 insertions(+), 42 deletions(-) diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index 6d80024796..b52fabae1e 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -103,7 +103,23 @@ function createGroupEdgePair(params: { }): { realEdge: Edge, uiEdge: Edge } | null { const { groupNodeId, handlerId, targetNodeId, targetHandle, nodes, baseEdgeData = {}, zIndex = 0 } = params - const { originalNodeId, originalSourceHandle } = parseGroupHandlerId(handlerId) + const groupNode = nodes.find(node => node.id === groupNodeId) + const groupData = groupNode?.data as GroupNodeData | undefined + const handler = groupData?.handlers?.find(h => h.id === handlerId) + + let originalNodeId: string + let originalSourceHandle: string + + if (handler?.nodeId && handler?.sourceHandle) { + originalNodeId = handler.nodeId + originalSourceHandle = handler.sourceHandle + } + else { + const parsed = parseGroupHandlerId(handlerId) + originalNodeId = parsed.originalNodeId + originalSourceHandle = parsed.originalSourceHandle + } + const originalNode = nodes.find(node => node.id === originalNodeId) const targetNode = nodes.find(node => node.id === targetNodeId) @@ -2580,9 +2596,40 @@ export const useNodesInteractions = () => { const outboundEdges = edges.filter(edge => bundledNodeIdSet.has(edge.source) && !bundledNodeIdSet.has(edge.target)) // leaf node: no outbound edges to other nodes in the selection - const leafNodeIds = bundledNodes - .filter(node => !edges.some(edge => edge.source === node.id && bundledNodeIdSet.has(edge.target))) - .map(node => node.id) + const handlers: GroupHandler[] = [] + const leafNodeIdSet = new Set() + + bundledNodes.forEach((node: Node) => { + const targetBranches = node.data._targetBranches || [{ id: 'source', name: node.data.title }] + targetBranches.forEach((branch) => { + // A branch should be a handler if it's either: + // 1. Connected to a node OUTSIDE the group + // 2. NOT connected to any node INSIDE the group + const isConnectedInside = edges.some(edge => + edge.source === node.id + && (edge.sourceHandle === branch.id || (!edge.sourceHandle && branch.id === 'source')) + && bundledNodeIdSet.has(edge.target), + ) + const isConnectedOutside = edges.some(edge => + edge.source === node.id + && (edge.sourceHandle === branch.id || (!edge.sourceHandle && branch.id === 'source')) + && !bundledNodeIdSet.has(edge.target), + ) + + if (isConnectedOutside || !isConnectedInside) { + const handlerId = `${node.id}-${branch.id}` + handlers.push({ + id: handlerId, + label: branch.name || node.data.title || node.id, + nodeId: node.id, + sourceHandle: branch.id, + }) + leafNodeIdSet.add(node.id) + } + }) + }) + + const leafNodeIds = Array.from(leafNodeIdSet) leafNodeIds.forEach(id => bundledNodeIdIsLeaf.add(id)) const members: GroupMember[] = bundledNodes.map((node) => { @@ -2592,42 +2639,6 @@ export const useNodesInteractions = () => { label: node.data.title, } }) - // Build handlers from all leaf nodes - // For multi-branch nodes (if-else, classifier), create one handler per branch - // For regular nodes, create one handler with 'source' handle - const handlerMap = new Map() - - leafNodeIds.forEach((nodeId) => { - const node = bundledNodes.find(n => n.id === nodeId) - if (!node) - return - - const targetBranches = node.data._targetBranches - if (targetBranches && targetBranches.length > 0) { - // Multi-branch node: create handler for each branch - targetBranches.forEach((branch: { id: string, name?: string }) => { - const handlerId = `${nodeId}-${branch.id}` - handlerMap.set(handlerId, { - id: handlerId, - label: branch.name || node.data.title || nodeId, - nodeId, - sourceHandle: branch.id, - }) - }) - } - else { - // Regular node: single 'source' handler - const handlerId = `${nodeId}-source` - handlerMap.set(handlerId, { - id: handlerId, - label: node.data.title || nodeId, - nodeId, - sourceHandle: 'source', - }) - } - }) - - const handlers: GroupHandler[] = Array.from(handlerMap.values()) // head nodes: nodes that receive input from outside the group const headNodeIds = [...new Set(inboundEdges.map(edge => edge.target))] @@ -2644,6 +2655,10 @@ export const useNodesInteractions = () => { headNodeIds, leafNodeIds, selected: true, + _targetBranches: handlers.map(handler => ({ + id: handler.id, + name: handler.label || handler.id, + })), } const { newNode: groupNode } = generateNewNode({ diff --git a/web/app/components/workflow/nodes/group/node.tsx b/web/app/components/workflow/nodes/group/node.tsx index c40e9476f5..37cd5e0419 100644 --- a/web/app/components/workflow/nodes/group/node.tsx +++ b/web/app/components/workflow/nodes/group/node.tsx @@ -24,14 +24,15 @@ const GroupNode = (props: NodeProps) => { : [] ), [data._children, data.members]) - // handler 列表:优先使用传入的 handlers,缺省时用 members 的 label 填充。 const handlers: GroupHandler[] = useMemo(() => ( data.handlers?.length ? data.handlers : members.length ? members.map(member => ({ - id: member.id, + id: `${member.id}-source`, label: member.label || member.id, + nodeId: member.id, + sourceHandle: 'source', })) : [] ), [data.handlers, members]) diff --git a/web/app/components/workflow/utils/workflow-init.ts b/web/app/components/workflow/utils/workflow-init.ts index c236049c64..4ee8390fc1 100644 --- a/web/app/components/workflow/utils/workflow-init.ts +++ b/web/app/components/workflow/utils/workflow-init.ts @@ -380,6 +380,16 @@ export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => { }) } + if (node.data.type === BlockEnum.Group) { + const groupData = node.data as GroupNodeData + if (groupData.handlers?.length) { + node.data._targetBranches = groupData.handlers.map(handler => ({ + id: handler.id, + name: handler.label || handler.id, + })) + } + } + if (node.data.type === BlockEnum.Iteration) { const iterationNodeData = node.data as IterationNodeType iterationNodeData._children = iterationOrLoopNodeMap[node.id] || [] From d92c476388b61297fae59b9c03ddb047de00f201 Mon Sep 17 00:00:00 2001 From: zhsama Date: Tue, 6 Jan 2026 02:07:13 +0800 Subject: [PATCH 21/82] feat(workflow): enhance group node availability checks - Updated `checkMakeGroupAvailability` to include a check for existing group nodes, preventing group creation if a group node is already selected. - Modified `useMakeGroupAvailability` and `useNodesInteractions` hooks to incorporate the new group node check, ensuring accurate group creation logic. - Adjusted UI rendering logic in the workflow panel to conditionally display elements based on node type, specifically for group nodes. --- .../components/workflow/hooks/use-make-group.ts | 16 +++++++++------- .../workflow/hooks/use-nodes-interactions.ts | 16 +++++++++------- .../_base/components/workflow-panel/index.tsx | 6 +++--- 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/web/app/components/workflow/hooks/use-make-group.ts b/web/app/components/workflow/hooks/use-make-group.ts index eb5502b679..321f0e393a 100644 --- a/web/app/components/workflow/hooks/use-make-group.ts +++ b/web/app/components/workflow/hooks/use-make-group.ts @@ -2,6 +2,7 @@ import type { PredecessorHandle } from '../utils' import { useMemo } from 'react' import { useStore as useReactFlowStore } from 'reactflow' import { shallow } from 'zustand/shallow' +import { BlockEnum } from '../types' import { getCommonPredecessorHandles } from '../utils' export type MakeGroupAvailability = { @@ -24,9 +25,9 @@ type MinimalEdge = { export const checkMakeGroupAvailability = ( selectedNodeIds: string[], edges: MinimalEdge[], + hasGroupNode = false, ): MakeGroupAvailability => { - // Make group requires selecting at least 2 nodes. - if (selectedNodeIds.length <= 1) { + if (selectedNodeIds.length <= 1 || hasGroupNode) { return { canMakeGroup: false, branchEntryNodeIds: [], @@ -109,8 +110,6 @@ export const checkMakeGroupAvailability = ( } export const useMakeGroupAvailability = (selectedNodeIds: string[]): MakeGroupAvailability => { - // Subscribe to the minimal edge state we need (source/sourceHandle/target) to avoid - // snowball rerenders caused by subscribing to the entire `edges` objects. const edgeKeys = useReactFlowStore((state) => { const delimiter = '\u0000' const keys = state.edges.map(edge => `${edge.source}${delimiter}${edge.sourceHandle || 'source'}${delimiter}${edge.target}`) @@ -118,8 +117,11 @@ export const useMakeGroupAvailability = (selectedNodeIds: string[]): MakeGroupAv return keys }, shallow) + const hasGroupNode = useReactFlowStore((state) => { + return state.getNodes().some(node => node.selected && node.data.type === BlockEnum.Group) + }) + return useMemo(() => { - // Reconstruct a minimal edge list from `edgeKeys` for downstream graph checks. const delimiter = '\u0000' const edges = edgeKeys.map((key) => { const [source, handleId, target] = key.split(delimiter) @@ -131,6 +133,6 @@ export const useMakeGroupAvailability = (selectedNodeIds: string[]): MakeGroupAv } }) - return checkMakeGroupAvailability(selectedNodeIds, edges) - }, [edgeKeys, selectedNodeIds]) + return checkMakeGroupAvailability(selectedNodeIds, edges, hasGroupNode) + }, [edgeKeys, selectedNodeIds, hasGroupNode]) } diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index b52fabae1e..e440beaed4 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -2550,23 +2550,24 @@ export const useNodesInteractions = () => { return nodes.some(node => node.data._isBundled) }, [store]) - // Check if the current box selection can be grouped const getCanMakeGroup = useCallback(() => { const { getNodes, edges } = store.getState() const nodes = getNodes() - const bundledNodeIds = nodes.filter(node => node.data._isBundled).map(node => node.id) + const bundledNodes = nodes.filter(node => node.data._isBundled) - if (bundledNodeIds.length <= 1) + if (bundledNodes.length <= 1) return false + const bundledNodeIds = bundledNodes.map(node => node.id) const minimalEdges = edges.map(edge => ({ id: edge.id, source: edge.source, sourceHandle: edge.sourceHandle || 'source', target: edge.target, })) + const hasGroupNode = bundledNodes.some(node => node.data.type === BlockEnum.Group) - const { canMakeGroup } = checkMakeGroupAvailability(bundledNodeIds, minimalEdges) + const { canMakeGroup } = checkMakeGroupAvailability(bundledNodeIds, minimalEdges, hasGroupNode) return canMakeGroup }, [store]) @@ -2574,19 +2575,20 @@ export const useNodesInteractions = () => { const { getNodes, setNodes, edges, setEdges } = store.getState() const nodes = getNodes() const bundledNodes = nodes.filter(node => node.data._isBundled) - const bundledNodeIds = bundledNodes.map(node => node.id) - if (bundledNodeIds.length <= 1) + if (bundledNodes.length <= 1) return + const bundledNodeIds = bundledNodes.map(node => node.id) const minimalEdges = edges.map(edge => ({ id: edge.id, source: edge.source, sourceHandle: edge.sourceHandle || 'source', target: edge.target, })) + const hasGroupNode = bundledNodes.some(node => node.data.type === BlockEnum.Group) - const { canMakeGroup } = checkMakeGroupAvailability(bundledNodeIds, minimalEdges) + const { canMakeGroup } = checkMakeGroupAvailability(bundledNodeIds, minimalEdges, hasGroupNode) if (!canMakeGroup) return diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx index c834f29ab3..3074b80774 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx @@ -594,7 +594,7 @@ const BasePanel: FC = ({ ) } { - !needsToolAuth && !currentDataSource && !currentTriggerPlugin && ( + !needsToolAuth && !currentDataSource && !currentTriggerPlugin && data.type !== BlockEnum.Group && (
= ({
) } - + {data.type !== BlockEnum.Group && }
- {tabType === TabType.settings && ( + {(tabType === TabType.settings || data.type === BlockEnum.Group) && (
{cloneElement(children as any, { From 88248ad2d3fa8611468ed35d3c62fad720943698 Mon Sep 17 00:00:00 2001 From: Novice Date: Wed, 7 Jan 2026 13:57:55 +0800 Subject: [PATCH 22/82] feat: add node level memory --- api/core/memory/README.md | 434 ++++++++++++++++++ api/core/memory/__init__.py | 15 + api/core/memory/base.py | 83 ++++ api/core/memory/node_token_buffer_memory.py | 353 ++++++++++++++ api/core/memory/token_buffer_memory.py | 51 +- .../entities/advanced_prompt_entities.py | 9 + api/core/workflow/nodes/llm/llm_utils.py | 56 ++- api/core/workflow/nodes/llm/node.py | 56 ++- 8 files changed, 997 insertions(+), 60 deletions(-) create mode 100644 api/core/memory/README.md create mode 100644 api/core/memory/__init__.py create mode 100644 api/core/memory/base.py create mode 100644 api/core/memory/node_token_buffer_memory.py diff --git a/api/core/memory/README.md b/api/core/memory/README.md new file mode 100644 index 0000000000..ba8f743125 --- /dev/null +++ b/api/core/memory/README.md @@ -0,0 +1,434 @@ +# Memory Module + +This module provides memory management for LLM conversations, enabling context retention across dialogue turns. + +## Overview + +The memory module contains two types of memory implementations: + +1. **TokenBufferMemory** - Conversation-level memory (existing) +2. **NodeTokenBufferMemory** - Node-level memory (to be implemented, **Chatflow only**) + +> **Note**: `NodeTokenBufferMemory` is only available in **Chatflow** (advanced-chat mode). +> This is because it requires both `conversation_id` and `node_id`, which are only present in Chatflow. +> Standard Workflow mode does not have `conversation_id` and therefore cannot use node-level memory. + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Memory Architecture │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────-┐ │ +│ │ TokenBufferMemory │ │ +│ │ Scope: Conversation │ │ +│ │ Storage: Database (Message table) │ │ +│ │ Key: conversation_id │ │ +│ └─────────────────────────────────────────────────────────────────────-┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────-┐ │ +│ │ NodeTokenBufferMemory │ │ +│ │ Scope: Node within Conversation │ │ +│ │ Storage: Object Storage (JSON file) │ │ +│ │ Key: (app_id, conversation_id, node_id) │ │ +│ └─────────────────────────────────────────────────────────────────────-┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## TokenBufferMemory (Existing) + +### Purpose + +`TokenBufferMemory` retrieves conversation history from the `Message` table and converts it to `PromptMessage` objects for LLM context. + +### Key Features + +- **Conversation-scoped**: All messages within a conversation are candidates +- **Thread-aware**: Uses `parent_message_id` to extract only the current thread (supports regeneration scenarios) +- **Token-limited**: Truncates history to fit within `max_token_limit` +- **File support**: Handles `MessageFile` attachments (images, documents, etc.) + +### Data Flow + +``` +Message Table TokenBufferMemory LLM + │ │ │ + │ SELECT * FROM messages │ │ + │ WHERE conversation_id = ? │ │ + │ ORDER BY created_at DESC │ │ + ├─────────────────────────────────▶│ │ + │ │ │ + │ extract_thread_messages() │ + │ │ │ + │ build_prompt_message_with_files() │ + │ │ │ + │ truncate by max_token_limit │ + │ │ │ + │ │ Sequence[PromptMessage] + │ ├───────────────────────▶│ + │ │ │ +``` + +### Thread Extraction + +When a user regenerates a response, a new thread is created: + +``` +Message A (user) + └── Message A' (assistant) + └── Message B (user) + └── Message B' (assistant) + └── Message A'' (assistant, regenerated) ← New thread + └── Message C (user) + └── Message C' (assistant) +``` + +`extract_thread_messages()` traces back from the latest message using `parent_message_id` to get only the current thread: `[A, A'', C, C']` + +### Usage + +```python +from core.memory.token_buffer_memory import TokenBufferMemory + +memory = TokenBufferMemory(conversation=conversation, model_instance=model_instance) +history = memory.get_history_prompt_messages(max_token_limit=2000, message_limit=100) +``` + +--- + +## NodeTokenBufferMemory (To Be Implemented) + +### Purpose + +`NodeTokenBufferMemory` provides **node-scoped memory** within a conversation. Each LLM node in a workflow can maintain its own independent conversation history. + +### Use Cases + +1. **Multi-LLM Workflows**: Different LLM nodes need separate context +2. **Iterative Processing**: An LLM node in a loop needs to accumulate context across iterations +3. **Specialized Agents**: Each agent node maintains its own dialogue history + +### Design Decisions + +#### Storage: Object Storage for Messages (No New Database Table) + +| Aspect | Database | Object Storage | +| ------------------------- | -------------------- | ------------------ | +| Cost | High | Low | +| Query Flexibility | High | Low | +| Schema Changes | Migration required | None | +| Consistency with existing | ConversationVariable | File uploads, logs | + +**Decision**: Store message data in object storage, but still use existing database tables for file metadata. + +**What is stored in Object Storage:** + +- Message content (text) +- Message metadata (role, token_count, created_at) +- File references (upload_file_id, tool_file_id, etc.) +- Thread relationships (message_id, parent_message_id) + +**What still requires Database queries:** + +- File reconstruction: When reading node memory, file references are used to query + `UploadFile` / `ToolFile` tables via `file_factory.build_from_mapping()` to rebuild + complete `File` objects with storage_key, mime_type, etc. + +**Why this hybrid approach:** + +- No database migration required (no new tables) +- Message data may be large, object storage is cost-effective +- File metadata is already in database, no need to duplicate +- Aligns with existing storage patterns (file uploads, logs) + +#### Storage Key Format + +``` +node_memory/{app_id}/{conversation_id}/{node_id}.json +``` + +#### Data Structure + +```json +{ + "version": 1, + "messages": [ + { + "message_id": "msg-001", + "parent_message_id": null, + "role": "user", + "content": "Analyze this image", + "files": [ + { + "type": "image", + "transfer_method": "local_file", + "upload_file_id": "file-uuid-123", + "belongs_to": "user" + } + ], + "token_count": 15, + "created_at": "2026-01-07T10:00:00Z" + }, + { + "message_id": "msg-002", + "parent_message_id": "msg-001", + "role": "assistant", + "content": "This is a landscape image...", + "files": [], + "token_count": 50, + "created_at": "2026-01-07T10:00:01Z" + } + ] +} +``` + +### Thread Support + +Node memory also supports thread extraction (for regeneration scenarios): + +```python +def _extract_thread( + self, + messages: list[NodeMemoryMessage], + current_message_id: str +) -> list[NodeMemoryMessage]: + """ + Extract messages belonging to the thread of current_message_id. + Similar to extract_thread_messages() in TokenBufferMemory. + """ + ... +``` + +### File Handling + +Files are stored as references (not full metadata): + +```python +class NodeMemoryFile(BaseModel): + type: str # image, audio, video, document, custom + transfer_method: str # local_file, remote_url, tool_file + upload_file_id: str | None # for local_file + tool_file_id: str | None # for tool_file + url: str | None # for remote_url + belongs_to: str # user / assistant +``` + +When reading, files are rebuilt using `file_factory.build_from_mapping()`. + +### API Design + +```python +class NodeTokenBufferMemory: + def __init__( + self, + app_id: str, + conversation_id: str, + node_id: str, + model_instance: ModelInstance, + ): + """ + Initialize node-level memory. + + :param app_id: Application ID + :param conversation_id: Conversation ID + :param node_id: Node ID in the workflow + :param model_instance: Model instance for token counting + """ + ... + + def add_messages( + self, + message_id: str, + parent_message_id: str | None, + user_content: str, + user_files: Sequence[File], + assistant_content: str, + assistant_files: Sequence[File], + ) -> None: + """ + Append a dialogue turn (user + assistant) to node memory. + Call this after LLM node execution completes. + + :param message_id: Current message ID (from Message table) + :param parent_message_id: Parent message ID (for thread tracking) + :param user_content: User's text input + :param user_files: Files attached by user + :param assistant_content: Assistant's text response + :param assistant_files: Files generated by assistant + """ + ... + + def get_history_prompt_messages( + self, + current_message_id: str, + tenant_id: str, + max_token_limit: int = 2000, + file_upload_config: FileUploadConfig | None = None, + ) -> Sequence[PromptMessage]: + """ + Retrieve history as PromptMessage sequence. + + :param current_message_id: Current message ID (for thread extraction) + :param tenant_id: Tenant ID (for file reconstruction) + :param max_token_limit: Maximum tokens for history + :param file_upload_config: File upload configuration + :return: Sequence of PromptMessage for LLM context + """ + ... + + def flush(self) -> None: + """ + Persist buffered changes to object storage. + Call this at the end of node execution. + """ + ... + + def clear(self) -> None: + """ + Clear all messages in this node's memory. + """ + ... +``` + +### Data Flow + +``` +Object Storage NodeTokenBufferMemory LLM Node + │ │ │ + │ │◀── get_history_prompt_messages() + │ storage.load(key) │ │ + │◀─────────────────────────────────┤ │ + │ │ │ + │ JSON data │ │ + ├─────────────────────────────────▶│ │ + │ │ │ + │ _extract_thread() │ + │ │ │ + │ _rebuild_files() via file_factory │ + │ │ │ + │ _build_prompt_messages() │ + │ │ │ + │ _truncate_by_tokens() │ + │ │ │ + │ │ Sequence[PromptMessage] │ + │ ├──────────────────────────▶│ + │ │ │ + │ │◀── LLM execution complete │ + │ │ │ + │ │◀── add_messages() │ + │ │ │ + │ storage.save(key, data) │ │ + │◀─────────────────────────────────┤ │ + │ │ │ +``` + +### Integration with LLM Node + +```python +# In LLM Node execution + +# 1. Fetch memory based on mode +if node_data.memory and node_data.memory.mode == MemoryMode.NODE: + # Node-level memory (Chatflow only) + memory = fetch_node_memory( + variable_pool=variable_pool, + app_id=app_id, + node_id=self.node_id, + node_data_memory=node_data.memory, + model_instance=model_instance, + ) +elif node_data.memory and node_data.memory.mode == MemoryMode.CONVERSATION: + # Conversation-level memory (existing behavior) + memory = fetch_memory( + variable_pool=variable_pool, + app_id=app_id, + node_data_memory=node_data.memory, + model_instance=model_instance, + ) +else: + memory = None + +# 2. Get history for context +if memory: + if isinstance(memory, NodeTokenBufferMemory): + history = memory.get_history_prompt_messages( + current_message_id=current_message_id, + tenant_id=tenant_id, + max_token_limit=max_token_limit, + ) + else: # TokenBufferMemory + history = memory.get_history_prompt_messages( + max_token_limit=max_token_limit, + ) + prompt_messages = [*history, *current_messages] +else: + prompt_messages = current_messages + +# 3. Call LLM +response = model_instance.invoke(prompt_messages) + +# 4. Append to node memory (only for NodeTokenBufferMemory) +if isinstance(memory, NodeTokenBufferMemory): + memory.add_messages( + message_id=message_id, + parent_message_id=parent_message_id, + user_content=user_input, + user_files=user_files, + assistant_content=response.content, + assistant_files=response_files, + ) + memory.flush() +``` + +### Configuration + +Add to `MemoryConfig` in `core/workflow/nodes/llm/entities.py`: + +```python +class MemoryMode(StrEnum): + CONVERSATION = "conversation" # Use TokenBufferMemory (default, existing behavior) + NODE = "node" # Use NodeTokenBufferMemory (new, Chatflow only) + +class MemoryConfig(BaseModel): + # Existing fields + role_prefix: RolePrefix | None = None + window: MemoryWindowConfig | None = None + query_prompt_template: str | None = None + + # Memory mode (new) + mode: MemoryMode = MemoryMode.CONVERSATION +``` + +**Mode Behavior:** + +| Mode | Memory Class | Scope | Availability | +| -------------- | --------------------- | ------------------------ | ------------- | +| `conversation` | TokenBufferMemory | Entire conversation | All app modes | +| `node` | NodeTokenBufferMemory | Per-node in conversation | Chatflow only | + +> When `mode=node` is used in a non-Chatflow context (no conversation_id), it should +> fall back to no memory or raise a configuration error. + +--- + +## Comparison + +| Feature | TokenBufferMemory | NodeTokenBufferMemory | +| -------------- | ------------------------ | ------------------------- | +| Scope | Conversation | Node within Conversation | +| Storage | Database (Message table) | Object Storage (JSON) | +| Thread Support | Yes | Yes | +| File Support | Yes (via MessageFile) | Yes (via file references) | +| Token Limit | Yes | Yes | +| Use Case | Standard chat apps | Complex workflows | + +--- + +## Future Considerations + +1. **Cleanup Task**: Add a Celery task to clean up old node memory files +2. **Concurrency**: Consider Redis lock for concurrent node executions +3. **Compression**: Compress large memory files to reduce storage costs +4. **Extension**: Other nodes (Agent, Tool) may also benefit from node-level memory diff --git a/api/core/memory/__init__.py b/api/core/memory/__init__.py new file mode 100644 index 0000000000..4baef1a835 --- /dev/null +++ b/api/core/memory/__init__.py @@ -0,0 +1,15 @@ +from core.memory.base import BaseMemory +from core.memory.node_token_buffer_memory import ( + NodeMemoryData, + NodeMemoryFile, + NodeTokenBufferMemory, +) +from core.memory.token_buffer_memory import TokenBufferMemory + +__all__ = [ + "BaseMemory", + "NodeMemoryData", + "NodeMemoryFile", + "NodeTokenBufferMemory", + "TokenBufferMemory", +] diff --git a/api/core/memory/base.py b/api/core/memory/base.py new file mode 100644 index 0000000000..af6e8eeda3 --- /dev/null +++ b/api/core/memory/base.py @@ -0,0 +1,83 @@ +""" +Base memory interfaces and types. + +This module defines the common protocol for memory implementations. +""" + +from abc import ABC, abstractmethod +from collections.abc import Sequence + +from core.model_runtime.entities import ImagePromptMessageContent, PromptMessage + + +class BaseMemory(ABC): + """ + Abstract base class for memory implementations. + + Provides a common interface for both conversation-level and node-level memory. + """ + + @abstractmethod + def get_history_prompt_messages( + self, + *, + max_token_limit: int = 2000, + message_limit: int | None = None, + ) -> Sequence[PromptMessage]: + """ + Get history prompt messages. + + :param max_token_limit: Maximum tokens for history + :param message_limit: Maximum number of messages + :return: Sequence of PromptMessage for LLM context + """ + pass + + def get_history_prompt_text( + self, + human_prefix: str = "Human", + ai_prefix: str = "Assistant", + max_token_limit: int = 2000, + message_limit: int | None = None, + ) -> str: + """ + Get history prompt as formatted text. + + :param human_prefix: Prefix for human messages + :param ai_prefix: Prefix for assistant messages + :param max_token_limit: Maximum tokens for history + :param message_limit: Maximum number of messages + :return: Formatted history text + """ + from core.model_runtime.entities import ( + PromptMessageRole, + TextPromptMessageContent, + ) + + prompt_messages = self.get_history_prompt_messages( + max_token_limit=max_token_limit, + message_limit=message_limit, + ) + + string_messages = [] + for m in prompt_messages: + if m.role == PromptMessageRole.USER: + role = human_prefix + elif m.role == PromptMessageRole.ASSISTANT: + role = ai_prefix + else: + continue + + if isinstance(m.content, list): + inner_msg = "" + for content in m.content: + if isinstance(content, TextPromptMessageContent): + inner_msg += f"{content.data}\n" + elif isinstance(content, ImagePromptMessageContent): + inner_msg += "[image]\n" + string_messages.append(f"{role}: {inner_msg.strip()}") + else: + message = f"{role}: {m.content}" + string_messages.append(message) + + return "\n".join(string_messages) diff --git a/api/core/memory/node_token_buffer_memory.py b/api/core/memory/node_token_buffer_memory.py new file mode 100644 index 0000000000..bc38c953eb --- /dev/null +++ b/api/core/memory/node_token_buffer_memory.py @@ -0,0 +1,353 @@ +""" +Node-level Token Buffer Memory for Chatflow. + +This module provides node-scoped memory within a conversation. +Each LLM node in a workflow can maintain its own independent conversation history. + +Note: This is only available in Chatflow (advanced-chat mode) because it requires +both conversation_id and node_id. + +Design: +- Storage is indexed by workflow_run_id (each execution stores one turn) +- Thread tracking leverages Message table's parent_message_id structure +- On read: query Message table for current thread, then filter Node Memory by workflow_run_ids +""" + +import logging +from collections.abc import Sequence + +from pydantic import BaseModel +from sqlalchemy import select + +from core.file import File, FileTransferMethod +from core.memory.base import BaseMemory +from core.model_manager import ModelInstance +from core.model_runtime.entities import ( + AssistantPromptMessage, + ImagePromptMessageContent, + PromptMessage, + TextPromptMessageContent, + UserPromptMessage, +) +from core.prompt.utils.extract_thread_messages import extract_thread_messages +from extensions.ext_database import db +from extensions.ext_storage import storage +from models.model import Message + +logger = logging.getLogger(__name__) + + +class NodeMemoryFile(BaseModel): + """File reference stored in node memory.""" + + type: str # image, audio, video, document, custom + transfer_method: str # local_file, remote_url, tool_file + upload_file_id: str | None = None + tool_file_id: str | None = None + url: str | None = None + + +class NodeMemoryTurn(BaseModel): + """A single dialogue turn (user + assistant) in node memory.""" + + user_content: str = "" + user_files: list[NodeMemoryFile] = [] + assistant_content: str = "" + assistant_files: list[NodeMemoryFile] = [] + + +class NodeMemoryData(BaseModel): + """Root data structure for node memory storage.""" + + version: int = 1 + # Key: workflow_run_id, Value: dialogue turn + turns: dict[str, NodeMemoryTurn] = {} + + +class NodeTokenBufferMemory(BaseMemory): + """ + Node-level Token Buffer Memory. + + Provides node-scoped memory within a conversation. Each LLM node can maintain + its own independent conversation history, stored in object storage. + + Key design: Thread tracking is delegated to Message table's parent_message_id. + Storage is indexed by workflow_run_id for easy filtering. + + Storage key format: node_memory/{app_id}/{conversation_id}/{node_id}.json + """ + + def __init__( + self, + app_id: str, + conversation_id: str, + node_id: str, + tenant_id: str, + model_instance: ModelInstance, + ): + """ + Initialize node-level memory. + + :param app_id: Application ID + :param conversation_id: Conversation ID + :param node_id: Node ID in the workflow + :param tenant_id: Tenant ID for file reconstruction + :param model_instance: Model instance for token counting + """ + self.app_id = app_id + self.conversation_id = conversation_id + self.node_id = node_id + self.tenant_id = tenant_id + self.model_instance = model_instance + self._storage_key = f"node_memory/{app_id}/{conversation_id}/{node_id}.json" + self._data: NodeMemoryData | None = None + self._dirty = False + + def _load(self) -> NodeMemoryData: + """Load data from object storage.""" + if self._data is not None: + return self._data + + try: + raw = storage.load_once(self._storage_key) + self._data = NodeMemoryData.model_validate_json(raw) + except Exception: + # File not found or parse error, start fresh + self._data = NodeMemoryData() + + return self._data + + def _save(self) -> None: + """Save data to object storage.""" + if self._data is not None: + storage.save(self._storage_key, self._data.model_dump_json().encode("utf-8")) + self._dirty = False + + def _file_to_memory_file(self, file: File) -> NodeMemoryFile: + """Convert File object to NodeMemoryFile reference.""" + return NodeMemoryFile( + type=file.type.value if hasattr(file.type, "value") else str(file.type), + transfer_method=( + file.transfer_method.value if hasattr(file.transfer_method, "value") else str(file.transfer_method) + ), + upload_file_id=file.related_id if file.transfer_method == FileTransferMethod.LOCAL_FILE else None, + tool_file_id=file.related_id if file.transfer_method == FileTransferMethod.TOOL_FILE else None, + url=file.remote_url if file.transfer_method == FileTransferMethod.REMOTE_URL else None, + ) + + def _memory_file_to_mapping(self, memory_file: NodeMemoryFile) -> dict: + """Convert NodeMemoryFile to mapping for file_factory.""" + mapping: dict = { + "type": memory_file.type, + "transfer_method": memory_file.transfer_method, + } + if memory_file.upload_file_id: + mapping["upload_file_id"] = memory_file.upload_file_id + if memory_file.tool_file_id: + mapping["tool_file_id"] = memory_file.tool_file_id + if memory_file.url: + mapping["url"] = memory_file.url + return mapping + + def _rebuild_files(self, memory_files: list[NodeMemoryFile]) -> list[File]: + """Rebuild File objects from NodeMemoryFile references.""" + if not memory_files: + return [] + + from factories import file_factory + + files = [] + for mf in memory_files: + try: + mapping = self._memory_file_to_mapping(mf) + file = file_factory.build_from_mapping(mapping=mapping, tenant_id=self.tenant_id) + files.append(file) + except Exception as e: + logger.warning("Failed to rebuild file from memory: %s", e) + continue + return files + + def _build_prompt_message( + self, + role: str, + content: str, + files: list[File], + detail: ImagePromptMessageContent.DETAIL = ImagePromptMessageContent.DETAIL.HIGH, + ) -> PromptMessage: + """Build PromptMessage from content and files.""" + from core.file import file_manager + + if not files: + if role == "user": + return UserPromptMessage(content=content) + else: + return AssistantPromptMessage(content=content) + + # Build multimodal content + prompt_contents: list = [] + for file in files: + try: + prompt_content = file_manager.to_prompt_message_content(file, image_detail_config=detail) + prompt_contents.append(prompt_content) + except Exception as e: + logger.warning("Failed to convert file to prompt content: %s", e) + continue + + prompt_contents.append(TextPromptMessageContent(data=content)) + + if role == "user": + return UserPromptMessage(content=prompt_contents) + else: + return AssistantPromptMessage(content=prompt_contents) + + def _get_thread_workflow_run_ids(self) -> list[str]: + """ + Get workflow_run_ids for the current thread by querying Message table. + + Returns workflow_run_ids in chronological order (oldest first). + """ + # Query messages for this conversation + stmt = ( + select(Message).where(Message.conversation_id == self.conversation_id).order_by(Message.created_at.desc()) + ) + messages = db.session.scalars(stmt.limit(500)).all() + + if not messages: + return [] + + # Extract thread messages using existing logic + thread_messages = extract_thread_messages(messages) + + # For newly created message, its answer is temporarily empty, skip it + if thread_messages and not thread_messages[0].answer and thread_messages[0].answer_tokens == 0: + thread_messages.pop(0) + + # Reverse to get chronological order, extract workflow_run_ids + workflow_run_ids = [] + for msg in reversed(thread_messages): + if msg.workflow_run_id: + workflow_run_ids.append(msg.workflow_run_id) + + return workflow_run_ids + + def add_messages( + self, + workflow_run_id: str, + user_content: str, + user_files: Sequence[File] | None = None, + assistant_content: str = "", + assistant_files: Sequence[File] | None = None, + ) -> None: + """ + Add a dialogue turn to node memory. + Call this after LLM node execution completes. + + :param workflow_run_id: Current workflow execution ID + :param user_content: User's text input + :param user_files: Files attached by user + :param assistant_content: Assistant's text response + :param assistant_files: Files generated by assistant + """ + data = self._load() + + # Convert files to memory file references + user_memory_files = [self._file_to_memory_file(f) for f in (user_files or [])] + assistant_memory_files = [self._file_to_memory_file(f) for f in (assistant_files or [])] + + # Store the turn indexed by workflow_run_id + data.turns[workflow_run_id] = NodeMemoryTurn( + user_content=user_content, + user_files=user_memory_files, + assistant_content=assistant_content, + assistant_files=assistant_memory_files, + ) + + self._dirty = True + + def get_history_prompt_messages( + self, + *, + max_token_limit: int = 2000, + message_limit: int | None = None, + ) -> Sequence[PromptMessage]: + """ + Retrieve history as PromptMessage sequence. + + Thread tracking is handled by querying Message table's parent_message_id structure. + + :param max_token_limit: Maximum tokens for history + :param message_limit: unused, for interface compatibility + :return: Sequence of PromptMessage for LLM context + """ + # message_limit is unused in NodeTokenBufferMemory (uses token limit instead) + _ = message_limit + detail = ImagePromptMessageContent.DETAIL.HIGH + data = self._load() + + if not data.turns: + return [] + + # Get workflow_run_ids for current thread from Message table + thread_workflow_run_ids = self._get_thread_workflow_run_ids() + + if not thread_workflow_run_ids: + return [] + + # Build prompt messages in thread order + prompt_messages: list[PromptMessage] = [] + for wf_run_id in thread_workflow_run_ids: + turn = data.turns.get(wf_run_id) + if not turn: + # This workflow execution didn't have node memory stored + continue + + # Build user message + user_files = self._rebuild_files(turn.user_files) if turn.user_files else [] + user_msg = self._build_prompt_message( + role="user", + content=turn.user_content, + files=user_files, + detail=detail, + ) + prompt_messages.append(user_msg) + + # Build assistant message + assistant_files = self._rebuild_files(turn.assistant_files) if turn.assistant_files else [] + assistant_msg = self._build_prompt_message( + role="assistant", + content=turn.assistant_content, + files=assistant_files, + detail=detail, + ) + prompt_messages.append(assistant_msg) + + if not prompt_messages: + return [] + + # Truncate by token limit + try: + current_tokens = self.model_instance.get_llm_num_tokens(prompt_messages) + while current_tokens > max_token_limit and len(prompt_messages) > 1: + prompt_messages.pop(0) + current_tokens = self.model_instance.get_llm_num_tokens(prompt_messages) + except Exception as e: + logger.warning("Failed to count tokens for truncation: %s", e) + + return prompt_messages + + def flush(self) -> None: + """ + Persist buffered changes to object storage. + Call this at the end of node execution. + """ + if self._dirty: + self._save() + + def clear(self) -> None: + """Clear all messages in this node's memory.""" + self._data = NodeMemoryData() + self._save() + + def exists(self) -> bool: + """Check if node memory exists in storage.""" + return storage.exists(self._storage_key) diff --git a/api/core/memory/token_buffer_memory.py b/api/core/memory/token_buffer_memory.py index 3ebbb60f85..58ffe04240 100644 --- a/api/core/memory/token_buffer_memory.py +++ b/api/core/memory/token_buffer_memory.py @@ -5,12 +5,12 @@ from sqlalchemy.orm import sessionmaker from core.app.app_config.features.file_upload.manager import FileUploadConfigManager from core.file import file_manager +from core.memory.base import BaseMemory from core.model_manager import ModelInstance from core.model_runtime.entities import ( AssistantPromptMessage, ImagePromptMessageContent, PromptMessage, - PromptMessageRole, TextPromptMessageContent, UserPromptMessage, ) @@ -24,7 +24,7 @@ from repositories.api_workflow_run_repository import APIWorkflowRunRepository from repositories.factory import DifyAPIRepositoryFactory -class TokenBufferMemory: +class TokenBufferMemory(BaseMemory): def __init__( self, conversation: Conversation, @@ -115,10 +115,14 @@ class TokenBufferMemory: return AssistantPromptMessage(content=prompt_message_contents) def get_history_prompt_messages( - self, max_token_limit: int = 2000, message_limit: int | None = None + self, + *, + max_token_limit: int = 2000, + message_limit: int | None = None, ) -> Sequence[PromptMessage]: """ Get history prompt messages. + :param max_token_limit: max token limit :param message_limit: message limit """ @@ -200,44 +204,3 @@ class TokenBufferMemory: curr_message_tokens = self.model_instance.get_llm_num_tokens(prompt_messages) return prompt_messages - - def get_history_prompt_text( - self, - human_prefix: str = "Human", - ai_prefix: str = "Assistant", - max_token_limit: int = 2000, - message_limit: int | None = None, - ) -> str: - """ - Get history prompt text. - :param human_prefix: human prefix - :param ai_prefix: ai prefix - :param max_token_limit: max token limit - :param message_limit: message limit - :return: - """ - prompt_messages = self.get_history_prompt_messages(max_token_limit=max_token_limit, message_limit=message_limit) - - string_messages = [] - for m in prompt_messages: - if m.role == PromptMessageRole.USER: - role = human_prefix - elif m.role == PromptMessageRole.ASSISTANT: - role = ai_prefix - else: - continue - - if isinstance(m.content, list): - inner_msg = "" - for content in m.content: - if isinstance(content, TextPromptMessageContent): - inner_msg += f"{content.data}\n" - elif isinstance(content, ImagePromptMessageContent): - inner_msg += "[image]\n" - - string_messages.append(f"{role}: {inner_msg.strip()}") - else: - message = f"{role}: {m.content}" - string_messages.append(message) - - return "\n".join(string_messages) diff --git a/api/core/prompt/entities/advanced_prompt_entities.py b/api/core/prompt/entities/advanced_prompt_entities.py index 7094633093..457800bad2 100644 --- a/api/core/prompt/entities/advanced_prompt_entities.py +++ b/api/core/prompt/entities/advanced_prompt_entities.py @@ -1,3 +1,4 @@ +from enum import StrEnum from typing import Literal from pydantic import BaseModel @@ -5,6 +6,13 @@ from pydantic import BaseModel from core.model_runtime.entities.message_entities import PromptMessageRole +class MemoryMode(StrEnum): + """Memory mode for LLM nodes.""" + + CONVERSATION = "conversation" # Use TokenBufferMemory (default, existing behavior) + NODE = "node" # Use NodeTokenBufferMemory (Chatflow only) + + class ChatModelMessage(BaseModel): """ Chat Message. @@ -48,3 +56,4 @@ class MemoryConfig(BaseModel): role_prefix: RolePrefix | None = None window: WindowConfig query_prompt_template: str | None = None + mode: MemoryMode = MemoryMode.CONVERSATION diff --git a/api/core/workflow/nodes/llm/llm_utils.py b/api/core/workflow/nodes/llm/llm_utils.py index 0c545469bc..aa5c784357 100644 --- a/api/core/workflow/nodes/llm/llm_utils.py +++ b/api/core/workflow/nodes/llm/llm_utils.py @@ -8,12 +8,13 @@ from configs import dify_config from core.app.entities.app_invoke_entities import ModelConfigWithCredentialsEntity from core.entities.provider_entities import QuotaUnit from core.file.models import File -from core.memory.token_buffer_memory import TokenBufferMemory +from core.memory import NodeTokenBufferMemory, TokenBufferMemory +from core.memory.base import BaseMemory from core.model_manager import ModelInstance, ModelManager from core.model_runtime.entities.llm_entities import LLMUsage from core.model_runtime.entities.model_entities import ModelType from core.model_runtime.model_providers.__base.large_language_model import LargeLanguageModel -from core.prompt.entities.advanced_prompt_entities import MemoryConfig +from core.prompt.entities.advanced_prompt_entities import MemoryConfig, MemoryMode from core.variables.segments import ArrayAnySegment, ArrayFileSegment, FileSegment, NoneSegment, StringSegment from core.workflow.enums import SystemVariableKey from core.workflow.nodes.llm.entities import ModelConfig @@ -86,25 +87,56 @@ def fetch_files(variable_pool: VariablePool, selector: Sequence[str]) -> Sequenc def fetch_memory( - variable_pool: VariablePool, app_id: str, node_data_memory: MemoryConfig | None, model_instance: ModelInstance -) -> TokenBufferMemory | None: + variable_pool: VariablePool, + app_id: str, + tenant_id: str, + node_data_memory: MemoryConfig | None, + model_instance: ModelInstance, + node_id: str = "", +) -> BaseMemory | None: + """ + Fetch memory based on configuration mode. + + Returns TokenBufferMemory for conversation mode (default), + or NodeTokenBufferMemory for node mode (Chatflow only). + + :param variable_pool: Variable pool containing system variables + :param app_id: Application ID + :param tenant_id: Tenant ID + :param node_data_memory: Memory configuration + :param model_instance: Model instance for token counting + :param node_id: Node ID in the workflow (required for node mode) + :return: Memory instance or None if not applicable + """ if not node_data_memory: return None - # get conversation id + # Get conversation_id from variable pool (required for both modes in Chatflow) conversation_id_variable = variable_pool.get(["sys", SystemVariableKey.CONVERSATION_ID]) if not isinstance(conversation_id_variable, StringSegment): return None conversation_id = conversation_id_variable.value - with Session(db.engine, expire_on_commit=False) as session: - stmt = select(Conversation).where(Conversation.app_id == app_id, Conversation.id == conversation_id) - conversation = session.scalar(stmt) - if not conversation: + # Return appropriate memory type based on mode + if node_data_memory.mode == MemoryMode.NODE: + # Node-level memory (Chatflow only) + if not node_id: return None - - memory = TokenBufferMemory(conversation=conversation, model_instance=model_instance) - return memory + return NodeTokenBufferMemory( + app_id=app_id, + conversation_id=conversation_id, + node_id=node_id, + tenant_id=tenant_id, + model_instance=model_instance, + ) + else: + # Conversation-level memory (default) + with Session(db.engine, expire_on_commit=False) as session: + stmt = select(Conversation).where(Conversation.app_id == app_id, Conversation.id == conversation_id) + conversation = session.scalar(stmt) + if not conversation: + return None + return TokenBufferMemory(conversation=conversation, model_instance=model_instance) def deduct_llm_quota(tenant_id: str, model_instance: ModelInstance, usage: LLMUsage): diff --git a/api/core/workflow/nodes/llm/node.py b/api/core/workflow/nodes/llm/node.py index 04e2802191..bbd6c92e75 100644 --- a/api/core/workflow/nodes/llm/node.py +++ b/api/core/workflow/nodes/llm/node.py @@ -14,7 +14,8 @@ from core.file import File, FileTransferMethod, FileType, file_manager from core.helper.code_executor import CodeExecutor, CodeLanguage from core.llm_generator.output_parser.errors import OutputParserError from core.llm_generator.output_parser.structured_output import invoke_llm_with_structured_output -from core.memory.token_buffer_memory import TokenBufferMemory +from core.memory.base import BaseMemory +from core.memory.node_token_buffer_memory import NodeTokenBufferMemory from core.model_manager import ModelInstance, ModelManager from core.model_runtime.entities import ( ImagePromptMessageContent, @@ -206,8 +207,10 @@ class LLMNode(Node[LLMNodeData]): memory = llm_utils.fetch_memory( variable_pool=variable_pool, app_id=self.app_id, + tenant_id=self.tenant_id, node_data_memory=self.node_data.memory, model_instance=model_instance, + node_id=self._node_id, ) query: str | None = None @@ -299,12 +302,41 @@ class LLMNode(Node[LLMNodeData]): "reasoning_content": reasoning_content, "usage": jsonable_encoder(usage), "finish_reason": finish_reason, + "context": self._build_context(prompt_messages, clean_text, model_config.mode), } if structured_output: outputs["structured_output"] = structured_output.structured_output if self._file_outputs: outputs["files"] = ArrayFileSegment(value=self._file_outputs) + # Write to Node Memory if in node memory mode + if isinstance(memory, NodeTokenBufferMemory): + # Get workflow_run_id as the key for this execution + workflow_run_id_var = variable_pool.get(["sys", SystemVariableKey.WORKFLOW_EXECUTION_ID]) + workflow_run_id = workflow_run_id_var.value if isinstance(workflow_run_id_var, StringSegment) else "" + + if workflow_run_id: + # Resolve the query template to get actual user content + # query may be a template like "{{#sys.query#}}" or "{{#node_id.output#}}" + actual_query = variable_pool.convert_template(query or "").text + + # Get user files from sys.files + user_files_var = variable_pool.get(["sys", SystemVariableKey.FILES]) + user_files: list[File] = [] + if isinstance(user_files_var, ArrayFileSegment): + user_files = list(user_files_var.value) + elif isinstance(user_files_var, FileSegment): + user_files = [user_files_var.value] + + memory.add_messages( + workflow_run_id=workflow_run_id, + user_content=actual_query, + user_files=user_files, + assistant_content=clean_text, + assistant_files=self._file_outputs, + ) + memory.flush() + # Send final chunk event to indicate streaming is complete yield StreamChunkEvent( selector=[self._node_id, "text"], @@ -564,6 +596,22 @@ class LLMNode(Node[LLMNodeData]): # Separated mode: always return clean text and reasoning_content return clean_text, reasoning_content or "" + @staticmethod + def _build_context( + prompt_messages: Sequence[PromptMessage], + assistant_response: str, + model_mode: str, + ) -> list[dict[str, Any]]: + """ + Build context from prompt messages and assistant response. + Excludes system messages and includes the current LLM response. + """ + context_messages: list[PromptMessage] = [m for m in prompt_messages if m.role != PromptMessageRole.SYSTEM] + context_messages.append(AssistantPromptMessage(content=assistant_response)) + return PromptMessageUtil.prompt_messages_to_prompt_for_saving( + model_mode=model_mode, prompt_messages=context_messages + ) + def _transform_chat_messages( self, messages: Sequence[LLMNodeChatModelMessage] | LLMNodeCompletionModelPromptTemplate, / ) -> Sequence[LLMNodeChatModelMessage] | LLMNodeCompletionModelPromptTemplate: @@ -776,7 +824,7 @@ class LLMNode(Node[LLMNodeData]): sys_query: str | None = None, sys_files: Sequence["File"], context: str | None = None, - memory: TokenBufferMemory | None = None, + memory: BaseMemory | None = None, model_config: ModelConfigWithCredentialsEntity, prompt_template: Sequence[LLMNodeChatModelMessage] | LLMNodeCompletionModelPromptTemplate, memory_config: MemoryConfig | None = None, @@ -1335,7 +1383,7 @@ def _calculate_rest_token( def _handle_memory_chat_mode( *, - memory: TokenBufferMemory | None, + memory: BaseMemory | None, memory_config: MemoryConfig | None, model_config: ModelConfigWithCredentialsEntity, ) -> Sequence[PromptMessage]: @@ -1352,7 +1400,7 @@ def _handle_memory_chat_mode( def _handle_memory_completion_mode( *, - memory: TokenBufferMemory | None, + memory: BaseMemory | None, memory_config: MemoryConfig | None, model_config: ModelConfigWithCredentialsEntity, ) -> str: From 831eba8b1cd26ffc0040668fc0eabdd2add4ed20 Mon Sep 17 00:00:00 2001 From: zhsama Date: Thu, 8 Jan 2026 16:59:09 +0800 Subject: [PATCH 23/82] feat: update agent functionality in mixed-variable text input --- .nvmrc | 2 +- .../components/base/prompt-editor/index.tsx | 13 ++ .../components/agent-node-list/index.tsx | 135 ++++++++++++++++++ .../agent-header-bar.tsx | 52 +++++++ .../mixed-variable-text-input/index.tsx | 123 ++++++++++++---- .../mixed-variable-text-input/placeholder.tsx | 11 ++ web/i18n/en-US/workflow.json | 3 + web/i18n/ja-JP/workflow.json | 3 + web/i18n/zh-Hans/workflow.json | 3 + web/i18n/zh-Hant/workflow.json | 3 + 10 files changed, 317 insertions(+), 31 deletions(-) create mode 100644 web/app/components/workflow/nodes/_base/components/agent-node-list/index.tsx create mode 100644 web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/agent-header-bar.tsx diff --git a/.nvmrc b/.nvmrc index 7af24b7ddb..a45fd52cc5 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22.11.0 +24 diff --git a/web/app/components/base/prompt-editor/index.tsx b/web/app/components/base/prompt-editor/index.tsx index 99e3a3325c..05853d1b4d 100644 --- a/web/app/components/base/prompt-editor/index.tsx +++ b/web/app/components/base/prompt-editor/index.tsx @@ -212,6 +212,19 @@ const PromptEditor: FC = ({ lastRunBlock={lastRunBlock} isSupportFileVar={isSupportFileVar} /> + void +} + +const Item: FC = ({ node, onSelect }) => { + const [isHovering, setIsHovering] = useState(false) + + return ( + + ) +} + +type Props = { + nodes: AgentNode[] + onSelect: (node: AgentNode) => void + onClose?: () => void + onBlur?: () => void + hideSearch?: boolean + searchBoxClassName?: string + maxHeightClass?: string + autoFocus?: boolean +} + +const AgentNodeList: FC = ({ + nodes, + onSelect, + onClose, + onBlur, + hideSearch, + searchBoxClassName, + maxHeightClass, + autoFocus = true, +}) => { + const { t } = useTranslation() + const [searchText, setSearchText] = useState('') + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault() + onClose?.() + } + } + + const filteredNodes = nodes.filter((node) => { + if (!searchText) + return true + return node.title.toLowerCase().includes(searchText.toLowerCase()) + }) + + return ( + <> + {!hideSearch && ( + <> +
+ setSearchText(e.target.value)} + onClick={e => e.stopPropagation()} + onKeyDown={handleKeyDown} + onClear={() => setSearchText('')} + onBlur={onBlur} + autoFocus={autoFocus} + /> +
+
+ + )} + + {filteredNodes.length > 0 + ? ( +
+ {filteredNodes.map(node => ( + + ))} +
+ ) + : ( +
+ {t('common.noAgentNodes', { ns: 'workflow' })} +
+ )} + + ) +} + +export default React.memo(AgentNodeList) diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/agent-header-bar.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/agent-header-bar.tsx new file mode 100644 index 0000000000..f956188474 --- /dev/null +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/agent-header-bar.tsx @@ -0,0 +1,52 @@ +import type { FC } from 'react' +import { RiCloseLine, RiEqualizer2Line } from '@remixicon/react' +import { memo } from 'react' +import { useTranslation } from 'react-i18next' +import { Agent } from '@/app/components/base/icons/src/vender/workflow' + +type AgentHeaderBarProps = { + agentName: string + onRemove: () => void + onViewInternals?: () => void +} + +const AgentHeaderBar: FC = ({ + agentName, + onRemove, + onViewInternals, +}) => { + const { t } = useTranslation() + + return ( +
+
+
+
+ +
+ + @ + {agentName} + + +
+
+ +
+ ) +} + +export default memo(AgentHeaderBar) diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx index ceef2c3489..8cd98bd3a3 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx @@ -4,14 +4,23 @@ import type { } from '@/app/components/workflow/types' import { memo, + useCallback, + useMemo, } from 'react' import { useTranslation } from 'react-i18next' import PromptEditor from '@/app/components/base/prompt-editor' import { useStore } from '@/app/components/workflow/store' import { BlockEnum } from '@/app/components/workflow/types' import { cn } from '@/utils/classnames' +import AgentHeaderBar from './agent-header-bar' import Placeholder from './placeholder' +/** + * Matches workflow variable syntax: {{#nodeId.varName#}} + * Example: {{#agent-123.text#}} -> captures "agent-123.text" + */ +const WORKFLOW_VAR_PATTERN = /\{\{#([^#]+)#\}\}/g + type MixedVariableTextInputProps = { readOnly?: boolean nodesOutputVars?: NodeOutPutVar[] @@ -21,7 +30,9 @@ type MixedVariableTextInputProps = { showManageInputField?: boolean onManageInputField?: () => void disableVariableInsertion?: boolean + onViewInternals?: () => void } + const MixedVariableTextInput = ({ readOnly = false, nodesOutputVars, @@ -31,43 +42,95 @@ const MixedVariableTextInput = ({ showManageInputField, onManageInputField, disableVariableInsertion = false, + onViewInternals, }: MixedVariableTextInputProps) => { const { t } = useTranslation() const controlPromptEditorRerenderKey = useStore(s => s.controlPromptEditorRerenderKey) + const setControlPromptEditorRerenderKey = useStore(s => s.setControlPromptEditorRerenderKey) + + const nodesByIdMap = useMemo(() => { + return availableNodes.reduce((acc, node) => { + acc[node.id] = node + return acc + }, {} as Record) + }, [availableNodes]) + + const detectedAgentFromValue = useMemo(() => { + if (!value) + return null + + const matches = value.matchAll(WORKFLOW_VAR_PATTERN) + for (const match of matches) { + const variablePath = match[1] + const nodeId = variablePath.split('.')[0] + const node = nodesByIdMap[nodeId] + if (node?.data.type === BlockEnum.Agent) { + return { + nodeId, + name: node.data.title, + } + } + } + return null + }, [value, nodesByIdMap]) + + const handleAgentRemove = useCallback(() => { + if (!detectedAgentFromValue || !onChange) + return + + const pattern = /\{\{#([^#]+)#\}\}/g + const valueWithoutAgentVars = value.replace(pattern, (match, variablePath) => { + const nodeId = variablePath.split('.')[0] + return nodeId === detectedAgentFromValue.nodeId ? '' : match + }).trim() + + onChange(valueWithoutAgentVars) + setControlPromptEditorRerenderKey(Date.now()) + }, [detectedAgentFromValue, value, onChange, setControlPromptEditorRerenderKey]) return ( - + {detectedAgentFromValue && ( + )} - className="caret:text-text-accent" - editable={!readOnly} - value={value} - workflowVariableBlock={{ - show: !disableVariableInsertion, - variables: nodesOutputVars || [], - workflowNodesMap: availableNodes.reduce((acc, node) => { - acc[node.id] = { - title: node.data.title, - type: node.data.type, - } - if (node.data.type === BlockEnum.Start) { - acc.sys = { - title: t('blocks.start', { ns: 'workflow' }), - type: BlockEnum.Start, + { + acc[node.id] = { + title: node.data.title, + type: node.data.type, } - } - return acc - }, {} as any), - showManageInputField, - onManageInputField, - }} - placeholder={} - onChange={onChange} - /> + if (node.data.type === BlockEnum.Start) { + acc.sys = { + title: t('blocks.start', { ns: 'workflow' }), + type: BlockEnum.Start, + } + } + return acc + }, {} as any), + showManageInputField, + onManageInputField, + }} + placeholder={} + onChange={onChange} + /> +
) } diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder.tsx index 6e999975f1..03a623b1bc 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder.tsx +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder.tsx @@ -44,6 +44,17 @@ const Placeholder = ({ disableVariableInsertion = false }: PlaceholderProps) => > {t('nodes.tool.insertPlaceholder2', { ns: 'workflow' })}
+
@
+
{ + e.preventDefault() + e.stopPropagation() + handleInsert('@') + })} + > + {t('nodes.tool.insertPlaceholder3', { ns: 'workflow' })} +
)}
diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index 11700722d3..03d42cd1df 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -204,6 +204,7 @@ "common.runApp": "Run App", "common.runHistory": "Run History", "common.running": "Running", + "common.searchAgent": "Search agent", "common.searchVar": "Search variable", "common.setVarValuePlaceholder": "Set variable", "common.showRunHistory": "Show Run History", @@ -215,6 +216,7 @@ "common.variableNamePlaceholder": "Variable name", "common.versionHistory": "Version History", "common.viewDetailInTracingPanel": "View details", + "common.viewInternals": "View internals", "common.viewOnly": "View Only", "common.viewRunHistory": "View run history", "common.workflowAsTool": "Workflow as Tool", @@ -764,6 +766,7 @@ "nodes.tool.inputVars": "Input Variables", "nodes.tool.insertPlaceholder1": "Type or press", "nodes.tool.insertPlaceholder2": "insert variable", + "nodes.tool.insertPlaceholder3": "add agent", "nodes.tool.outputVars.files.title": "tool generated files", "nodes.tool.outputVars.files.transfer_method": "Transfer method.Value is remote_url or local_file", "nodes.tool.outputVars.files.type": "Support type. Now only support image", diff --git a/web/i18n/ja-JP/workflow.json b/web/i18n/ja-JP/workflow.json index dd811a3858..7669d378e1 100644 --- a/web/i18n/ja-JP/workflow.json +++ b/web/i18n/ja-JP/workflow.json @@ -202,6 +202,7 @@ "common.runApp": "アプリを実行", "common.runHistory": "実行履歴", "common.running": "実行中", + "common.searchAgent": "エージェントを検索", "common.searchVar": "変数を検索", "common.setVarValuePlaceholder": "変数値を設定", "common.showRunHistory": "実行履歴を表示", @@ -213,6 +214,7 @@ "common.variableNamePlaceholder": "変数名を入力", "common.versionHistory": "バージョン履歴", "common.viewDetailInTracingPanel": "詳細を表示", + "common.viewInternals": "内部を表示", "common.viewOnly": "閲覧のみ", "common.viewRunHistory": "実行履歴を表示", "common.workflowAsTool": "ワークフローをツールとして公開する", @@ -762,6 +764,7 @@ "nodes.tool.inputVars": "入力変数", "nodes.tool.insertPlaceholder1": "タイプするか押してください", "nodes.tool.insertPlaceholder2": "変数を挿入する", + "nodes.tool.insertPlaceholder3": "エージェントを追加", "nodes.tool.outputVars.files.title": "ツールが生成したファイル", "nodes.tool.outputVars.files.transfer_method": "転送方法。値は remote_url または local_file です", "nodes.tool.outputVars.files.type": "サポートタイプ。現在は画像のみサポートされています", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index 569407aafa..1579da0620 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -202,6 +202,7 @@ "common.runApp": "运行", "common.runHistory": "运行历史", "common.running": "运行中", + "common.searchAgent": "搜索代理", "common.searchVar": "搜索变量", "common.setVarValuePlaceholder": "设置变量值", "common.showRunHistory": "显示运行历史", @@ -213,6 +214,7 @@ "common.variableNamePlaceholder": "变量名", "common.versionHistory": "版本历史", "common.viewDetailInTracingPanel": "查看详细信息", + "common.viewInternals": "查看内部", "common.viewOnly": "只读", "common.viewRunHistory": "查看运行历史", "common.workflowAsTool": "发布为工具", @@ -762,6 +764,7 @@ "nodes.tool.inputVars": "输入变量", "nodes.tool.insertPlaceholder1": "键入", "nodes.tool.insertPlaceholder2": "插入变量", + "nodes.tool.insertPlaceholder3": "添加代理", "nodes.tool.outputVars.files.title": "工具生成的文件", "nodes.tool.outputVars.files.transfer_method": "传输方式。值为 remote_url 或 local_file", "nodes.tool.outputVars.files.type": "支持类型。现在只支持图片", diff --git a/web/i18n/zh-Hant/workflow.json b/web/i18n/zh-Hant/workflow.json index 80e72e42ff..bc9a3008b4 100644 --- a/web/i18n/zh-Hant/workflow.json +++ b/web/i18n/zh-Hant/workflow.json @@ -202,6 +202,7 @@ "common.runApp": "運行", "common.runHistory": "運行歷史", "common.running": "運行中", + "common.searchAgent": "搜索代理", "common.searchVar": "搜索變數", "common.setVarValuePlaceholder": "設置變數值", "common.showRunHistory": "顯示運行歷史", @@ -213,6 +214,7 @@ "common.variableNamePlaceholder": "變數名", "common.versionHistory": "版本歷史", "common.viewDetailInTracingPanel": "查看詳細信息", + "common.viewInternals": "查看內部", "common.viewOnly": "只讀", "common.viewRunHistory": "查看運行歷史", "common.workflowAsTool": "發佈為工具", @@ -762,6 +764,7 @@ "nodes.tool.inputVars": "輸入變數", "nodes.tool.insertPlaceholder1": "輸入或按壓", "nodes.tool.insertPlaceholder2": "插入變數", + "nodes.tool.insertPlaceholder3": "添加代理", "nodes.tool.outputVars.files.title": "工具生成的文件", "nodes.tool.outputVars.files.transfer_method": "傳輸方式。值為 remote_url 或 local_file", "nodes.tool.outputVars.files.type": "支持類型。現在只支持圖片", From 1aed585a19e8091d5a4a35a78860c00c2892d381 Mon Sep 17 00:00:00 2001 From: zhsama Date: Thu, 8 Jan 2026 17:02:35 +0800 Subject: [PATCH 24/82] feat: enhance agent integration in prompt editor and mixed-variable text input --- .../components/base/prompt-editor/index.tsx | 33 +++-- .../plugins/component-picker-block/index.tsx | 134 +++++++++++------- .../components/base/prompt-editor/types.ts | 11 ++ .../mixed-variable-text-input/index.tsx | 44 +++++- .../mixed-variable-text-input/placeholder.tsx | 29 ++-- web/i18n/en-US/workflow.json | 1 + web/i18n/ja-JP/workflow.json | 1 + web/i18n/zh-Hans/workflow.json | 1 + web/i18n/zh-Hant/workflow.json | 1 + 9 files changed, 176 insertions(+), 79 deletions(-) diff --git a/web/app/components/base/prompt-editor/index.tsx b/web/app/components/base/prompt-editor/index.tsx index 05853d1b4d..bf69d682d8 100644 --- a/web/app/components/base/prompt-editor/index.tsx +++ b/web/app/components/base/prompt-editor/index.tsx @@ -5,6 +5,7 @@ import type { } from 'lexical' import type { FC } from 'react' import type { + AgentBlockType, ContextBlockType, CurrentBlockType, ErrorMessageBlockType, @@ -103,6 +104,7 @@ export type PromptEditorProps = { currentBlock?: CurrentBlockType errorMessageBlock?: ErrorMessageBlockType lastRunBlock?: LastRunBlockType + agentBlock?: AgentBlockType isSupportFileVar?: boolean } @@ -128,6 +130,7 @@ const PromptEditor: FC = ({ currentBlock, errorMessageBlock, lastRunBlock, + agentBlock, isSupportFileVar, }) => { const { eventEmitter } = useEventEmitterContextContext() @@ -139,6 +142,7 @@ const PromptEditor: FC = ({ { replace: TextNode, with: (node: TextNode) => new CustomTextNode(node.__text), + withKlass: CustomTextNode, }, ContextBlockNode, HistoryBlockNode, @@ -212,19 +216,22 @@ const PromptEditor: FC = ({ lastRunBlock={lastRunBlock} isSupportFileVar={isSupportFileVar} /> - + {(!agentBlock || agentBlock.show) && ( + + )} { const { eventEmitter } = useEventEmitterContextContext() @@ -151,12 +156,31 @@ const ComponentPicker = ({ editor.dispatchCommand(KEY_ESCAPE_COMMAND, escapeEvent) }, [editor]) + const handleSelectAgent = useCallback((agent: { id: string, title: string }) => { + editor.update(() => { + const needRemove = $splitNodeContainingQuery(checkForTriggerMatch(triggerString, editor)!) + if (needRemove) + needRemove.remove() + }) + agentBlock?.onSelect?.(agent) + handleClose() + }, [editor, checkForTriggerMatch, triggerString, agentBlock, handleClose]) + + const isAgentTrigger = triggerString === '@' && agentBlock?.show + const agentNodes = agentBlock?.agentNodes || [] + const renderMenu = useCallback>(( anchorElementRef, { options, selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }, ) => { - if (!(anchorElementRef.current && (allFlattenOptions.length || workflowVariableBlock?.show))) - return null + if (isAgentTrigger) { + if (!(anchorElementRef.current && agentNodes.length)) + return null + } + else { + if (!(anchorElementRef.current && (allFlattenOptions.length || workflowVariableBlock?.show))) + return null + } setTimeout(() => { if (anchorElementRef.current) @@ -167,9 +191,6 @@ const ComponentPicker = ({ <> { ReactDOM.createPortal( - // The `LexicalMenu` will try to calculate the position of the floating menu based on the first child. - // Since we use floating ui, we need to wrap it with a div to prevent the position calculation being affected. - // See https://github.com/facebook/lexical/blob/ac97dfa9e14a73ea2d6934ff566282d7f758e8bb/packages/lexical-react/src/shared/LexicalMenu.ts#L493
- { - workflowVariableBlock?.show && ( -
- { - handleSelectWorkflowVariable(variables) - }} - maxHeightClass="max-h-[34vh]" - isSupportFileVar={isSupportFileVar} + {isAgentTrigger + ? ( + ({ + ...node, + type: BlockEnum.Agent, + }))} + onSelect={handleSelectAgent} onClose={handleClose} onBlur={handleClose} - showManageInputField={workflowVariableBlock.showManageInputField} - onManageInputField={workflowVariableBlock.onManageInputField} + maxHeightClass="max-h-[34vh]" autoFocus={false} - isInCodeGeneratorInstructionEditor={currentBlock?.generatorType === GeneratorType.code} /> -
- ) - } - { - workflowVariableBlock?.show && !!options.length && ( -
- ) - } -
- { - options.map((option, index) => ( - + ) + : ( + <> { - // Divider - index !== 0 && options.at(index - 1)?.group !== option.group && ( + workflowVariableBlock?.show && ( +
+ { + handleSelectWorkflowVariable(variables) + }} + maxHeightClass="max-h-[34vh]" + isSupportFileVar={isSupportFileVar} + onClose={handleClose} + onBlur={handleClose} + showManageInputField={workflowVariableBlock.showManageInputField} + onManageInputField={workflowVariableBlock.onManageInputField} + autoFocus={false} + isInCodeGeneratorInstructionEditor={currentBlock?.generatorType === GeneratorType.code} + /> +
+ ) + } + { + workflowVariableBlock?.show && !!options.length && (
) } - {option.renderMenuOption({ - queryString, - isSelected: selectedIndex === index, - onSelect: () => { - selectOptionAndCleanUp(option) - }, - onSetHighlight: () => { - setHighlightedIndex(index) - }, - })} -
- )) - } -
+
+ { + options.map((option, index) => ( + + { + index !== 0 && options.at(index - 1)?.group !== option.group && ( +
+ ) + } + {option.renderMenuOption({ + queryString, + isSelected: selectedIndex === index, + onSelect: () => { + selectOptionAndCleanUp(option) + }, + onSetHighlight: () => { + setHighlightedIndex(index) + }, + })} +
+ )) + } +
+ + )}
, anchorElementRef.current, @@ -236,7 +274,7 @@ const ComponentPicker = ({ } ) - }, [allFlattenOptions.length, workflowVariableBlock?.show, floatingStyles, isPositioned, refs, workflowVariableOptions, isSupportFileVar, handleClose, currentBlock?.generatorType, handleSelectWorkflowVariable, queryString, workflowVariableBlock?.showManageInputField, workflowVariableBlock?.onManageInputField]) + }, [isAgentTrigger, agentNodes, allFlattenOptions.length, workflowVariableBlock?.show, floatingStyles, isPositioned, refs, handleSelectAgent, handleClose, workflowVariableOptions, isSupportFileVar, currentBlock?.generatorType, handleSelectWorkflowVariable, queryString, workflowVariableBlock?.showManageInputField, workflowVariableBlock?.onManageInputField]) return ( void } +export type AgentNode = { + id: string + title: string +} + +export type AgentBlockType = { + show?: boolean + agentNodes?: AgentNode[] + onSelect?: (agent: AgentNode) => void +} + export type MenuTextMatch = { leadOffset: number matchingString: string diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx index 8cd98bd3a3..1e891ea385 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx @@ -1,3 +1,4 @@ +import type { AgentBlockType } from '@/app/components/base/prompt-editor/types' import type { Node, NodeOutPutVar, @@ -6,6 +7,7 @@ import { memo, useCallback, useMemo, + useState, } from 'react' import { useTranslation } from 'react-i18next' import PromptEditor from '@/app/components/base/prompt-editor' @@ -74,19 +76,44 @@ const MixedVariableTextInput = ({ return null }, [value, nodesByIdMap]) + const [selectedAgent, setSelectedAgent] = useState<{ id: string, title: string } | null>(null) + + const agentNodes = useMemo(() => { + return availableNodes + .filter(node => node.data.type === BlockEnum.Agent) + .map(node => ({ + id: node.id, + title: node.data.title, + })) + }, [availableNodes]) + + const handleAgentSelect = useCallback((agent: { id: string, title: string }) => { + setSelectedAgent(agent) + if (onChange) { + const agentVar = `{{#${agent.id}.text#}}` + const newValue = value ? `${agentVar}${value}` : agentVar + onChange(newValue) + setControlPromptEditorRerenderKey(Date.now()) + } + }, [onChange, value, setControlPromptEditorRerenderKey]) + const handleAgentRemove = useCallback(() => { - if (!detectedAgentFromValue || !onChange) + const agentNodeId = detectedAgentFromValue?.nodeId || selectedAgent?.id + if (!agentNodeId || !onChange) return const pattern = /\{\{#([^#]+)#\}\}/g const valueWithoutAgentVars = value.replace(pattern, (match, variablePath) => { const nodeId = variablePath.split('.')[0] - return nodeId === detectedAgentFromValue.nodeId ? '' : match + return nodeId === agentNodeId ? '' : match }).trim() onChange(valueWithoutAgentVars) + setSelectedAgent(null) setControlPromptEditorRerenderKey(Date.now()) - }, [detectedAgentFromValue, value, onChange, setControlPromptEditorRerenderKey]) + }, [detectedAgentFromValue?.nodeId, selectedAgent?.id, value, onChange, setControlPromptEditorRerenderKey]) + + const displayedAgent = detectedAgentFromValue || (selectedAgent ? { nodeId: selectedAgent.id, name: selectedAgent.title } : null) return (
- {detectedAgentFromValue && ( + {displayedAgent && ( @@ -127,7 +154,12 @@ const MixedVariableTextInput = ({ showManageInputField, onManageInputField, }} - placeholder={} + agentBlock={{ + show: agentNodes.length > 0 && !displayedAgent, + agentNodes, + onSelect: handleAgentSelect, + } as AgentBlockType} + placeholder={} onChange={onChange} />
diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder.tsx index 03a623b1bc..02053861a3 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder.tsx +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder.tsx @@ -7,9 +7,10 @@ import { CustomTextNode } from '@/app/components/base/prompt-editor/plugins/cust type PlaceholderProps = { disableVariableInsertion?: boolean + hasSelectedAgent?: boolean } -const Placeholder = ({ disableVariableInsertion = false }: PlaceholderProps) => { +const Placeholder = ({ disableVariableInsertion = false, hasSelectedAgent = false }: PlaceholderProps) => { const { t } = useTranslation() const [editor] = useLexicalComposerContext() @@ -44,17 +45,21 @@ const Placeholder = ({ disableVariableInsertion = false }: PlaceholderProps) => > {t('nodes.tool.insertPlaceholder2', { ns: 'workflow' })}
-
@
-
{ - e.preventDefault() - e.stopPropagation() - handleInsert('@') - })} - > - {t('nodes.tool.insertPlaceholder3', { ns: 'workflow' })} -
+ {!hasSelectedAgent && ( + <> +
@
+
{ + e.preventDefault() + e.stopPropagation() + handleInsert('@') + })} + > + {t('nodes.tool.insertPlaceholder3', { ns: 'workflow' })} +
+ + )} )}
diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index 03d42cd1df..39bcee3050 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -173,6 +173,7 @@ "common.needConnectTip": "This step is not connected to anything", "common.needOutputNode": "The Output node must be added", "common.needStartNode": "At least one start node must be added", + "common.noAgentNodes": "No agent nodes available", "common.noHistory": "No History", "common.noVar": "No variable", "common.notRunning": "Not running yet", diff --git a/web/i18n/ja-JP/workflow.json b/web/i18n/ja-JP/workflow.json index 7669d378e1..ed7abb48a3 100644 --- a/web/i18n/ja-JP/workflow.json +++ b/web/i18n/ja-JP/workflow.json @@ -171,6 +171,7 @@ "common.needConnectTip": "接続されていないステップがあります", "common.needOutputNode": "出力ノードを追加する必要があります", "common.needStartNode": "少なくとも1つのスタートノードを追加する必要があります", + "common.noAgentNodes": "利用可能なエージェントノードがありません", "common.noHistory": "履歴がありません", "common.noVar": "変数がありません", "common.notRunning": "まだ実行されていません", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index 1579da0620..24be2d6c67 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -171,6 +171,7 @@ "common.needConnectTip": "此节点尚未连接到其他节点", "common.needOutputNode": "必须添加输出节点", "common.needStartNode": "必须添加至少一个开始节点", + "common.noAgentNodes": "没有可用的代理节点", "common.noHistory": "没有历史版本", "common.noVar": "没有变量", "common.notRunning": "尚未运行", diff --git a/web/i18n/zh-Hant/workflow.json b/web/i18n/zh-Hant/workflow.json index bc9a3008b4..e6f8e88fc5 100644 --- a/web/i18n/zh-Hant/workflow.json +++ b/web/i18n/zh-Hant/workflow.json @@ -171,6 +171,7 @@ "common.needConnectTip": "此節點尚未連接到其他節點", "common.needOutputNode": "必須新增輸出節點", "common.needStartNode": "至少必須新增一個起始節點", + "common.noAgentNodes": "沒有可用的代理節點", "common.noHistory": "無歷史記錄", "common.noVar": "沒有變數", "common.notRunning": "尚未運行", From 5bcd3b6fe60546910c684b4647db3efd5d8c1356 Mon Sep 17 00:00:00 2001 From: Novice Date: Thu, 8 Jan 2026 17:35:42 +0800 Subject: [PATCH 25/82] feat: add mention node executor --- .../docs/variable_extraction_design.md | 1418 +++++++++++++++++ .../event_management/event_handlers.py | 69 + api/core/workflow/graph_events/node.py | 6 + api/core/workflow/nodes/base/__init__.py | 13 +- api/core/workflow/nodes/base/entities.py | 21 + api/core/workflow/nodes/base/node.py | 43 + .../nodes/base/virtual_node_executor.py | 213 +++ api/core/workflow/nodes/tool/tool_node.py | 35 +- api/tests/fixtures/pav-test-extraction.yml | 266 ++++ .../workflow/entities/test_virtual_node.py | 77 + .../use-workflow-node-finished.ts | 5 +- .../use-workflow-node-started.ts | 5 + 12 files changed, 2161 insertions(+), 10 deletions(-) create mode 100644 api/core/workflow/docs/variable_extraction_design.md create mode 100644 api/core/workflow/nodes/base/virtual_node_executor.py create mode 100644 api/tests/fixtures/pav-test-extraction.yml create mode 100644 api/tests/unit_tests/core/workflow/entities/test_virtual_node.py diff --git a/api/core/workflow/docs/variable_extraction_design.md b/api/core/workflow/docs/variable_extraction_design.md new file mode 100644 index 0000000000..8022d94766 --- /dev/null +++ b/api/core/workflow/docs/variable_extraction_design.md @@ -0,0 +1,1418 @@ +# Variable Extraction Design + +从 `list[PromptMessage]` 类型变量中通过 LLM 调用提取参数值的功能设计。 + +--- + +## 1. 概述 + +### 1.1 背景 + +目前 LLM 节点会输出 `context`,它是 `list[dict]` 类型,保存了当前对话的 prompt messages(不含 system message)。 + +```python +# LLM Node outputs +outputs = { + "text": "LLM response text", + "context": [ + {"role": "user", "text": "user input", "files": []}, + {"role": "assistant", "text": "assistant response", "files": []}, + ], + # ... +} +``` + +### 1.2 需求 + +允许其他节点(如工具节点)通过特殊语法引用 LLM 节点的 `context`,并附带一个 prompt,再次调用 LLM 来提取所需的参数值。 + +**使用场景示例**: + +``` +工具节点参数 = "@llm1.context | 提取关键词" + +执行流程: +1. 获取 llm1 节点的 context(对话历史) +2. 将 context + 提取 prompt 组合成新的 prompt messages +3. 调用 LLM 获取提取结果 +4. 将结果作为工具节点的参数值 +``` + +### 1.3 节点组概念 + +当 Tool 节点使用了 `@llm1.context` 时,Tool 节点变成一个"节点组": + +``` +┌─────────────────────────────────────────────────────┐ +│ Tool 节点组 (tool1) │ +│ │ +│ ┌───────────────────────────────────────────────┐ │ +│ │ Extraction 子节点 (tool1_ext_1) │ │ +│ │ - 有独立的 node_id │ │ +│ │ - 有独立的日志和流式输出 │ │ +│ │ - 输出存入 variable_pool │ │ +│ └───────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────┐ │ +│ │ Tool 主节点 (tool1) │ │ +│ │ - 使用 extraction 的输出作为参数 │ │ +│ │ - 有自己的日志和输出 │ │ +│ └───────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────┘ +``` + +--- + +## 2. 现有调用链分析 + +### 2.1 Graph Engine 执行流程 + +``` +GraphEngine.run() + │ + ▼ +┌───────────────────────────────────────────────────────────────────┐ +│ WorkerPool │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ Worker Thread │ │ +│ │ │ │ +│ │ Worker._execute_node(node) │ │ +│ │ │ │ │ +│ │ ├─ node.run() │ │ +│ │ │ │ │ │ +│ │ │ ├─ yield NodeRunStartedEvent │ │ +│ │ │ ├─ yield NodeRunStreamChunkEvent (多次) │ │ +│ │ │ └─ yield NodeRunSucceededEvent │ │ +│ │ │ │ │ +│ │ └─ for event in node.run(): │ │ +│ │ event_queue.put(event) ──────────────────────┐ │ │ +│ │ │ │ │ +│ └───────────────────────────────────────────────────────────│─┘ │ +└──────────────────────────────────────────────────────────────│────┘ + │ + ┌──────────────────────────────────────────────────────┘ + │ + ▼ +┌───────────────────────────────────────────────────────────────────┐ +│ Dispatcher Thread │ +│ │ +│ _dispatcher_loop(): │ +│ while True: │ +│ event = event_queue.get() │ +│ event_handler.dispatch(event) │ +│ │ +└───────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌───────────────────────────────────────────────────────────────────┐ +│ EventHandler.dispatch(event) │ +│ │ +│ ┌─ NodeRunStartedEvent ─────────────────────────────────────┐ │ +│ │ → event_collector.collect(event) │ │ +│ └───────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ NodeRunStreamChunkEvent ─────────────────────────────────┐ │ +│ │ → response_coordinator.intercept_event(event) │ │ +│ │ → event_collector.collect(stream_events) │ │ +│ └───────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ NodeRunSucceededEvent ───────────────────────────────────┐ │ +│ │ → _store_node_outputs(node_id, outputs) │ │ +│ │ └─ variable_pool.add((node_id, var_name), value) │ │ +│ │ → response_coordinator.intercept_event(event) │ │ +│ │ → edge_processor.process_node_success(node_id) │ │ +│ │ └─ ready_queue.put(next_nodes) │ │ +│ └───────────────────────────────────────────────────────────┘ │ +│ │ +└───────────────────────────────────────────────────────────────────┘ +``` + +### 2.2 关键点 + +1. **事件驱动**:节点通过 yield 事件与引擎通信 +2. **Variable Pool 写入时机**:在 `NodeRunSucceededEvent` 处理时,outputs 被写入 variable_pool +3. **事件收集**:所有事件都通过 `event_collector.collect()` 收集,最终返回给调用方 + +--- + +## 3. 节点内嵌子节点设计 + +### 3.1 设计原则 + +**核心思想**:虚拟节点本质上就是一个完整的节点(如 LLM 节点),应该用完整的节点配置来定义,而不是把配置塞到其他地方。 + +**方案**:在节点配置中添加 `virtual_nodes` 字段,定义该节点依赖的子节点列表。子节点是完整的节点定义,执行时先执行子节点,再执行主节点。 + +### 3.2 DSL 设计 + +```yaml +nodes: + - id: tool1 + type: tool + data: + # 虚拟子节点列表 - 完整的节点定义 + virtual_nodes: + - id: ext_1 # 局部 ID,实际会变成 tool1.ext_1 + type: llm # 就是一个完整的 LLM 节点! + data: + title: "提取关键词" + model: + provider: openai + name: gpt-4o-mini + mode: chat + prompt_template: + - role: user + text: "{{#llm1.context#}}" # 引用上游 context + - role: user + text: "请提取关键词,只返回关键词本身" + + # 主节点参数引用子节点输出 + tool_parameters: + query: + type: variable + value: [ext_1, text] # 引用子节点输出 +``` + +### 3.3 完整示例 + +```yaml +nodes: + # 上游 LLM 节点 + - id: llm1 + type: llm + data: + model: + provider: openai + name: gpt-4 + prompt_template: + - role: user + text: "{{#start.query#}}" + + # Tool 节点 - 包含虚拟子节点 + - id: tool1 + type: tool + data: + # 子节点列表 + virtual_nodes: + - id: ext_1 + type: llm + data: + title: "提取搜索关键词" + model: + provider: openai + name: gpt-4o-mini + prompt_template: + - role: user + text: "{{#llm1.context#}}" + - role: user + text: "请从对话中提取用户想要搜索的关键词" + + - id: ext_2 + type: llm + data: + title: "提取搜索范围" + model: + provider: openai + name: gpt-4o-mini + prompt_template: + - role: user + text: "{{#llm1.context#}}" + - role: user + text: "请提取用户想要的搜索范围(如:最近一周)" + + # 主节点配置 + tool_name: google_search + tool_parameters: + query: + type: variable + value: [ext_1, text] # 引用子节点 ext_1 的输出 + time_range: + type: variable + value: [ext_2, text] # 引用子节点 ext_2 的输出 + limit: + type: constant + value: 10 +``` + +### 3.4 子节点 ID 规则 + +子节点的局部 ID 会被转换为全局 ID: + +| 局部 ID | 父节点 ID | 全局 ID | +|---------|-----------|---------| +| `ext_1` | `tool1` | `tool1.ext_1` | +| `ext_2` | `tool1` | `tool1.ext_2` | + +子节点引用使用局部 ID:`[ext_1, text]` + +### 3.5 实体定义 + +```python +# core/workflow/entities/virtual_node.py + +from pydantic import BaseModel +from typing import Any + + +class VirtualNodeConfig(BaseModel): + """Configuration for a virtual sub-node""" + + # Local ID within parent node (e.g., "ext_1") + id: str + + # Node type (e.g., "llm", "code") + type: str + + # Full node data configuration + data: dict[str, Any] + + +# core/workflow/nodes/base/entities.py + +class BaseNodeData(BaseModel): + """Base class for all node data""" + + title: str + desc: str | None = None + # ... existing fields ... + + # Virtual sub-nodes + virtual_nodes: list[VirtualNodeConfig] = [] +``` + +### 3.6 支持的节点类型 + +以下节点需要输出 `context` 变量以支持 extraction: + +| 节点类型 | NodeType | context 来源 | 模型配置位置 | +| ------------------- | ------------------------------ | ----------------------- | ---------------------------------- | +| LLM | `NodeType.LLM` | 已实现 `_build_context` | `node_data.model` | +| Agent | `NodeType.AGENT` | 需要添加 | `agent_parameters` 中的 model 参数 | +| Question Classify | `NodeType.QUESTION_CLASSIFIER` | 需要添加 | `node_data.model` | +| Parameter Extractor | `NodeType.PARAMETER_EXTRACTOR` | 需要添加 | `node_data.model` | + +**context 结构**(统一格式): + +```python +context = [ + {"role": "user", "text": "用户输入", "files": []}, + {"role": "assistant", "text": "模型回复", "files": []}, +] +``` + +--- + +## 4. 执行流程 + +### 4.1 节点内嵌子节点执行流程 + +``` +Tool 节点组执行 + │ + ├─ node.run() 被调用 + │ + ├─ Step 1: 执行虚拟子节点 + │ │ + │ │ 遍历 node_data.virtual_nodes + │ │ + │ │ ┌─────────────────────────────────────────────────────────┐ + │ │ │ 虚拟节点 (tool1.ext_1) │ + │ │ │ + │ │ yield NodeRunStartedEvent (tool1_ext_1, type=LLM) │ + │ │ yield NodeRunStreamChunkEvent (tool1_ext_1, chunk) │ + │ │ yield NodeRunSucceededEvent (tool1_ext_1, outputs) │ + │ │ │ + │ │ → variable_pool.add((tool1_ext_1, "text"), result) │ + │ └─────────────────────────────────────────────────────────┘ + │ + ├─ Tool 参数解析:使用 {{#tool1_ext_1.text#}} 替代原 @llm1.context + │ + │ ┌─────────────────────────────────────────────────────────┐ + │ │ Tool 主节点 (tool1) │ + │ │ │ + │ │ yield NodeRunStartedEvent (tool1) │ + │ │ yield NodeRunStreamChunkEvent (tool1, tool output) │ + │ │ yield NodeRunSucceededEvent (tool1, outputs) │ + │ └─────────────────────────────────────────────────────────┘ + │ + └─ 完成 +``` + +**优点**: + +- 虚拟节点有独立的 node_id,有独立的日志 +- 虚拟节点的 outputs 存入 variable_pool,可被其他节点引用 +- UI 可以清晰展示两个独立的执行过程 + +**缺点**: + +- 实现稍复杂 +- 需要处理虚拟节点的 ID 生成和关联 + +### 4.2 推荐方案:思路 B + +采用虚拟节点方案,因为: + +1. 符合你说的"节点组"概念 +2. 两个调用都有独立的日志和输出 +3. 更清晰的执行边界 + +### 4.3 执行位置选择 + +在节点 \_run() 方法开始时(推荐) + +```python +# tool_node.py +def _run(self) -> Generator[NodeEventBase, None, None]: + # Step 1: 预处理 - 执行所有 extraction + extraction_results = yield from self._process_extractions() + + # Step 2: 使用 extraction 结果生成参数 + parameters = self._generate_parameters(extraction_results) + + # Step 3: 执行 tool 调用 + ... +``` + +**优点**: + +- 可以 yield 事件 +- 在节点控制范围内 +- 清晰的执行顺序 + +## 5. 详细执行流程 + +### 5.1 完整调用链 + +用户定义的 Tool 节点参数(结构化配置): + +```yaml +# Tool 节点配置 +- id: tool1 + type: tool + data: + tool_name: google_search + inputs: + # extraction 类型输入 + - name: query + type: extraction + value: + source_node_id: llm1 + source_variable: context + extraction_prompt: "提取关键词" + # model 不指定,自动继承 llm1 的模型配置 +``` + +执行流程: + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Worker Thread │ +│ │ +│ Worker._execute_node(tool_node) │ +│ │ │ +│ └─ for event in tool_node.run(): │ +│ event_queue.put(event) │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ ToolNode.run() │ +│ │ +│ ┌───────────────────────────────────────────────────────────────────────┐ │ +│ │ Step 1: 预处理 - 发现并执行 extractions │ │ +│ │ │ │ +│ │ yield from self._process_extractions() │ │ +│ │ │ │ │ +│ │ ├─ 解析参数,发现 type=extraction 的 input │ │ +│ │ │ │ │ +│ │ ├─ 创建虚拟节点 ID: "tool1_ext_1" │ │ +│ │ │ │ │ +│ │ ├─ yield NodeRunStartedEvent( │ │ +│ │ │ node_id="tool1_ext_1", │ │ +│ │ │ node_type=NodeType.LLM, │ │ +│ │ │ node_title="Extraction: 提取关键词" │ │ +│ │ │ ) │ │ +│ │ │ │ │ +│ │ ├─ 获取 llm1.context 并构建 prompt_messages │ │ +│ │ │ │ │ +│ │ ├─ 调用 LLM (流式) │ │ +│ │ │ for chunk in llm_invoke(): │ │ +│ │ │ yield NodeRunStreamChunkEvent( │ │ +│ │ │ node_id="tool1_ext_1", │ │ +│ │ │ selector=["tool1_ext_1", "text"], │ │ +│ │ │ chunk=chunk │ │ +│ │ │ ) │ │ +│ │ │ │ │ +│ │ ├─ yield NodeRunSucceededEvent( │ │ +│ │ │ node_id="tool1_ext_1", │ │ +│ │ │ outputs={"text": "关键词A, 关键词B"} │ │ +│ │ │ ) │ │ +│ │ │ │ │ +│ │ └─ 返回 extraction_results = {"tool1_ext_1": "关键词A, 关键词B"} │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────────────────────────┐ │ +│ │ Step 2: 主节点执行 │ │ +│ │ │ │ +│ │ yield NodeRunStartedEvent( │ │ +│ │ node_id="tool1", │ │ +│ │ node_type=NodeType.TOOL │ │ +│ │ ) │ │ +│ │ │ │ +│ │ parameters = _generate_parameters(extraction_results) │ │ +│ │ # param = "关键词A, 关键词B" │ │ +│ │ │ │ +│ │ tool.invoke(parameters) │ │ +│ │ for chunk in tool_output: │ │ +│ │ yield NodeRunStreamChunkEvent( │ │ +│ │ node_id="tool1", │ │ +│ │ selector=["tool1", "text"], │ │ +│ │ chunk=chunk │ │ +│ │ ) │ │ +│ │ │ │ +│ │ yield NodeRunSucceededEvent( │ │ +│ │ node_id="tool1", │ │ +│ │ outputs={"text": "tool output..."} │ │ +│ │ ) │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Dispatcher Thread │ +│ │ +│ 收到事件序列: │ +│ │ +│ 1. NodeRunStartedEvent(node_id="tool1_ext_1") │ +│ → event_collector.collect() │ +│ │ +│ 2. NodeRunStreamChunkEvent(node_id="tool1_ext_1", chunk="关键词") │ +│ → response_coordinator → event_collector.collect() │ +│ │ +│ 3. NodeRunSucceededEvent(node_id="tool1_ext_1", outputs={...}) │ +│ → _store_node_outputs("tool1_ext_1", outputs) │ +│ └─ variable_pool.add(("tool1_ext_1", "text"), "关键词A, 关键词B") │ +│ → event_collector.collect() │ +│ 注意:不触发 edge_processor,因为这是虚拟节点 │ +│ │ +│ 4. NodeRunStartedEvent(node_id="tool1") │ +│ → event_collector.collect() │ +│ │ +│ 5. NodeRunStreamChunkEvent(node_id="tool1", chunk="tool output") │ +│ → response_coordinator → event_collector.collect() │ +│ │ +│ 6. NodeRunSucceededEvent(node_id="tool1", outputs={...}) │ +│ → _store_node_outputs("tool1", outputs) │ +│ → edge_processor.process_node_success("tool1") │ +│ └─ ready_queue.put(next_nodes) │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### 5.2 关键问题:虚拟节点的事件处理 + +虚拟节点(如 `tool1_ext_1`)的事件需要特殊处理: + +```python +# EventHandler 需要区分虚拟节点和真实节点 +def _(self, event: NodeRunSucceededEvent) -> None: + # 存储输出到 variable_pool(虚拟节点也需要) + self._store_node_outputs(event.node_id, event.node_run_result.outputs) + + # 检查是否是虚拟节点(通过 node_id 格式判断:包含 _ext_) + if self._is_virtual_node(event.node_id): + # 虚拟节点不触发边处理,只收集事件 + self._event_collector.collect(event) + return + + # 真实节点:触发边处理,推进工作流 + ready_nodes = self._edge_processor.process_node_success(event.node_id) + ... + +def _is_virtual_node(self, node_id: str) -> bool: + """Check if node_id represents a virtual extraction node.""" + return "_ext_" in node_id +``` + +### 5.3 虚拟节点 ID 命名规则 + +```python +def _generate_extraction_node_id( + parent_node_id: str, + extraction_index: int, +) -> str: + """ + Generate unique ID for extraction virtual node. + + Format: {parent_node_id}_ext_{index} + Example: tool1_ext_1, tool1_ext_2 + """ + return f"{parent_node_id}_ext_{extraction_index}" +``` + +### 5.4 ExtractionExecutor 详细设计 + +**设计原则**: + +1. **直接实例化并运行 LLMNode**:创建真正的 LLMNode 实例并调用 `run()` +2. **完全复用节点逻辑**:LLMNode 的 `_run()`、Node 基类的 `run()` 和异常处理全部复用 +3. **通过重新实例化实现重试**:失败时重新创建 LLMNode 实例并再次运行 +4. **自动获得所有能力**:token 统计、流式输出、完整的 NodeRunResult 格式 + +```python +# core/workflow/nodes/base/extraction_executor.py + +class ExtractionExecutor: + """ + Executes LLM calls for extracting values from PromptMessage-type variables. + + This executor directly instantiates LLMNode instances, fully reusing: + - LLMNode's _run() logic + - Node base class's run() method and exception handling + - All events and token statistics + + Retry is implemented at this level by re-instantiating and re-running the node. + """ + + def __init__( + self, + *, + variable_pool: VariablePool, + graph_config: Mapping[str, Any], + graph_init_params: GraphInitParams, + graph_runtime_state: GraphRuntimeState, + parent_node_id: str, + parent_retry_config: RetryConfig | None = None, + ): + # Store graph context for creating LLMNode instances + self._graph_init_params = graph_init_params + self._graph_runtime_state = graph_runtime_state + # ... + + def _execute_single_extraction( + self, + spec: VariableExtractionSpec, + ext_node_id: str, + ) -> Generator[GraphNodeEventBase, None, str]: + """ + Execute a single extraction by instantiating and running a real LLMNode. + """ + # Create LLMNode instance with minimal config + llm_node = self._create_llm_node( + ext_node_id=ext_node_id, + context=context, + extraction_prompt=spec.extraction_prompt, + model_config=model_config, + spec=spec, + ) + + # Run the node and collect events - FULLY REUSES LLMNode's logic! + for event in llm_node.run(): + # Mark events as virtual + event = self._mark_event_as_virtual(event, spec) + yield event + + if isinstance(event, NodeRunSucceededEvent): + result_text = event.node_run_result.outputs.get("text", "") + elif isinstance(event, NodeRunFailedEvent): + raise LLMInvocationError(Exception(event.error)) + + return result_text + + def _create_llm_node(self, ...) -> LLMNode: + """ + Create a real LLMNode instance for extraction. + Constructs minimal required configuration. + """ + # Build prompt template from context + extraction prompt + prompt_template = [...] # LLMNodeChatModelMessage list + + # Create LLMNode with full graph context + llm_node = LLMNode( + id=ext_node_id, + config=node_config, + graph_init_params=self._graph_init_params, + graph_runtime_state=self._graph_runtime_state, + ) + return llm_node + + def _execute_with_retry(self, spec, ext_node_id) -> Generator[...]: + """ + Retry by re-instantiating and re-running the LLMNode. + """ + for attempt in range(retry_config.max_retries + 1): + try: + return (yield from self._execute_single_extraction(spec, ext_node_id)) + except Exception as e: + if attempt < retry_config.max_retries: + yield NodeRunRetryEvent(...) + time.sleep(retry_config.retry_interval_seconds) + continue + raise +``` + +--- + +## 6. 事件设计 + +### 6.1 复用现有事件类型 + +采用虚拟节点方案后,**不需要新增事件类型**。虚拟节点直接使用现有的: + +- `NodeRunStartedEvent` +- `NodeRunStreamChunkEvent` +- `NodeRunSucceededEvent` +- `NodeRunFailedEvent` + +**区分虚拟节点的方式**:在 `NodeRunStartedEvent` 中添加可选字段: + +```python +# core/workflow/graph_events/node.py + +class NodeRunStartedEvent(GraphNodeEventBase): + node_title: str + predecessor_node_id: str | None = None + agent_strategy: AgentNodeStrategyInit | None = None + start_at: datetime = Field(..., description="node start time") + + # Existing fields for ToolNode + provider_type: str = "" + provider_id: str = "" + + # NEW: Virtual node fields for extraction + is_virtual: bool = False + parent_node_id: str | None = None + extraction_source: str | None = None # e.g., "llm1.context" + extraction_prompt: str | None = None +``` + +**字段说明**: + +| 字段 | 类型 | 说明 | +| ------------------- | ------------- | ------------------------------ | +| `is_virtual` | `bool` | 是否为虚拟节点,默认 `False` | +| `parent_node_id` | `str \| None` | 父节点 ID,如 `"tool1"` | +| `extraction_source` | `str \| None` | 提取来源,如 `"llm1.context"` | +| `extraction_prompt` | `str \| None` | 提取 prompt,如 `"提取关键词"` | + +### 6.2 事件序列示例 + +前端收到的事件序列: + +``` +1. NodeRunStartedEvent + - node_id: "tool1_ext_1" + - node_type: NodeType.LLM + - node_title: "Extraction: 提取关键词" + - is_virtual: true + - parent_node_id: "tool1" + - extraction_source: "llm1.context" + - extraction_prompt: "提取关键词" + +2. NodeRunStreamChunkEvent + - node_id: "tool1_ext_1" + - selector: ["tool1_ext_1", "text"] + - chunk: "关键词" + +3. NodeRunSucceededEvent + - node_id: "tool1_ext_1" + - outputs: {"text": "关键词A, 关键词B"} + +4. NodeRunStartedEvent + - node_id: "tool1" + - node_type: NodeType.TOOL + - node_title: "Search Tool" + - is_virtual: false + +5. NodeRunStreamChunkEvent + - node_id: "tool1" + - selector: ["tool1", "text"] + - chunk: "search result..." + +6. NodeRunSucceededEvent + - node_id: "tool1" + - outputs: {"text": "..."} +``` + +### 6.3 前端展示建议 + +前端可以根据 `is_virtual` 和 `parent_node_id` 字段: + +1. **嵌套展示**:将虚拟节点的输出显示在父节点内部 +2. **分开展示**:作为独立的节点展示,但用 UI 标识关联关系 +3. **折叠展示**:默认折叠虚拟节点,可展开查看详情 + +--- + +## 7. 日志与记录 + +### 7.1 虚拟节点的 NodeRunResult + +虚拟节点有独立的 `NodeRunResult`,结构与普通 LLM 节点一致: + +```python +NodeRunResult( + status=WorkflowNodeExecutionStatus.SUCCEEDED, + inputs={ + "context_source": "llm1.context", + "extraction_prompt": "提取关键词", + }, + process_data={ + "source": "llm1.context", + "prompt": "提取关键词", + "model_mode": "chat", + "prompts": [ + {"role": "user", "text": "原始用户输入"}, + {"role": "assistant", "text": "原始助手回复"}, + {"role": "user", "text": "提取关键词"}, + ], + "usage": { + "prompt_tokens": 100, + "completion_tokens": 20, + "total_tokens": 120, + }, + }, + outputs={ + "text": "关键词A, 关键词B", + }, + metadata={ + WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS: 120, + WorkflowNodeExecutionMetadataKey.TOTAL_PRICE: 0.0001, + WorkflowNodeExecutionMetadataKey.CURRENCY: "USD", + }, + llm_usage=LLMUsage( + prompt_tokens=100, + completion_tokens=20, + total_tokens=120, + ), +) +``` + +### 7.2 父节点的 process_data + +父节点(如 ToolNode)可以在 `process_data` 中记录关联的虚拟节点: + +```python +process_data = { + # ... existing fields + "extraction_nodes": ["tool1_ext_1", "tool1_ext_2"], +} +``` + +### 7.3 数据库记录 + +虚拟节点的执行记录会被保存到 `workflow_node_executions` 表: + +| 字段 | 值 | +| ----------- | ----------------------------------------- | +| `node_id` | `"tool1_ext_1"` | +| `node_type` | `"llm"` | +| `title` | `"Extraction: 提取关键词..."` | +| `inputs` | `{"context_source": "llm1.context", ...}` | +| `outputs` | `{"text": "关键词A, 关键词B"}` | +| `status` | `"succeeded"` | + +前端可以通过 `node_id` 中的 `_ext_` 识别虚拟节点,并关联到父节点。 + +--- + +## 8. 集成示例 + +### 8.1 ToolNode 集成 + +```python +# core/workflow/nodes/tool/tool_node.py + +from core.workflow.nodes.base.extraction_executor import ExtractionExecutor + + +class ToolNode(Node[ToolNodeData]): + + def _run(self) -> Generator[NodeEventBase, None, None]: + # Step 1: 创建 ExtractionExecutor(传入父节点的 retry_config) + extraction_executor = ExtractionExecutor( + variable_pool=self.graph_runtime_state.variable_pool, + graph_config=self.graph_config, + graph_init_params=self._graph_init_params, + graph_runtime_state=self.graph_runtime_state, + parent_node_id=self._node_id, + parent_retry_config=self.retry_config, # 继承父节点的重试配置 + ) + + # Step 2: 查找所有 extraction 类型的 inputs + specs = extraction_executor.find_extractions(self.node_data.model_dump()) + + # Step 3: 执行 extractions(yield 虚拟节点事件,包括重试事件) + extraction_results: dict[str, str] = {} + if specs: + try: + extraction_results = yield from extraction_executor.process_extractions(specs) + except ExtractionError as e: + # ExtractionExecutor 已 yield 了 NodeRunFailedEvent + # 根据父节点的 error_strategy 决定如何处理 + if self.error_strategy == ErrorStrategy.DEFAULT_VALUE: + extraction_results = self._get_default_extraction_values(specs) + else: + raise + + # Step 4: 生成参数(使用 extraction 结果作为对应 input 的值) + parameters = self._generate_parameters_with_extractions( + tool_parameters=tool_parameters, + extraction_results=extraction_results, + ) + + # Step 5: 继续正常的 tool 调用流程... + ... + + def _generate_parameters_with_extractions( + self, + *, + tool_parameters: Sequence[ToolParameter], + extraction_results: dict[str, str], # input_name -> extracted_value + ) -> dict[str, Any]: + """Generate parameters, using extraction results for extraction-type inputs.""" + result: dict[str, Any] = {} + + for parameter_name, tool_input in self.node_data.tool_parameters.items(): + # Check if this input is an extraction type (result already in extraction_results) + if parameter_name in extraction_results: + result[parameter_name] = extraction_results[parameter_name] + + elif tool_input.type in {"mixed", "constant"}: + template = str(tool_input.value) + resolved = self.graph_runtime_state.variable_pool.convert_template(template).text + result[parameter_name] = resolved + + elif tool_input.type == "variable": + variable = self.graph_runtime_state.variable_pool.get(tool_input.value) + result[parameter_name] = variable.value if variable else None + + return result + + def _get_default_extraction_values( + self, + specs: list[VariableExtractionSpec], + ) -> dict[str, str]: + """Return default values for failed extractions.""" + return {spec.input_name: "" for spec in specs} +``` + +### 8.2 通用基类集成(可选方案) + +如果多个节点类型都需要支持 extraction,可以在基类中统一处理: + +```python +# core/workflow/nodes/base/node.py + +class Node(Generic[NodeDataT]): + + def run(self) -> Generator[GraphNodeEventBase, None, None]: + # Step 1: 预处理 extractions(如果有) + extraction_results = yield from self._preprocess_extractions() + + # Step 2: 正常执行 + execution_id = self.ensure_execution_id() + # ...existing logic... + + def _preprocess_extractions(self) -> Generator[GraphNodeEventBase, None, dict[str, str]]: + """ + Override in subclasses that support extraction. + Default implementation returns empty dict. + """ + return {} + + def _supports_extraction(self) -> bool: + """Override to return True if node supports extraction.""" + return False +``` + +### 8.3 为其他节点添加 context 输出 + +以下节点需要在 outputs 中添加 `context`: + +```python +# core/workflow/nodes/question_classifier/question_classifier_node.py + +def _run(self) -> NodeRunResult: + # ...existing logic... + + outputs = { + "class_name": result.class_name, + # NEW: Add context for extraction support + "context": self._build_context(prompt_messages, result.text), + } + + return NodeRunResult( + status=WorkflowNodeExecutionStatus.SUCCEEDED, + outputs=outputs, + ) +``` + +```python +# core/workflow/nodes/parameter_extractor/parameter_extractor_node.py + +def _run(self) -> NodeRunResult: + # ...existing logic... + + outputs = { + **extracted_parameters, + # NEW: Add context for extraction support + "context": self._build_context(prompt_messages, assistant_response), + } +``` + +**注意**:`_build_context` 方法可以从 `LLMNode` 中提取为公共函数,或者直接复用: + +```python +# core/workflow/nodes/llm/llm_utils.py + +def build_context( + prompt_messages: Sequence[PromptMessage], + assistant_response: str, + model_mode: str, +) -> list[dict[str, Any]]: + """ + Build context from prompt messages and assistant response. + Excludes system messages and includes the current LLM response. + """ + context_messages = [m for m in prompt_messages if m.role != PromptMessageRole.SYSTEM] + context_messages.append(AssistantPromptMessage(content=assistant_response)) + return PromptMessageUtil.prompt_messages_to_prompt_for_saving( + model_mode=model_mode, prompt_messages=context_messages + ) +``` + +--- + +## 9. 配置选项 + +### 9.1 模型配置策略 + +提取调用使用的模型,按优先级: + +| 优先级 | 来源 | 说明 | +| ------ | ---------- | ------------------------------------ | +| 1 | 显式指定 | `extraction.value.model` 配置 | +| 2 | 源节点配置 | 继承 `source_node_id` 节点的模型配置 | + +### 9.2 ExtractionModelConfig 使用 + +```python +# 在 ExtractionExecutor 中获取模型配置 + +def _get_model_config(self, spec: VariableExtractionSpec) -> dict: + # 如果显式指定了 model,使用它 + if spec.model: + return { + "provider": spec.model.provider, + "name": spec.model.name, + "mode": spec.model.mode.value, + "completion_params": spec.model.completion_params, + } + + # 否则继承源节点的模型配置 + source_model_config = self._get_source_node_model_config(spec.source_node_id) + if source_model_config is None: + raise ModelConfigNotFoundError(spec.source_node_id, spec.source_variable) + + return source_model_config +``` + +### 9.3 模型配置示例 + +**场景 1:继承源节点配置(推荐)** + +```yaml +# 节点配置 +inputs: + - name: query + type: extraction + value: + source_node_id: llm1 + source_variable: context + extraction_prompt: "提取关键词" + # 不指定 model,自动继承 llm1 的模型配置 + +# llm1 节点配置 +data: + model: + provider: openai + name: gpt-4 + mode: chat + completion_params: + temperature: 0.7 +# 结果:使用 openai/gpt-4 +``` + +**场景 2:显式指定模型** + +```yaml +# 节点配置 +inputs: + - name: query + type: extraction + value: + source_node_id: llm1 + source_variable: context + extraction_prompt: "提取关键词" + model: + provider: openai + name: gpt-4o-mini + mode: chat + completion_params: + temperature: 0.3 +# 结果:使用 openai/gpt-4o-mini(忽略源节点配置) +``` + +--- + +## 10. 错误处理与重试机制 + +### 10.1 设计考量 + +**重要说明**:虚拟节点(Extraction 节点)的重试机制**无法**直接复用现有的节点级别重试机制。 + +原因分析: + +- Worker 从 `ready_queue` 取节点时,通过 `graph.nodes[node_id]` 获取节点实例 +- 虚拟节点不在 `graph.nodes` 中 +- `ErrorHandler._handle_retry()` 无法找到虚拟节点进行重试 + +因此,**ExtractionExecutor 需要在内部实现重试逻辑**。 + +### 10.2 错误类型 + +```python +# core/workflow/nodes/base/extraction_errors.py + +class ExtractionError(Exception): + """Base exception for extraction operations""" + pass + + +class VariableNotFoundError(ExtractionError): + """Source variable not found in variable pool""" + + def __init__(self, selector: list[str]): + self.selector = selector + super().__init__(f"Variable {'.'.join(selector)} not found in variable pool") + + +class InvalidVariableTypeError(ExtractionError): + """Source variable is not a valid context type (list[dict])""" + + def __init__(self, selector: list[str], actual_type: type): + self.selector = selector + self.actual_type = actual_type + super().__init__( + f"Variable {'.'.join(selector)} is not a list type, got {actual_type.__name__}" + ) + + +class SourceNodeNotFoundError(ExtractionError): + """Source node not found in graph config""" + + def __init__(self, node_id: str): + self.node_id = node_id + super().__init__(f"Source node {node_id} not found in graph config") + + +class LLMInvocationError(ExtractionError): + """LLM invocation failed during extraction""" + + def __init__(self, original_error: Exception): + self.original_error = original_error + super().__init__(f"LLM invocation failed: {original_error}") +``` + +### 10.3 内部重试机制 + +虚拟节点的重试在 `ExtractionExecutor` 内部处理,继承父节点的 `retry_config`: + +```python +# ExtractionExecutor 的重试实现 + +def _execute_single_extraction_with_retry( + self, + spec: VariableExtractionSpec, + ext_node_id: str, +) -> Generator[..., None, tuple[str, LLMUsage]]: + """ + Execute extraction with internal retry support. + + Retry config is inherited from parent node. + """ + retry_config = self._parent_retry_config + last_error: Exception | None = None + + for attempt in range(retry_config.max_retries + 1): + try: + return (yield from self._execute_single_extraction(spec, ext_node_id)) + except LLMInvocationError as e: + last_error = e + + if attempt < retry_config.max_retries: + # Yield retry event for frontend display + yield NodeRunRetryEvent( + id=str(uuid4()), + node_id=ext_node_id, + node_type=NodeType.LLM, + node_title=f"Extraction: {spec.extraction_prompt[:30]}...", + start_at=self._start_time, + error=str(e), + retry_index=attempt + 1, + ) + + # Wait for retry interval + time.sleep(retry_config.retry_interval_seconds) + continue + + # Max retries exceeded, raise + raise + + # Should not reach here, but for type safety + raise last_error or LLMInvocationError(Exception("Unknown error")) +``` + +### 10.4 错误传播 + +```python +# ToolNode 中的错误处理示例 + +def _run(self) -> Generator[NodeEventBase, None, None]: + try: + # 执行 extractions(内部已处理重试) + extraction_results = yield from extraction_executor.process_extractions(specs) + except ExtractionError as e: + # 虚拟节点已 yield 了 NodeRunFailedEvent + # 异常传播到父节点,由父节点的 error_strategy 决定后续处理 + if self.error_strategy == ErrorStrategy.DEFAULT_VALUE: + extraction_results = self._get_default_extraction_values(specs) + else: + raise # 终止执行 + + # 继续执行... +``` + +### 10.5 为什么不能复用节点级别重试 + +节点级别的重试流程: + +``` +Worker 执行节点 + → 失败 → NodeRunFailedEvent + → Dispatcher → EventHandler + → ErrorHandler._handle_retry() + → 检查 graph.nodes[node_id] ← 虚拟节点不存在! + → 重新入队 ready_queue +``` + +虚拟节点不在 `graph.nodes` 中,无法进入此流程。因此重试必须在 ExtractionExecutor 内部完成。 + +--- + +## 11. 设计决策 + +### 11.1 模型配置 + +**决定:使用结构化配置,可选显式指定模型** + +**配置方式**: + +```yaml +# 继承源节点模型(推荐) +- name: query + type: extraction + value: + source_node_id: llm1 + source_variable: context + extraction_prompt: "提取关键词" + +# 显式指定模型 +- name: summary + type: extraction + value: + source_node_id: agent1 + source_variable: context + extraction_prompt: "总结对话" + model: + provider: openai + name: gpt-4o-mini +``` + +**优先级**: + +1. 如果 `extraction.value.model` 存在,使用指定的模型 +2. 否则,继承源节点的模型配置 + +**模型配置字段**: + +| 字段 | 说明 | 来源 | +| ------------------- | ---------- | ------------------------- | +| `provider` | 模型提供商 | 显式指定 或 源节点配置 | +| `name` | 模型名称 | 显式指定 或 源节点配置 | +| `mode` | LLM 模式 | 默认 `chat` 或 源节点配置 | +| `completion_params` | 推理参数 | 显式指定 或 源节点配置 | + +### 11.2 Token 计费 + +**决定:A - 虚拟节点独立计费** + +虚拟节点有独立的 `NodeRunResult`,token 消耗记录在虚拟节点的 `metadata` 中。 + +### 11.3 context 变量类型 + +**决定:C - 暂不新增类型** + +当前 `context` 使用 `list[dict]` 格式(`ArrayAnySegment`),先这样实现,后续视需要再考虑新增 `PromptMessagesSegment` 类型。 + +### 11.4 支持范围 + +**决定:A - 支持所有使用 LLM 的节点** + +包括: + +- LLM 节点 +- Agent 节点 +- Question Classify 节点 +- Parameter Extractor 节点 + +这些节点都需要输出 `context` 变量。 + +### 11.5 重试机制 + +**决定:A - 内部实现重试** + +虚拟节点在 `ExtractionExecutor` 内部实现重试机制,而非复用节点级别的重试流程。 + +**原因**: + +- 节点级别的重试需要节点在 `graph.nodes` 中,虚拟节点不满足此条件 +- `ErrorHandler._handle_retry()` 无法找到虚拟节点 + +**实现方式**: + +- 继承父节点的 `retry_config`(max_retries, retry_interval_seconds) +- 在 `ExtractionExecutor._execute_with_retry()` 中实现重试循环 +- 每次重试 yield `NodeRunRetryEvent` 供前端展示 + +### 11.6 复用 LLMNode 逻辑 + +**决定:使用 LLMNode 静态方法** + +ExtractionExecutor 复用 `LLMNode.invoke_llm()` 和 `LLMNode.handle_invoke_result()` 静态方法: + +**优点**: + +- 获得完整的 streaming 处理能力 +- 获得完整的 token 统计(`LLMUsage`) +- 获得文件处理能力(multimodal) +- 返回格式与真正的 LLM 节点一致 + +**NodeRunResult 包含**: + +- `outputs`: `{"text": "..."}` +- `llm_usage`: `LLMUsage` 对象 +- `metadata`: token 计费信息(TOTAL_TOKENS, TOTAL_PRICE, CURRENCY) + +--- + +## 12. 实现计划 + +### Phase 1: 基础设施 + +| Task | 文件 | 说明 | +| ---- | ----------------------------------------------- | ------------------------------------------------------ | +| 1.1 | `core/workflow/entities/variable_extraction.py` | 定义 `VariableExtractionSpec`、`ExtractionModelConfig` | +| 1.2 | `core/workflow/graph_events/node.py` | 在 `NodeRunStartedEvent` 添加虚拟节点字段 | +| 1.3 | `core/workflow/nodes/llm/llm_utils.py` | 提取 `build_context` 为公共函数 | + +### Phase 2: 核心执行器 + +| Task | 文件 | 说明 | +| ---- | --------------------------------------------------------------- | ----------------------------------------------------------- | +| 2.1 | `core/workflow/nodes/base/extraction_errors.py` | 定义错误类型 | +| 2.2 | `core/workflow/nodes/base/extraction_executor.py` | 实现 `ExtractionExecutor` | +| 2.3 | `core/workflow/graph_engine/event_management/event_handlers.py` | 修改 `_is_virtual_node` 判断,虚拟节点不触发 edge_processor | + +### Phase 3: 节点 context 输出 + +| Task | 文件 | 说明 | +| ---- | ------------------------------------------ | ------------------- | +| 3.1 | `core/workflow/nodes/agent/agent_node.py` | 添加 `context` 输出 | +| 3.2 | `core/workflow/nodes/question_classifier/` | 添加 `context` 输出 | +| 3.3 | `core/workflow/nodes/parameter_extractor/` | 添加 `context` 输出 | + +### Phase 4: 节点集成 + +| Task | 文件 | 说明 | +| ---- | ----------------------------------------- | ------------------------- | +| 4.1 | `core/workflow/nodes/tool/tool_node.py` | 集成 `ExtractionExecutor` | +| 4.2 | `core/workflow/nodes/agent/agent_node.py` | 集成 `ExtractionExecutor` | +| 4.3 | 其他节点 | 按需集成 | + +### Phase 5: 测试 + +| Task | 说明 | +| ---- | ---------------------------------- | +| 5.1 | 单元测试:结构化配置解析 | +| 5.2 | 单元测试:ExtractionExecutor | +| 5.3 | 集成测试:ToolNode with extraction | +| 5.4 | 集成测试:多个 extraction 场景 | + +--- + +## 13. 附录 + +### 13.1 相关代码位置 + +| 模块 | 路径 | 说明 | +| ------------- | --------------------------------------------------------------- | --------------------------------- | +| LLM Node | `core/workflow/nodes/llm/node.py` | `_build_context` 方法(line 600) | +| Tool Node | `core/workflow/nodes/tool/tool_node.py` | `_generate_parameters` 方法 | +| Agent Node | `core/workflow/nodes/agent/agent_node.py` | 需要添加 context 输出 | +| Variable Pool | `core/workflow/runtime/variable_pool.py` | 变量存取和模板解析 | +| Graph Events | `core/workflow/graph_events/node.py` | 节点事件定义 | +| Event Handler | `core/workflow/graph_engine/event_management/event_handlers.py` | 事件处理和变量存储 | +| Worker | `core/workflow/graph_engine/worker.py` | 节点执行和事件队列 | + +### 13.2 参考实现 + +| 功能 | 参考代码 | 说明 | +| ------------- | ------------------------ | ------------------------------------------------------ | +| 模板解析 | `VariableTemplateParser` | `core/workflow/nodes/base/variable_template_parser.py` | +| 历史消息处理 | `TokenBufferMemory` | `core/memory/token_buffer_memory.py` | +| LLM 流式调用 | `LLMNode.invoke_llm` | `core/workflow/nodes/llm/node.py` line 386 | +| 事件 dispatch | `Node._dispatch` | `core/workflow/nodes/base/node.py` line 559 | + +### 13.3 新增文件 + +实现本功能需要新增以下文件: + +``` +core/workflow/ +├── entities/ +│ └── variable_extraction.py # NEW: VariableExtractionSpec 定义 +└── nodes/ + └── base/ + ├── extraction_errors.py # NEW: 错误类型定义 + └── extraction_executor.py # NEW: ExtractionExecutor 实现 +``` + +### 13.4 修改文件清单 + +| 文件 | 修改内容 | +| --------------------------------------------------------------- | ------------------------------------------- | +| `core/workflow/graph_events/node.py` | 添加 `is_virtual`, `parent_node_id` 等字段 | +| `core/workflow/graph_engine/event_management/event_handlers.py` | 添加 `_is_virtual_node` 判断 | +| `core/workflow/nodes/llm/llm_utils.py` | 提取 `build_context` 公共函数 | +| `core/workflow/nodes/tool/tool_node.py` | 集成 ExtractionExecutor | +| `core/workflow/nodes/agent/agent_node.py` | 添加 context 输出 + 集成 ExtractionExecutor | +| `core/workflow/nodes/question_classifier/*.py` | 添加 context 输出 | +| `core/workflow/nodes/parameter_extractor/*.py` | 添加 context 输出 | diff --git a/api/core/workflow/graph_engine/event_management/event_handlers.py b/api/core/workflow/graph_engine/event_management/event_handlers.py index 5b0f56e59d..7a10b0f291 100644 --- a/api/core/workflow/graph_engine/event_management/event_handlers.py +++ b/api/core/workflow/graph_engine/event_management/event_handlers.py @@ -125,6 +125,11 @@ class EventHandler: Args: event: The node started event """ + # Check if this is a virtual node (extraction node) + if self._is_virtual_node(event.node_id): + self._handle_virtual_node_started(event) + return + # Track execution in domain model node_execution = self._graph_execution.get_or_create_node_execution(event.node_id) is_initial_attempt = node_execution.retry_count == 0 @@ -164,6 +169,11 @@ class EventHandler: Args: event: The node succeeded event """ + # Check if this is a virtual node (extraction node) + if self._is_virtual_node(event.node_id): + self._handle_virtual_node_success(event) + return + # Update domain model node_execution = self._graph_execution.get_or_create_node_execution(event.node_id) node_execution.mark_taken() @@ -226,6 +236,11 @@ class EventHandler: Args: event: The node failed event """ + # Check if this is a virtual node (extraction node) + if self._is_virtual_node(event.node_id): + self._handle_virtual_node_failed(event) + return + # Update domain model node_execution = self._graph_execution.get_or_create_node_execution(event.node_id) node_execution.mark_failed(event.error) @@ -345,3 +360,57 @@ class EventHandler: self._graph_runtime_state.set_output("answer", value) else: self._graph_runtime_state.set_output(key, value) + + def _is_virtual_node(self, node_id: str) -> bool: + """ + Check if node_id represents a virtual sub-node. + + Virtual nodes have IDs in the format: {parent_node_id}.{local_id} + We check if the part before '.' exists in graph nodes. + """ + if "." in node_id: + parent_id = node_id.rsplit(".", 1)[0] + return parent_id in self._graph.nodes + return False + + def _handle_virtual_node_started(self, event: NodeRunStartedEvent) -> None: + """ + Handle virtual node started event. + + Virtual nodes don't need full execution tracking, just collect the event. + """ + # Track in response coordinator for stream ordering + self._response_coordinator.track_node_execution(event.node_id, event.id) + + # Collect the event + self._event_collector.collect(event) + + def _handle_virtual_node_success(self, event: NodeRunSucceededEvent) -> None: + """ + Handle virtual node success event. + + Virtual nodes (extraction nodes) need special handling: + - Store outputs in variable pool (for reference by other nodes) + - Accumulate token usage + - Collect the event for logging + - Do NOT process edges or enqueue next nodes (parent node handles that) + """ + self._accumulate_node_usage(event.node_run_result.llm_usage) + + # Store outputs in variable pool + self._store_node_outputs(event.node_id, event.node_run_result.outputs) + + # Collect the event + self._event_collector.collect(event) + + def _handle_virtual_node_failed(self, event: NodeRunFailedEvent) -> None: + """ + Handle virtual node failed event. + + Virtual nodes (extraction nodes) failures are collected for logging, + but the parent node is responsible for handling the error. + """ + self._accumulate_node_usage(event.node_run_result.llm_usage) + + # Collect the event for logging + self._event_collector.collect(event) diff --git a/api/core/workflow/graph_events/node.py b/api/core/workflow/graph_events/node.py index f225798d41..52345ece82 100644 --- a/api/core/workflow/graph_events/node.py +++ b/api/core/workflow/graph_events/node.py @@ -20,6 +20,12 @@ class NodeRunStartedEvent(GraphNodeEventBase): provider_type: str = "" provider_id: str = "" + # Virtual node fields for extraction + is_virtual: bool = False + parent_node_id: str | None = None + extraction_source: str | None = None # e.g., "llm1.context" + extraction_prompt: str | None = None + class NodeRunStreamChunkEvent(GraphNodeEventBase): # Spec-compliant fields diff --git a/api/core/workflow/nodes/base/__init__.py b/api/core/workflow/nodes/base/__init__.py index f83df0e323..e6cde91bea 100644 --- a/api/core/workflow/nodes/base/__init__.py +++ b/api/core/workflow/nodes/base/__init__.py @@ -1,5 +1,13 @@ -from .entities import BaseIterationNodeData, BaseIterationState, BaseLoopNodeData, BaseLoopState, BaseNodeData +from .entities import ( + BaseIterationNodeData, + BaseIterationState, + BaseLoopNodeData, + BaseLoopState, + BaseNodeData, + VirtualNodeConfig, +) from .usage_tracking_mixin import LLMUsageTrackingMixin +from .virtual_node_executor import VirtualNodeExecutionError, VirtualNodeExecutor __all__ = [ "BaseIterationNodeData", @@ -8,4 +16,7 @@ __all__ = [ "BaseLoopState", "BaseNodeData", "LLMUsageTrackingMixin", + "VirtualNodeConfig", + "VirtualNodeExecutionError", + "VirtualNodeExecutor", ] diff --git a/api/core/workflow/nodes/base/entities.py b/api/core/workflow/nodes/base/entities.py index e5a20c8e91..41469d6ee8 100644 --- a/api/core/workflow/nodes/base/entities.py +++ b/api/core/workflow/nodes/base/entities.py @@ -167,6 +167,24 @@ class DefaultValue(BaseModel): return self +class VirtualNodeConfig(BaseModel): + """Configuration for a virtual sub-node embedded within a parent node.""" + + # Local ID within parent node (e.g., "ext_1") + # Will be converted to global ID: "{parent_id}.{id}" + id: str + + # Node type (e.g., "llm", "code", "tool") + type: str + + # Full node data configuration + data: dict[str, Any] = {} + + def get_global_id(self, parent_node_id: str) -> str: + """Get the global node ID by combining parent ID and local ID.""" + return f"{parent_node_id}.{self.id}" + + class BaseNodeData(ABC, BaseModel): title: str desc: str | None = None @@ -175,6 +193,9 @@ class BaseNodeData(ABC, BaseModel): default_value: list[DefaultValue] | None = None retry_config: RetryConfig = RetryConfig() + # Virtual sub-nodes that execute before the main node + virtual_nodes: list[VirtualNodeConfig] = [] + @property def default_value_dict(self) -> dict[str, Any]: if self.default_value: diff --git a/api/core/workflow/nodes/base/node.py b/api/core/workflow/nodes/base/node.py index 55c8db40ea..d49910c9fb 100644 --- a/api/core/workflow/nodes/base/node.py +++ b/api/core/workflow/nodes/base/node.py @@ -229,6 +229,7 @@ class Node(Generic[NodeDataT]): self._node_id = node_id self._node_execution_id: str = "" self._start_at = naive_utc_now() + self._virtual_node_outputs: dict[str, Any] = {} # Outputs from virtual sub-nodes raw_node_data = config.get("data") or {} if not isinstance(raw_node_data, Mapping): @@ -270,10 +271,52 @@ class Node(Generic[NodeDataT]): """Check if execution should be stopped.""" return self.graph_runtime_state.stop_event.is_set() + def _execute_virtual_nodes(self) -> Generator[GraphNodeEventBase, None, dict[str, Any]]: + """ + Execute all virtual sub-nodes defined in node configuration. + + Virtual nodes are complete node definitions that execute before the main node. + Each virtual node: + - Has its own global ID: "{parent_id}.{local_id}" + - Generates standard node events + - Stores outputs in the variable pool (via event handling) + - Supports retry via parent node's retry config + + Returns: + dict mapping local_id -> outputs dict + """ + from .virtual_node_executor import VirtualNodeExecutor + + virtual_nodes = self.node_data.virtual_nodes + if not virtual_nodes: + return {} + + executor = VirtualNodeExecutor( + graph_init_params=self._graph_init_params, + graph_runtime_state=self.graph_runtime_state, + parent_node_id=self._node_id, + parent_retry_config=self.retry_config, + ) + + return (yield from executor.execute_virtual_nodes(virtual_nodes)) + + @property + def virtual_node_outputs(self) -> dict[str, Any]: + """ + Get the outputs from virtual sub-nodes. + + Returns: + dict mapping local_id -> outputs dict + """ + return self._virtual_node_outputs + def run(self) -> Generator[GraphNodeEventBase, None, None]: execution_id = self.ensure_execution_id() self._start_at = naive_utc_now() + # Step 1: Execute virtual sub-nodes before main node execution + self._virtual_node_outputs = yield from self._execute_virtual_nodes() + # Create and push start event with required fields start_event = NodeRunStartedEvent( id=execution_id, diff --git a/api/core/workflow/nodes/base/virtual_node_executor.py b/api/core/workflow/nodes/base/virtual_node_executor.py new file mode 100644 index 0000000000..3f3b8f1f99 --- /dev/null +++ b/api/core/workflow/nodes/base/virtual_node_executor.py @@ -0,0 +1,213 @@ +""" +Virtual Node Executor for running embedded sub-nodes within a parent node. + +This module handles the execution of virtual nodes defined in a parent node's +`virtual_nodes` configuration. Virtual nodes are complete node definitions +that execute before the parent node. + +Example configuration: + virtual_nodes: + - id: ext_1 + type: llm + data: + model: {...} + prompt_template: [...] +""" + +import time +from collections.abc import Generator +from typing import TYPE_CHECKING, Any +from uuid import uuid4 + +from core.workflow.enums import NodeType +from core.workflow.graph_events import ( + GraphNodeEventBase, + NodeRunFailedEvent, + NodeRunRetryEvent, + NodeRunStartedEvent, + NodeRunSucceededEvent, +) +from libs.datetime_utils import naive_utc_now + +from .entities import RetryConfig, VirtualNodeConfig + +if TYPE_CHECKING: + from core.workflow.entities import GraphInitParams + from core.workflow.runtime import GraphRuntimeState + + +class VirtualNodeExecutionError(Exception): + """Error during virtual node execution""" + + def __init__(self, node_id: str, original_error: Exception): + self.node_id = node_id + self.original_error = original_error + super().__init__(f"Virtual node {node_id} execution failed: {original_error}") + + +class VirtualNodeExecutor: + """ + Executes virtual sub-nodes embedded within a parent node. + + Virtual nodes are complete node definitions that execute before the parent node. + Each virtual node: + - Has its own global ID: "{parent_id}.{local_id}" + - Generates standard node events + - Stores outputs in the variable pool + - Supports retry via parent node's retry config + """ + + def __init__( + self, + *, + graph_init_params: "GraphInitParams", + graph_runtime_state: "GraphRuntimeState", + parent_node_id: str, + parent_retry_config: RetryConfig | None = None, + ): + self._graph_init_params = graph_init_params + self._graph_runtime_state = graph_runtime_state + self._parent_node_id = parent_node_id + self._parent_retry_config = parent_retry_config or RetryConfig() + + def execute_virtual_nodes( + self, + virtual_nodes: list[VirtualNodeConfig], + ) -> Generator[GraphNodeEventBase, None, dict[str, Any]]: + """ + Execute all virtual nodes in order. + + Args: + virtual_nodes: List of virtual node configurations + + Yields: + Node events from each virtual node execution + + Returns: + dict mapping local_id -> outputs dict + """ + results: dict[str, Any] = {} + + for vnode_config in virtual_nodes: + global_id = vnode_config.get_global_id(self._parent_node_id) + + # Execute with retry + outputs = yield from self._execute_with_retry(vnode_config, global_id) + results[vnode_config.id] = outputs + + return results + + def _execute_with_retry( + self, + vnode_config: VirtualNodeConfig, + global_id: str, + ) -> Generator[GraphNodeEventBase, None, dict[str, Any]]: + """ + Execute virtual node with retry support. + """ + retry_config = self._parent_retry_config + last_error: Exception | None = None + + for attempt in range(retry_config.max_retries + 1): + try: + return (yield from self._execute_single_node(vnode_config, global_id)) + except Exception as e: + last_error = e + + if attempt < retry_config.max_retries: + # Yield retry event + yield NodeRunRetryEvent( + id=str(uuid4()), + node_id=global_id, + node_type=self._get_node_type(vnode_config.type), + node_title=vnode_config.data.get("title", f"Virtual: {vnode_config.id}"), + start_at=naive_utc_now(), + error=str(e), + retry_index=attempt + 1, + ) + + time.sleep(retry_config.retry_interval_seconds) + continue + + raise VirtualNodeExecutionError(global_id, e) from e + + raise last_error or VirtualNodeExecutionError(global_id, Exception("Unknown error")) + + def _execute_single_node( + self, + vnode_config: VirtualNodeConfig, + global_id: str, + ) -> Generator[GraphNodeEventBase, None, dict[str, Any]]: + """ + Execute a single virtual node by instantiating and running it. + """ + from core.workflow.nodes.node_mapping import LATEST_VERSION, NODE_TYPE_CLASSES_MAPPING + + # Build node config + node_config: dict[str, Any] = { + "id": global_id, + "data": { + **vnode_config.data, + "title": vnode_config.data.get("title", f"Virtual: {vnode_config.id}"), + }, + } + + # Get the node class for this type + node_type = self._get_node_type(vnode_config.type) + node_mapping = NODE_TYPE_CLASSES_MAPPING.get(node_type) + if not node_mapping: + raise ValueError(f"No class mapping found for node type: {node_type}") + + node_version = str(vnode_config.data.get("version", "1")) + node_cls = node_mapping.get(node_version) or node_mapping.get(LATEST_VERSION) + if not node_cls: + raise ValueError(f"No class found for node type: {node_type}") + + # Instantiate the node + node = node_cls( + id=global_id, + config=node_config, + graph_init_params=self._graph_init_params, + graph_runtime_state=self._graph_runtime_state, + ) + + # Run and collect events + outputs: dict[str, Any] = {} + + for event in node.run(): + # Mark event as coming from virtual node + self._mark_event_as_virtual(event, vnode_config) + yield event + + if isinstance(event, NodeRunSucceededEvent): + outputs = event.node_run_result.outputs or {} + elif isinstance(event, NodeRunFailedEvent): + raise Exception(event.error or "Virtual node execution failed") + + return outputs + + def _mark_event_as_virtual( + self, + event: GraphNodeEventBase, + vnode_config: VirtualNodeConfig, + ) -> None: + """Mark event as coming from a virtual node.""" + if isinstance(event, NodeRunStartedEvent): + event.is_virtual = True + event.parent_node_id = self._parent_node_id + + def _get_node_type(self, type_str: str) -> NodeType: + """Convert type string to NodeType enum.""" + type_mapping = { + "llm": NodeType.LLM, + "code": NodeType.CODE, + "tool": NodeType.TOOL, + "if-else": NodeType.IF_ELSE, + "question-classifier": NodeType.QUESTION_CLASSIFIER, + "parameter-extractor": NodeType.PARAMETER_EXTRACTOR, + "template-transform": NodeType.TEMPLATE_TRANSFORM, + "variable-assigner": NodeType.VARIABLE_ASSIGNER, + "http-request": NodeType.HTTP_REQUEST, + "knowledge-retrieval": NodeType.KNOWLEDGE_RETRIEVAL, + } + return type_mapping.get(type_str, NodeType.LLM) diff --git a/api/core/workflow/nodes/tool/tool_node.py b/api/core/workflow/nodes/tool/tool_node.py index 2e7ec757b4..0ba58a9560 100644 --- a/api/core/workflow/nodes/tool/tool_node.py +++ b/api/core/workflow/nodes/tool/tool_node.py @@ -89,18 +89,20 @@ class ToolNode(Node[ToolNodeData]): ) return - # get parameters + # get parameters (use virtual_node_outputs from base class) tool_parameters = tool_runtime.get_merged_runtime_parameters() or [] parameters = self._generate_parameters( tool_parameters=tool_parameters, variable_pool=self.graph_runtime_state.variable_pool, node_data=self.node_data, + virtual_node_outputs=self.virtual_node_outputs, ) parameters_for_log = self._generate_parameters( tool_parameters=tool_parameters, variable_pool=self.graph_runtime_state.variable_pool, node_data=self.node_data, for_log=True, + virtual_node_outputs=self.virtual_node_outputs, ) # get conversation id conversation_id = self.graph_runtime_state.variable_pool.get(["sys", SystemVariableKey.CONVERSATION_ID]) @@ -176,6 +178,7 @@ class ToolNode(Node[ToolNodeData]): variable_pool: "VariablePool", node_data: ToolNodeData, for_log: bool = False, + virtual_node_outputs: dict[str, Any] | None = None, ) -> dict[str, Any]: """ Generate parameters based on the given tool parameters, variable pool, and node data. @@ -184,12 +187,17 @@ class ToolNode(Node[ToolNodeData]): tool_parameters (Sequence[ToolParameter]): The list of tool parameters. variable_pool (VariablePool): The variable pool containing the variables. node_data (ToolNodeData): The data associated with the tool node. + for_log (bool): Whether to generate parameters for logging. + virtual_node_outputs (dict[str, Any] | None): Outputs from virtual sub-nodes. + Maps local_id -> outputs dict. Virtual node outputs are also in variable_pool + with global IDs like "{parent_id}.{local_id}". Returns: Mapping[str, Any]: A dictionary containing the generated parameters. """ tool_parameters_dictionary = {parameter.name: parameter for parameter in tool_parameters} + virtual_node_outputs = virtual_node_outputs or {} result: dict[str, Any] = {} for parameter_name in node_data.tool_parameters: @@ -199,14 +207,25 @@ class ToolNode(Node[ToolNodeData]): continue tool_input = node_data.tool_parameters[parameter_name] if tool_input.type == "variable": - variable = variable_pool.get(tool_input.value) - if variable is None: - if parameter.required: - raise ToolParameterError(f"Variable {tool_input.value} does not exist") - continue - parameter_value = variable.value + # Check if this references a virtual node output (local ID like [ext_1, text]) + selector = tool_input.value + if len(selector) >= 2 and selector[0] in virtual_node_outputs: + # Reference to virtual node output + local_id = selector[0] + var_name = selector[1] + outputs = virtual_node_outputs.get(local_id, {}) + parameter_value = outputs.get(var_name) + else: + # Normal variable reference + variable = variable_pool.get(selector) + if variable is None: + if parameter.required: + raise ToolParameterError(f"Variable {selector} does not exist") + continue + parameter_value = variable.value elif tool_input.type in {"mixed", "constant"}: - segment_group = variable_pool.convert_template(str(tool_input.value)) + template = str(tool_input.value) + segment_group = variable_pool.convert_template(template) parameter_value = segment_group.log if for_log else segment_group.text else: raise ToolParameterError(f"Unknown tool input type '{tool_input.type}'") diff --git a/api/tests/fixtures/pav-test-extraction.yml b/api/tests/fixtures/pav-test-extraction.yml new file mode 100644 index 0000000000..d1b9d55add --- /dev/null +++ b/api/tests/fixtures/pav-test-extraction.yml @@ -0,0 +1,266 @@ +app: + description: Test for variable extraction feature + icon: 🤖 + icon_background: '#FFEAD5' + mode: advanced-chat + name: pav-test-extraction + use_icon_as_answer_icon: false +dependencies: +- current_identifier: null + type: marketplace + value: + marketplace_plugin_unique_identifier: langgenius/google:0.0.8@3efcf55ffeef9d0f77715e0afb23534952ae0cb385c051d0637e86d71199d1a6 + version: null +- current_identifier: null + type: marketplace + value: + marketplace_plugin_unique_identifier: langgenius/tongyi:0.1.16@d8bffbe45418f0c117fb3393e5e40e61faee98f9a2183f062e5a280e74b15d21 + version: null +kind: app +version: 0.5.0 +workflow: + conversation_variables: [] + environment_variables: [] + features: + file_upload: + allowed_file_extensions: + - .JPG + - .JPEG + - .PNG + - .GIF + - .WEBP + - .SVG + allowed_file_types: + - image + allowed_file_upload_methods: + - local_file + - remote_url + enabled: false + image: + enabled: false + number_limits: 3 + transfer_methods: + - local_file + - remote_url + number_limits: 3 + opening_statement: 你好!我是一个搜索助手,请告诉我你想搜索什么内容。 + retriever_resource: + enabled: true + sensitive_word_avoidance: + enabled: false + speech_to_text: + enabled: false + suggested_questions: [] + suggested_questions_after_answer: + enabled: false + text_to_speech: + enabled: false + language: '' + voice: '' + graph: + edges: + - data: + sourceType: start + targetType: llm + id: 1767773675796-llm + source: '1767773675796' + sourceHandle: source + target: llm + targetHandle: target + type: custom + - data: + isInIteration: false + isInLoop: false + sourceType: llm + targetType: tool + id: llm-source-1767773709491-target + source: llm + sourceHandle: source + target: '1767773709491' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: tool + targetType: answer + id: tool-source-answer-target + source: '1767773709491' + sourceHandle: source + target: answer + targetHandle: target + type: custom + zIndex: 0 + nodes: + - data: + selected: false + title: User Input + type: start + variables: [] + height: 73 + id: '1767773675796' + position: + x: 80 + y: 282 + positionAbsolute: + x: 80 + y: 282 + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + context: + enabled: false + variable_selector: [] + memory: + query_prompt_template: '' + role_prefix: + assistant: '' + user: '' + window: + enabled: true + size: 10 + model: + completion_params: + temperature: 0.7 + mode: chat + name: qwen-max + provider: langgenius/tongyi/tongyi + prompt_template: + - id: 11d06d15-914a-4915-a5b1-0e35ab4fba51 + role: system + text: '你是一个智能搜索助手。用户会告诉你他们想搜索的内容。 + + 请与用户进行对话,了解他们的搜索需求。 + + 当用户明确表达了想要搜索的内容后,你可以回复"好的,我来帮你搜索"。 + + ' + selected: false + title: LLM + type: llm + vision: + enabled: false + height: 88 + id: llm + position: + x: 380 + y: 282 + positionAbsolute: + x: 380 + y: 282 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + is_team_authorization: true + paramSchemas: + - auto_generate: null + default: null + form: llm + human_description: + en_US: used for searching + ja_JP: used for searching + pt_BR: used for searching + zh_Hans: 用于搜索网页内容 + label: + en_US: Query string + ja_JP: Query string + pt_BR: Query string + zh_Hans: 查询语句 + llm_description: key words for searching + max: null + min: null + name: query + options: [] + placeholder: null + precision: null + required: true + scope: null + template: null + type: string + params: + query: '' + plugin_id: langgenius/google + plugin_unique_identifier: langgenius/google:0.0.8@3efcf55ffeef9d0f77715e0afb23534952ae0cb385c051d0637e86d71199d1a6 + provider_icon: http://localhost:5001/console/api/workspaces/current/plugin/icon?tenant_id=7217e801-f6f5-49ec-8103-d7de97a4b98f&filename=1c5871163478957bac64c3fe33d72d003f767497d921c74b742aad27a8344a74.svg + provider_id: langgenius/google/google + provider_name: langgenius/google/google + provider_type: builtin + selected: false + title: GoogleSearch + tool_configurations: {} + tool_description: A tool for performing a Google SERP search and extracting + snippets and webpages.Input should be a search query. + tool_label: GoogleSearch + tool_name: google_search + tool_node_version: '2' + tool_parameters: + query: + type: variable + value: + - ext_1 + - text + type: tool + virtual_nodes: + - data: + model: + completion_params: + temperature: 0.7 + mode: chat + name: qwen-max + provider: langgenius/tongyi/tongyi + context: + enabled: false + prompt_template: + - role: user + text: '{{#llm.context#}}' + - role: user + text: 请从对话历史中提取用户想要搜索的关键词,只返回关键词本身,不要返回其他内容 + title: 提取搜索关键词 + id: ext_1 + type: llm + height: 52 + id: '1767773709491' + position: + x: 682 + y: 282 + positionAbsolute: + x: 682 + y: 282 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + answer: '搜索结果: + + {{#1767773709491.text#}} + + ' + selected: false + title: Answer + type: answer + height: 103 + id: answer + position: + x: 984 + y: 282 + positionAbsolute: + x: 984 + y: 282 + selected: true + sourcePosition: right + targetPosition: left + type: custom + width: 242 + viewport: + x: 151 + y: 141.5 + zoom: 1 + rag_pipeline_variables: [] diff --git a/api/tests/unit_tests/core/workflow/entities/test_virtual_node.py b/api/tests/unit_tests/core/workflow/entities/test_virtual_node.py new file mode 100644 index 0000000000..ffffccfa1b --- /dev/null +++ b/api/tests/unit_tests/core/workflow/entities/test_virtual_node.py @@ -0,0 +1,77 @@ +""" +Unit tests for virtual node configuration. +""" + +from core.workflow.nodes.base.entities import VirtualNodeConfig + + +class TestVirtualNodeConfig: + """Tests for VirtualNodeConfig entity.""" + + def test_create_basic_config(self): + """Test creating a basic virtual node config.""" + config = VirtualNodeConfig( + id="ext_1", + type="llm", + data={ + "title": "Extract keywords", + "model": {"provider": "openai", "name": "gpt-4o-mini"}, + }, + ) + + assert config.id == "ext_1" + assert config.type == "llm" + assert config.data["title"] == "Extract keywords" + + def test_get_global_id(self): + """Test generating global ID from parent ID.""" + config = VirtualNodeConfig( + id="ext_1", + type="llm", + data={}, + ) + + global_id = config.get_global_id("tool1") + assert global_id == "tool1.ext_1" + + def test_get_global_id_with_different_parents(self): + """Test global ID generation with different parent IDs.""" + config = VirtualNodeConfig(id="sub_node", type="code", data={}) + + assert config.get_global_id("parent1") == "parent1.sub_node" + assert config.get_global_id("node_123") == "node_123.sub_node" + + def test_empty_data(self): + """Test virtual node config with empty data.""" + config = VirtualNodeConfig( + id="test", + type="tool", + ) + + assert config.id == "test" + assert config.type == "tool" + assert config.data == {} + + def test_complex_data(self): + """Test virtual node config with complex data.""" + config = VirtualNodeConfig( + id="llm_1", + type="llm", + data={ + "title": "Generate summary", + "model": { + "provider": "openai", + "name": "gpt-4", + "mode": "chat", + "completion_params": {"temperature": 0.7, "max_tokens": 500}, + }, + "prompt_template": [ + {"role": "user", "text": "{{#llm1.context#}}"}, + {"role": "user", "text": "Please summarize the conversation"}, + ], + }, + ) + + assert config.data["model"]["provider"] == "openai" + assert len(config.data["prompt_template"]) == 2 + diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-finished.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-finished.ts index cf0d9bcef1..c18ab909a9 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-finished.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-finished.ts @@ -37,7 +37,10 @@ export const useWorkflowNodeFinished = () => { })) const newNodes = produce(nodes, (draft) => { - const currentNode = draft.find(node => node.id === data.node_id)! + const currentNode = draft.find(node => node.id === data.node_id) + // Skip if node not found (e.g., virtual extraction nodes) + if (!currentNode) + return currentNode.data._runningStatus = data.status if (data.status === NodeRunningStatus.Exception) { if (data.execution_metadata?.error_strategy === ErrorHandleTypeEnum.failBranch) diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-started.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-started.ts index 03c7387d38..282e35fbd6 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-started.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-started.ts @@ -45,6 +45,11 @@ export const useWorkflowNodeStarted = () => { } = reactflow const currentNodeIndex = nodes.findIndex(node => node.id === data.node_id) const currentNode = nodes[currentNodeIndex] + + // Skip if node not found (e.g., virtual extraction nodes) + if (!currentNode) + return + const position = currentNode.position const zoom = transform[2] From 8b0bc6937dd6acbeac195e5c31e3a105ef9e30fb Mon Sep 17 00:00:00 2001 From: zhsama Date: Thu, 8 Jan 2026 18:17:09 +0800 Subject: [PATCH 26/82] feat: enhance component picker and workflow variable block functionality --- .../plugins/component-picker-block/index.tsx | 17 ++++++++++++++++- .../workflow-variable-block/component.tsx | 5 +++++ .../mixed-variable-text-input/index.tsx | 14 +++++++------- 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx b/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx index 8d08e87c75..4731f9abe7 100644 --- a/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx +++ b/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx @@ -21,7 +21,11 @@ import { } from '@floating-ui/react' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { LexicalTypeaheadMenuPlugin } from '@lexical/react/LexicalTypeaheadMenuPlugin' -import { KEY_ESCAPE_COMMAND } from 'lexical' +import { + $getRoot, + $insertNodes, + KEY_ESCAPE_COMMAND, +} from 'lexical' import { Fragment, memo, @@ -41,6 +45,7 @@ import { INSERT_ERROR_MESSAGE_BLOCK_COMMAND } from '../error-message-block' import { INSERT_LAST_RUN_BLOCK_COMMAND } from '../last-run-block' import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from '../variable-block' import { INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND } from '../workflow-variable-block' +import { $createWorkflowVariableBlockNode } from '../workflow-variable-block/node' import { useOptions } from './hooks' type ComponentPickerProps = { @@ -161,6 +166,16 @@ const ComponentPicker = ({ const needRemove = $splitNodeContainingQuery(checkForTriggerMatch(triggerString, editor)!) if (needRemove) needRemove.remove() + + const root = $getRoot() + const firstChild = root.getFirstChild() + if (firstChild) { + const selection = firstChild.selectStart() + if (selection) { + const workflowVariableBlockNode = $createWorkflowVariableBlockNode([agent.id, 'text'], {}, undefined) + $insertNodes([workflowVariableBlockNode]) + } + } }) agentBlock?.onSelect?.(agent) handleClose() diff --git a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/component.tsx b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/component.tsx index bdeb30d9b6..4ba321e92a 100644 --- a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/component.tsx +++ b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/component.tsx @@ -21,6 +21,7 @@ import { VariableLabelInEditor, } from '@/app/components/workflow/nodes/_base/components/variable/variable-label' import { Type } from '@/app/components/workflow/nodes/llm/types' +import { BlockEnum } from '@/app/components/workflow/types' import { isExceptionVariable } from '@/app/components/workflow/utils' import { useSelectOrDelete } from '../../hooks' import { @@ -66,6 +67,7 @@ const WorkflowVariableBlockComponent = ({ )() const [localWorkflowNodesMap, setLocalWorkflowNodesMap] = useState(workflowNodesMap) const node = localWorkflowNodesMap![variables[isRagVar ? 1 : 0]] + const isAgentVariable = node?.type === BlockEnum.Agent const isException = isExceptionVariable(varName, node?.type) const variableValid = useMemo(() => { @@ -134,6 +136,9 @@ const WorkflowVariableBlockComponent = ({ }) }, [node, reactflow, store]) + if (isAgentVariable) + return + const Item = ( (null) + useEffect(() => { + if (!detectedAgentFromValue && selectedAgent) + setSelectedAgent(null) + }, [detectedAgentFromValue, selectedAgent]) + const agentNodes = useMemo(() => { return availableNodes .filter(node => node.data.type === BlockEnum.Agent) @@ -89,13 +95,7 @@ const MixedVariableTextInput = ({ const handleAgentSelect = useCallback((agent: { id: string, title: string }) => { setSelectedAgent(agent) - if (onChange) { - const agentVar = `{{#${agent.id}.text#}}` - const newValue = value ? `${agentVar}${value}` : agentVar - onChange(newValue) - setControlPromptEditorRerenderKey(Date.now()) - } - }, [onChange, value, setControlPromptEditorRerenderKey]) + }, []) const handleAgentRemove = useCallback(() => { const agentNodeId = detectedAgentFromValue?.nodeId || selectedAgent?.id From cab7cd37b891ec6d507beb8ea45a2d4ee8d6cbc6 Mon Sep 17 00:00:00 2001 From: zhsama Date: Mon, 12 Jan 2026 14:56:53 +0800 Subject: [PATCH 27/82] feat: Add sub-graph component for workflow --- .../components/sub-graph-children.tsx | 57 ++++++++ .../sub-graph/components/sub-graph-main.tsx | 107 +++++++++++++++ web/app/components/sub-graph/hooks/index.ts | 5 + .../hooks/use-available-nodes-meta-data.ts | 43 ++++++ .../sub-graph/hooks/use-sub-graph-init.ts | 90 ++++++++++++ .../sub-graph/hooks/use-sub-graph-nodes.ts | 20 +++ .../hooks/use-sub-graph-persistence.ts | 128 ++++++++++++++++++ web/app/components/sub-graph/index.tsx | 57 ++++++++ web/app/components/sub-graph/store/index.ts | 49 +++++++ web/app/components/sub-graph/types.ts | 65 +++++++++ .../_base/components/form-input-item.tsx | 2 + .../mixed-variable-text-input/index.tsx | 40 ++++-- .../sub-graph-modal/config-panel.tsx | 83 ++++++++++++ .../tool/components/sub-graph-modal/index.tsx | 72 ++++++++++ .../tool/components/sub-graph-modal/store.ts | 49 +++++++ .../sub-graph-modal/sub-graph-canvas.tsx | 27 ++++ .../tool/components/sub-graph-modal/types.ts | 103 ++++++++++++++ web/i18n/en-US/workflow.json | 16 ++- web/i18n/ja-JP/workflow.json | 16 ++- web/i18n/zh-Hans/workflow.json | 18 ++- web/i18n/zh-Hant/workflow.json | 18 ++- 21 files changed, 1046 insertions(+), 19 deletions(-) create mode 100644 web/app/components/sub-graph/components/sub-graph-children.tsx create mode 100644 web/app/components/sub-graph/components/sub-graph-main.tsx create mode 100644 web/app/components/sub-graph/hooks/index.ts create mode 100644 web/app/components/sub-graph/hooks/use-available-nodes-meta-data.ts create mode 100644 web/app/components/sub-graph/hooks/use-sub-graph-init.ts create mode 100644 web/app/components/sub-graph/hooks/use-sub-graph-nodes.ts create mode 100644 web/app/components/sub-graph/hooks/use-sub-graph-persistence.ts create mode 100644 web/app/components/sub-graph/index.tsx create mode 100644 web/app/components/sub-graph/store/index.ts create mode 100644 web/app/components/sub-graph/types.ts create mode 100644 web/app/components/workflow/nodes/tool/components/sub-graph-modal/config-panel.tsx create mode 100644 web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx create mode 100644 web/app/components/workflow/nodes/tool/components/sub-graph-modal/store.ts create mode 100644 web/app/components/workflow/nodes/tool/components/sub-graph-modal/sub-graph-canvas.tsx create mode 100644 web/app/components/workflow/nodes/tool/components/sub-graph-modal/types.ts diff --git a/web/app/components/sub-graph/components/sub-graph-children.tsx b/web/app/components/sub-graph/components/sub-graph-children.tsx new file mode 100644 index 0000000000..ec1f9ee4d6 --- /dev/null +++ b/web/app/components/sub-graph/components/sub-graph-children.tsx @@ -0,0 +1,57 @@ +import type { FC } from 'react' +import type { SubGraphConfig } from '../types' +import { memo, useMemo } from 'react' +import { useStore as useReactFlowStore } from 'reactflow' +import { useShallow } from 'zustand/react/shallow' +import { Panel as NodePanel } from '@/app/components/workflow/nodes' + +type SubGraphChildrenProps = { + toolNodeId: string + paramKey: string + onConfigChange: (config: Partial) => void +} + +const SubGraphChildren: FC = ({ + toolNodeId: _toolNodeId, + paramKey: _paramKey, + onConfigChange: _onConfigChange, +}) => { + const selectedNode = useReactFlowStore(useShallow((s) => { + const nodes = s.getNodes() + const currentNode = nodes.find(node => node.data.selected) + + if (currentNode) { + return { + id: currentNode.id, + type: currentNode.type, + data: currentNode.data, + } + } + return null + })) + + const nodePanel = useMemo(() => { + if (!selectedNode) + return null + + return ( + + ) + }, [selectedNode]) + + return ( +
+ {nodePanel && ( +
+ {nodePanel} +
+ )} +
+ ) +} + +export default memo(SubGraphChildren) diff --git a/web/app/components/sub-graph/components/sub-graph-main.tsx b/web/app/components/sub-graph/components/sub-graph-main.tsx new file mode 100644 index 0000000000..efd1009692 --- /dev/null +++ b/web/app/components/sub-graph/components/sub-graph-main.tsx @@ -0,0 +1,107 @@ +import type { FC } from 'react' +import type { Viewport } from 'reactflow' +import type { SubGraphConfig } from '../types' +import type { Edge, Node } from '@/app/components/workflow/types' +import { useCallback, useMemo } from 'react' +import { WorkflowWithInnerContext } from '@/app/components/workflow' +import { useAvailableNodesMetaData, useSubGraphPersistence } from '../hooks' +import SubGraphChildren from './sub-graph-children' + +type SubGraphMainProps = { + nodes: Node[] + edges: Edge[] + viewport: Viewport + toolNodeId: string + paramKey: string +} + +const SubGraphMain: FC = ({ + nodes, + edges, + viewport, + toolNodeId, + paramKey, +}) => { + const availableNodesMetaData = useAvailableNodesMetaData() + const { + saveSubGraphData, + loadSubGraphData, + updateSubGraphConfig, + } = useSubGraphPersistence({ toolNodeId, paramKey }) + + const handleNodesChange = useCallback((updatedNodes: Node[]) => { + const existingData = loadSubGraphData() + const defaultConfig: SubGraphConfig = { + enabled: true, + startNodeId: updatedNodes[0]?.id || '', + selectedOutputVar: [], + whenOutputNone: 'skip', + } + + saveSubGraphData({ + nodes: updatedNodes, + edges, + config: existingData?.config || defaultConfig, + }) + }, [edges, loadSubGraphData, saveSubGraphData]) + + const hooksStore = useMemo(() => { + return { + availableNodesMetaData, + doSyncWorkflowDraft: async () => { + handleNodesChange(nodes) + }, + syncWorkflowDraftWhenPageClose: () => { + handleNodesChange(nodes) + }, + handleRefreshWorkflowDraft: () => {}, + handleBackupDraft: () => {}, + handleLoadBackupDraft: () => {}, + handleRestoreFromPublishedWorkflow: () => {}, + handleRun: () => {}, + handleStopRun: () => {}, + handleStartWorkflowRun: () => {}, + handleWorkflowStartRunInWorkflow: () => {}, + handleWorkflowStartRunInChatflow: () => {}, + handleWorkflowTriggerScheduleRunInWorkflow: () => {}, + handleWorkflowTriggerWebhookRunInWorkflow: () => {}, + handleWorkflowTriggerPluginRunInWorkflow: () => {}, + handleWorkflowRunAllTriggersInWorkflow: () => {}, + getWorkflowRunAndTraceUrl: () => ({ runUrl: '', traceUrl: '' }), + exportCheck: async () => {}, + handleExportDSL: async () => {}, + fetchInspectVars: async () => {}, + hasNodeInspectVars: () => false, + hasSetInspectVar: () => false, + fetchInspectVarValue: async () => {}, + editInspectVarValue: async () => {}, + renameInspectVarName: async () => {}, + appendNodeInspectVars: () => {}, + deleteInspectVar: async () => {}, + deleteNodeInspectorVars: async () => {}, + deleteAllInspectorVars: async () => {}, + isInspectVarEdited: () => false, + resetToLastRunVar: async () => {}, + invalidateSysVarValues: () => {}, + resetConversationVar: async () => {}, + invalidateConversationVarValues: () => {}, + } + }, [availableNodesMetaData, handleNodesChange, nodes]) + + return ( + + + + ) +} + +export default SubGraphMain diff --git a/web/app/components/sub-graph/hooks/index.ts b/web/app/components/sub-graph/hooks/index.ts new file mode 100644 index 0000000000..d67a22f2cc --- /dev/null +++ b/web/app/components/sub-graph/hooks/index.ts @@ -0,0 +1,5 @@ +export { useAvailableNodesMetaData } from './use-available-nodes-meta-data' +export { useSubGraphInit } from './use-sub-graph-init' +export { useSubGraphNodes } from './use-sub-graph-nodes' +export { useSubGraphPersistence } from './use-sub-graph-persistence' +export type { SubGraphData } from './use-sub-graph-persistence' 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 new file mode 100644 index 0000000000..f9a843e7a4 --- /dev/null +++ b/web/app/components/sub-graph/hooks/use-available-nodes-meta-data.ts @@ -0,0 +1,43 @@ +import type { AvailableNodesMetaData } from '@/app/components/workflow/hooks-store/store' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { WORKFLOW_COMMON_NODES } from '@/app/components/workflow/constants/node' +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: { + ...metaData, + title, + description, + }, + defaultValue: { + ...node.defaultValue, + type: metaData.type, + title, + }, + } + }), [t]) + + const availableNodesMetaDataMap = useMemo(() => availableNodesMetaData.reduce((acc, node) => { + acc![node.metaData.type] = node + return acc + }, {} as AvailableNodesMetaData['nodesMap']), [availableNodesMetaData]) + + return useMemo(() => { + return { + nodes: availableNodesMetaData, + nodesMap: { + ...availableNodesMetaDataMap, + [BlockEnum.VariableAssigner]: availableNodesMetaDataMap?.[BlockEnum.VariableAggregator], + }, + } + }, [availableNodesMetaData, availableNodesMetaDataMap]) +} diff --git a/web/app/components/sub-graph/hooks/use-sub-graph-init.ts b/web/app/components/sub-graph/hooks/use-sub-graph-init.ts new file mode 100644 index 0000000000..ba6f391a83 --- /dev/null +++ b/web/app/components/sub-graph/hooks/use-sub-graph-init.ts @@ -0,0 +1,90 @@ +import type { SubGraphProps } from '../types' +import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types' +import type { StartNodeType } from '@/app/components/workflow/nodes/start/types' +import type { Edge, Node } from '@/app/components/workflow/types' +import { useMemo } from 'react' +import { BlockEnum, PromptRole } from '@/app/components/workflow/types' +import { AppModeEnum } from '@/types/app' + +const SUBGRAPH_SOURCE_NODE_ID = 'subgraph-source' +const SUBGRAPH_LLM_NODE_ID = 'subgraph-llm' + +export const useSubGraphInit = (props: SubGraphProps) => { + const { sourceVariable, agentName } = props + + const initialNodes = useMemo((): Node[] => { + const sourceVarName = sourceVariable.length > 1 + ? sourceVariable.slice(1).join('.') + : 'output' + + const startNode: Node = { + id: SUBGRAPH_SOURCE_NODE_ID, + type: 'custom', + position: { x: 100, y: 150 }, + data: { + type: BlockEnum.Start, + title: `${agentName}: ${sourceVarName}`, + desc: 'Source variable from agent', + _connectedSourceHandleIds: ['source'], + _connectedTargetHandleIds: [], + variables: [], + }, + } + + const llmNode: Node = { + id: SUBGRAPH_LLM_NODE_ID, + type: 'custom', + position: { x: 450, y: 150 }, + data: { + type: BlockEnum.LLM, + title: 'LLM', + desc: 'Transform the output', + _connectedSourceHandleIds: [], + _connectedTargetHandleIds: ['target'], + model: { + provider: '', + name: '', + mode: AppModeEnum.CHAT, + completion_params: { + temperature: 0.7, + }, + }, + prompt_template: [{ + role: PromptRole.system, + text: '', + }], + context: { + enabled: false, + variable_selector: [], + }, + vision: { + enabled: false, + }, + }, + } + + return [startNode, llmNode] + }, [sourceVariable, agentName]) + + const initialEdges = useMemo((): Edge[] => { + return [ + { + id: `${SUBGRAPH_SOURCE_NODE_ID}-${SUBGRAPH_LLM_NODE_ID}`, + source: SUBGRAPH_SOURCE_NODE_ID, + sourceHandle: 'source', + target: SUBGRAPH_LLM_NODE_ID, + targetHandle: 'target', + type: 'custom', + data: { + sourceType: BlockEnum.Start, + targetType: BlockEnum.LLM, + }, + }, + ] + }, []) + + return { + initialNodes, + initialEdges, + } +} diff --git a/web/app/components/sub-graph/hooks/use-sub-graph-nodes.ts b/web/app/components/sub-graph/hooks/use-sub-graph-nodes.ts new file mode 100644 index 0000000000..c2a868f05e --- /dev/null +++ b/web/app/components/sub-graph/hooks/use-sub-graph-nodes.ts @@ -0,0 +1,20 @@ +import type { Edge, Node } from '@/app/components/workflow/types' +import { useMemo } from 'react' +import { initialEdges, initialNodes } from '@/app/components/workflow/utils' + +export const useSubGraphNodes = (nodes: Node[], edges: Edge[]) => { + const processedNodes = useMemo( + () => initialNodes(nodes, edges), + [nodes, edges], + ) + + const processedEdges = useMemo( + () => initialEdges(edges, nodes), + [edges, nodes], + ) + + return { + nodes: processedNodes, + edges: processedEdges, + } +} diff --git a/web/app/components/sub-graph/hooks/use-sub-graph-persistence.ts b/web/app/components/sub-graph/hooks/use-sub-graph-persistence.ts new file mode 100644 index 0000000000..f3fd97565d --- /dev/null +++ b/web/app/components/sub-graph/hooks/use-sub-graph-persistence.ts @@ -0,0 +1,128 @@ +import type { SubGraphConfig } from '../types' +import type { ToolNodeType } from '@/app/components/workflow/nodes/tool/types' +import type { Edge, Node } from '@/app/components/workflow/types' +import { useCallback } from 'react' +import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' +import { VarKindType } from '@/app/components/workflow/nodes/_base/types' + +type SubGraphPersistenceProps = { + toolNodeId: string + paramKey: string +} + +export type SubGraphData = { + nodes: Node[] + edges: Edge[] + config: SubGraphConfig +} + +const SUB_GRAPH_DATA_PREFIX = '__subgraph__' + +export const useSubGraphPersistence = ({ + toolNodeId, + paramKey, +}: SubGraphPersistenceProps) => { + const { inputs, setInputs } = useNodeCrud(toolNodeId, {} as ToolNodeType) + + const getSubGraphDataKey = useCallback(() => { + return `${SUB_GRAPH_DATA_PREFIX}${paramKey}` + }, [paramKey]) + + const loadSubGraphData = useCallback((): SubGraphData | null => { + const dataKey = getSubGraphDataKey() + const toolParameters = inputs.tool_parameters || {} + const storedData = toolParameters[dataKey] + + if (!storedData || storedData.type !== VarKindType.constant) { + return null + } + + try { + const parsed = typeof storedData.value === 'string' + ? JSON.parse(storedData.value) + : storedData.value + + return parsed as SubGraphData + } + catch { + return null + } + }, [getSubGraphDataKey, inputs.tool_parameters]) + + const saveSubGraphData = useCallback((data: SubGraphData) => { + const dataKey = getSubGraphDataKey() + const newToolParameters = { + ...inputs.tool_parameters, + [dataKey]: { + type: VarKindType.constant, + value: JSON.stringify(data), + }, + } + + setInputs({ + ...inputs, + tool_parameters: newToolParameters, + }) + }, [getSubGraphDataKey, inputs, setInputs]) + + const clearSubGraphData = useCallback(() => { + const dataKey = getSubGraphDataKey() + const newToolParameters = { ...inputs.tool_parameters } + delete newToolParameters[dataKey] + + setInputs({ + ...inputs, + tool_parameters: newToolParameters, + }) + }, [getSubGraphDataKey, inputs, setInputs]) + + const hasSubGraphData = useCallback(() => { + const dataKey = getSubGraphDataKey() + const toolParameters = inputs.tool_parameters || {} + return !!toolParameters[dataKey] + }, [getSubGraphDataKey, inputs.tool_parameters]) + + const updateSubGraphConfig = useCallback(( + config: Partial, + ) => { + const existingData = loadSubGraphData() + if (!existingData) + return + + saveSubGraphData({ + ...existingData, + config: { + ...existingData.config, + ...config, + }, + }) + }, [loadSubGraphData, saveSubGraphData]) + + const updateSubGraphNodes = useCallback(( + nodes: Node[], + edges: Edge[], + ) => { + const existingData = loadSubGraphData() + const defaultConfig: SubGraphConfig = { + enabled: true, + startNodeId: nodes[0]?.id || '', + selectedOutputVar: [], + whenOutputNone: 'skip', + } + + saveSubGraphData({ + nodes, + edges, + config: existingData?.config || defaultConfig, + }) + }, [loadSubGraphData, saveSubGraphData]) + + return { + loadSubGraphData, + saveSubGraphData, + clearSubGraphData, + hasSubGraphData, + updateSubGraphConfig, + updateSubGraphNodes, + } +} diff --git a/web/app/components/sub-graph/index.tsx b/web/app/components/sub-graph/index.tsx new file mode 100644 index 0000000000..7a97ec152a --- /dev/null +++ b/web/app/components/sub-graph/index.tsx @@ -0,0 +1,57 @@ +import type { FC } from 'react' +import type { Viewport } from 'reactflow' +import type { SubGraphProps } from './types' +import type { InjectWorkflowStoreSliceFn } from '@/app/components/workflow/store' +import { memo, useMemo } from 'react' +import WorkflowWithDefaultContext from '@/app/components/workflow' +import { WorkflowContextProvider } from '@/app/components/workflow/context' +import SubGraphMain from './components/sub-graph-main' +import { useSubGraphInit, useSubGraphNodes, useSubGraphPersistence } from './hooks' +import { createSubGraphSlice } from './store' + +const defaultViewport: Viewport = { + x: 50, + y: 50, + zoom: 1, +} + +const SubGraph: FC = (props) => { + const { toolNodeId, paramKey } = props + + const { loadSubGraphData } = useSubGraphPersistence({ toolNodeId, paramKey }) + const savedData = useMemo(() => loadSubGraphData(), [loadSubGraphData]) + + const { initialNodes, initialEdges } = useSubGraphInit(props) + + const nodesSource = savedData?.nodes || initialNodes + const edgesSource = savedData?.edges || initialEdges + + const { nodes, edges } = useSubGraphNodes(nodesSource, edgesSource) + + return ( + + + + ) +} + +const SubGraphWrapper: FC = (props) => { + return ( + + + + ) +} + +export default memo(SubGraphWrapper) diff --git a/web/app/components/sub-graph/store/index.ts b/web/app/components/sub-graph/store/index.ts new file mode 100644 index 0000000000..52accd4a21 --- /dev/null +++ b/web/app/components/sub-graph/store/index.ts @@ -0,0 +1,49 @@ +import type { CreateSubGraphSlice, SubGraphSliceShape } from '../types' + +const initialState: Omit = { + parentToolNodeId: '', + parameterKey: '', + sourceAgentNodeId: '', + sourceVariable: [], + + subGraphNodes: [], + subGraphEdges: [], + + selectedOutputVar: [], + whenOutputNone: 'skip', + defaultValue: '', + + showDebugPanel: false, + isRunning: false, + + parentAvailableVars: [], +} + +export const createSubGraphSlice: CreateSubGraphSlice = set => ({ + ...initialState, + + setSubGraphContext: context => set(() => ({ + parentToolNodeId: context.parentToolNodeId, + parameterKey: context.parameterKey, + sourceAgentNodeId: context.sourceAgentNodeId, + sourceVariable: context.sourceVariable, + })), + + setSubGraphNodes: nodes => set(() => ({ subGraphNodes: nodes })), + + setSubGraphEdges: edges => set(() => ({ subGraphEdges: edges })), + + setSelectedOutputVar: selector => set(() => ({ selectedOutputVar: selector })), + + setWhenOutputNone: option => set(() => ({ whenOutputNone: option })), + + setDefaultValue: value => set(() => ({ defaultValue: value })), + + setShowDebugPanel: show => set(() => ({ showDebugPanel: show })), + + setIsRunning: running => set(() => ({ isRunning: running })), + + setParentAvailableVars: vars => set(() => ({ parentAvailableVars: vars })), + + resetSubGraph: () => set(() => ({ ...initialState })), +}) diff --git a/web/app/components/sub-graph/types.ts b/web/app/components/sub-graph/types.ts new file mode 100644 index 0000000000..f9b376d5c9 --- /dev/null +++ b/web/app/components/sub-graph/types.ts @@ -0,0 +1,65 @@ +import type { StateCreator } from 'zustand' +import type { Edge, Node, NodeOutPutVar, ValueSelector, VarType } from '@/app/components/workflow/types' + +export type WhenOutputNoneOption = 'skip' | 'error' | 'default' + +export type SubGraphConfig = { + enabled: boolean + startNodeId: string + selectedOutputVar: ValueSelector + whenOutputNone: WhenOutputNoneOption + defaultValue?: string +} + +export type SubGraphOutputVariable = { + nodeId: string + nodeName: string + variable: string + type: VarType + description?: string +} + +export type SubGraphProps = { + toolNodeId: string + paramKey: string + sourceVariable: ValueSelector + agentNodeId: string + agentName: string +} + +export type SubGraphSliceShape = { + parentToolNodeId: string + parameterKey: string + sourceAgentNodeId: string + sourceVariable: ValueSelector + + subGraphNodes: Node[] + subGraphEdges: Edge[] + + selectedOutputVar: ValueSelector + whenOutputNone: WhenOutputNoneOption + defaultValue: string + + showDebugPanel: boolean + isRunning: boolean + + parentAvailableVars: NodeOutPutVar[] + + setSubGraphContext: (context: { + parentToolNodeId: string + parameterKey: string + sourceAgentNodeId: string + sourceVariable: ValueSelector + }) => void + setSubGraphNodes: (nodes: Node[]) => void + setSubGraphEdges: (edges: Edge[]) => void + setSelectedOutputVar: (selector: ValueSelector) => void + setWhenOutputNone: (option: WhenOutputNoneOption) => void + setDefaultValue: (value: string) => void + setShowDebugPanel: (show: boolean) => void + setIsRunning: (running: boolean) => void + setParentAvailableVars: (vars: NodeOutPutVar[]) => void + resetSubGraph: () => void +} + +export type CreateSubGraphSlice = StateCreator diff --git a/web/app/components/workflow/nodes/_base/components/form-input-item.tsx b/web/app/components/workflow/nodes/_base/components/form-input-item.tsx index 419f905fa5..caafd34e97 100644 --- a/web/app/components/workflow/nodes/_base/components/form-input-item.tsx +++ b/web/app/components/workflow/nodes/_base/components/form-input-item.tsx @@ -337,6 +337,8 @@ const FormInputItem: FC = ({ showManageInputField={showManageInputField} onManageInputField={onManageInputField} disableVariableInsertion={disableVariableInsertion} + toolNodeId={nodeId} + paramKey={variable} /> )} {isNumber && isConstant && ( diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx index 167cb41807..8dfb63c2e2 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx @@ -2,11 +2,11 @@ import type { AgentBlockType } from '@/app/components/base/prompt-editor/types' import type { Node, NodeOutPutVar, + ValueSelector, } from '@/app/components/workflow/types' import { memo, useCallback, - useEffect, useMemo, useState, } from 'react' @@ -15,6 +15,7 @@ import PromptEditor from '@/app/components/base/prompt-editor' import { useStore } from '@/app/components/workflow/store' import { BlockEnum } from '@/app/components/workflow/types' import { cn } from '@/utils/classnames' +import SubGraphModal from '../sub-graph-modal' import AgentHeaderBar from './agent-header-bar' import Placeholder from './placeholder' @@ -33,7 +34,8 @@ type MixedVariableTextInputProps = { showManageInputField?: boolean onManageInputField?: () => void disableVariableInsertion?: boolean - onViewInternals?: () => void + toolNodeId?: string + paramKey?: string } const MixedVariableTextInput = ({ @@ -45,11 +47,13 @@ const MixedVariableTextInput = ({ showManageInputField, onManageInputField, disableVariableInsertion = false, - onViewInternals, + toolNodeId, + paramKey = '', }: MixedVariableTextInputProps) => { const { t } = useTranslation() const controlPromptEditorRerenderKey = useStore(s => s.controlPromptEditorRerenderKey) const setControlPromptEditorRerenderKey = useStore(s => s.setControlPromptEditorRerenderKey) + const [isSubGraphModalOpen, setIsSubGraphModalOpen] = useState(false) const nodesByIdMap = useMemo(() => { return availableNodes.reduce((acc, node) => { @@ -79,11 +83,6 @@ const MixedVariableTextInput = ({ const [selectedAgent, setSelectedAgent] = useState<{ id: string, title: string } | null>(null) - useEffect(() => { - if (!detectedAgentFromValue && selectedAgent) - setSelectedAgent(null) - }, [detectedAgentFromValue, selectedAgent]) - const agentNodes = useMemo(() => { return availableNodes .filter(node => node.data.type === BlockEnum.Agent) @@ -115,6 +114,18 @@ const MixedVariableTextInput = ({ const displayedAgent = detectedAgentFromValue || (selectedAgent ? { nodeId: selectedAgent.id, name: selectedAgent.title } : null) + const handleOpenSubGraphModal = useCallback(() => { + setIsSubGraphModalOpen(true) + }, []) + + const handleCloseSubGraphModal = useCallback(() => { + setIsSubGraphModalOpen(false) + }, []) + + const sourceVariable: ValueSelector | undefined = displayedAgent + ? [displayedAgent.nodeId, 'text'] + : undefined + return (
)} } onChange={onChange} /> + {toolNodeId && displayedAgent && sourceVariable && ( + + )}
) } diff --git a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/config-panel.tsx b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/config-panel.tsx new file mode 100644 index 0000000000..acc9929ee1 --- /dev/null +++ b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/config-panel.tsx @@ -0,0 +1,83 @@ +'use client' +import type { FC } from 'react' +import type { ConfigPanelProps, WhenOutputNoneOption } from './types' +import { memo, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import Field from '@/app/components/workflow/nodes/_base/components/field' +import { cn } from '@/utils/classnames' + +const outputVariables = [ + { name: 'text', type: 'string' }, + { name: 'structured_output', type: 'object' }, +] + +const ConfigPanel: FC = ({ + toolNodeId: _toolNodeId, + paramKey: _paramKey, + activeTab, +}) => { + const { t } = useTranslation() + const [whenOutputNone, setWhenOutputNone] = useState('skip') + + const handleWhenOutputNoneChange = useCallback((e: React.ChangeEvent) => { + setWhenOutputNone(e.target.value as WhenOutputNoneOption) + }, []) + + if (activeTab === 'lastRun') { + return ( +
+
+

+ {t('subGraphModal.noRunHistory', { ns: 'workflow' })} +

+
+
+ ) + } + + return ( +
+ +
+ {outputVariables.map(variable => ( +
+ {variable.name} + {variable.type} +
+ ))} +
+
+ + + + +
+ ) +} + +export default memo(ConfigPanel) diff --git a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx new file mode 100644 index 0000000000..ee550b9c86 --- /dev/null +++ b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx @@ -0,0 +1,72 @@ +'use client' +import type { FC } from 'react' +import type { SubGraphModalProps } from './types' +import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react' +import { RiCloseLine } from '@remixicon/react' +import { noop } from 'es-toolkit/function' +import { Fragment, memo } from 'react' +import { useTranslation } from 'react-i18next' +import { Agent } from '@/app/components/base/icons/src/vender/workflow' +import SubGraphCanvas from './sub-graph-canvas' + +const SubGraphModal: FC = ({ + isOpen, + onClose, + toolNodeId, + paramKey, + sourceVariable, + agentName, + agentNodeId, +}) => { + const { t } = useTranslation() + + return ( + + + +
+ +
+
+ + +
+
+
+ +
+ + @ + {agentName} + {' '} + {t('subGraphModal.title', { ns: 'workflow' })} + +
+ +
+ +
+ +
+
+
+
+
+
+
+ ) +} + +export default memo(SubGraphModal) diff --git a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/store.ts b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/store.ts new file mode 100644 index 0000000000..63723459f0 --- /dev/null +++ b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/store.ts @@ -0,0 +1,49 @@ +import type { CreateSubGraphSlice, SubGraphSliceShape } from './types' + +const initialState: Omit = { + parentToolNodeId: '', + parameterKey: '', + sourceAgentNodeId: '', + sourceVariable: [], + + subGraphNodes: [], + subGraphEdges: [], + + selectedOutputVar: [], + whenOutputNone: 'skip', + defaultValue: '', + + showDebugPanel: false, + isRunning: false, + + parentAvailableVars: [], +} + +export const createSubGraphSlice: CreateSubGraphSlice = set => ({ + ...initialState, + + setSubGraphContext: context => set(() => ({ + parentToolNodeId: context.parentToolNodeId, + parameterKey: context.parameterKey, + sourceAgentNodeId: context.sourceAgentNodeId, + sourceVariable: context.sourceVariable, + })), + + setSubGraphNodes: nodes => set(() => ({ subGraphNodes: nodes })), + + setSubGraphEdges: edges => set(() => ({ subGraphEdges: edges })), + + setSelectedOutputVar: selector => set(() => ({ selectedOutputVar: selector })), + + setWhenOutputNone: option => set(() => ({ whenOutputNone: option })), + + setDefaultValue: value => set(() => ({ defaultValue: value })), + + setShowDebugPanel: show => set(() => ({ showDebugPanel: show })), + + setIsRunning: running => set(() => ({ isRunning: running })), + + setParentAvailableVars: vars => set(() => ({ parentAvailableVars: vars })), + + resetSubGraph: () => set(() => ({ ...initialState })), +}) diff --git a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/sub-graph-canvas.tsx b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/sub-graph-canvas.tsx new file mode 100644 index 0000000000..f13a48d87c --- /dev/null +++ b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/sub-graph-canvas.tsx @@ -0,0 +1,27 @@ +'use client' +import type { FC } from 'react' +import type { SubGraphCanvasProps } from './types' +import { memo } from 'react' +import SubGraph from '@/app/components/sub-graph' + +const SubGraphCanvas: FC = ({ + toolNodeId, + paramKey, + sourceVariable, + agentNodeId, + agentName, +}) => { + return ( +
+ +
+ ) +} + +export default memo(SubGraphCanvas) diff --git a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/types.ts b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/types.ts new file mode 100644 index 0000000000..a8ce0496be --- /dev/null +++ b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/types.ts @@ -0,0 +1,103 @@ +import type { StateCreator } from 'zustand' +import type { Edge, Node, NodeOutPutVar, ValueSelector, VarType } from '@/app/components/workflow/types' + +export type SubGraphNodeData = { + isInSubGraph: boolean + subGraph_id: string + subGraphParamKey: string +} + +export type SubGraphNode = Node & { + data: Node['data'] & SubGraphNodeData +} + +export type SubGraphSourceNodeData = { + title: string + sourceAgentNodeId: string + sourceVariable: ValueSelector + sourceVarType: VarType + isReadOnly: true + isInSubGraph: true + subGraph_id: string + subGraphParamKey: string +} + +export type WhenOutputNoneOption = 'skip' | 'error' | 'default' + +export type SubGraphConfig = { + enabled: boolean + startNodeId: string + selectedOutputVar: ValueSelector + whenOutputNone: WhenOutputNoneOption + defaultValue?: string +} + +export type SubGraphOutputVariable = { + nodeId: string + nodeName: string + variable: string + type: VarType + description?: string +} + +export type SubGraphModalProps = { + isOpen: boolean + onClose: () => void + toolNodeId: string + paramKey: string + sourceVariable: ValueSelector + agentName: string + agentNodeId: string +} + +export type ConfigPanelProps = { + toolNodeId: string + paramKey: string + activeTab: 'settings' | 'lastRun' + onTabChange: (tab: 'settings' | 'lastRun') => void +} + +export type SubGraphCanvasProps = { + toolNodeId: string + paramKey: string + sourceVariable: ValueSelector + agentNodeId: string + agentName: string +} + +export type SubGraphSliceShape = { + parentToolNodeId: string + parameterKey: string + sourceAgentNodeId: string + sourceVariable: ValueSelector + + subGraphNodes: SubGraphNode[] + subGraphEdges: Edge[] + + selectedOutputVar: ValueSelector + whenOutputNone: WhenOutputNoneOption + defaultValue: string + + showDebugPanel: boolean + isRunning: boolean + + parentAvailableVars: NodeOutPutVar[] + + setSubGraphContext: (context: { + parentToolNodeId: string + parameterKey: string + sourceAgentNodeId: string + sourceVariable: ValueSelector + }) => void + setSubGraphNodes: (nodes: SubGraphNode[]) => void + setSubGraphEdges: (edges: Edge[]) => void + setSelectedOutputVar: (selector: ValueSelector) => void + setWhenOutputNone: (option: WhenOutputNoneOption) => void + setDefaultValue: (value: string) => void + setShowDebugPanel: (show: boolean) => void + setIsRunning: (running: boolean) => void + setParentAvailableVars: (vars: NodeOutPutVar[]) => void + resetSubGraph: () => void +} + +export type CreateSubGraphSlice = StateCreator diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index 39bcee3050..686fe3f7cc 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -205,7 +205,7 @@ "common.runApp": "Run App", "common.runHistory": "Run History", "common.running": "Running", - "common.searchAgent": "Search agent", + "common.searchAgent": "Search agent...", "common.searchVar": "Search variable", "common.setVarValuePlaceholder": "Set variable", "common.showRunHistory": "Show Run History", @@ -217,7 +217,7 @@ "common.variableNamePlaceholder": "Variable name", "common.versionHistory": "Version History", "common.viewDetailInTracingPanel": "View details", - "common.viewInternals": "View internals", + "common.viewInternals": "View Internals", "common.viewOnly": "View Only", "common.viewRunHistory": "View run history", "common.workflowAsTool": "Workflow as Tool", @@ -988,6 +988,18 @@ "singleRun.testRun": "Test Run", "singleRun.testRunIteration": "Test Run Iteration", "singleRun.testRunLoop": "Test Run Loop", + "subGraphModal.canvasPlaceholder": "Click to configure the internal structure", + "subGraphModal.internalStructure": "Internal structure", + "subGraphModal.lastRun": "LAST RUN", + "subGraphModal.noRunHistory": "No run history yet", + "subGraphModal.outputVariables": "OUTPUT VARIABLES", + "subGraphModal.settings": "SETTINGS", + "subGraphModal.sourceNode": "SOURCE", + "subGraphModal.title": "INTERNAL STRUCTURE", + "subGraphModal.whenOutputIsNone": "WHEN OUTPUT IS NONE", + "subGraphModal.whenOutputNone.default": "Use default value", + "subGraphModal.whenOutputNone.error": "Raise an error", + "subGraphModal.whenOutputNone.skip": "Skip this step", "tabs.-": "Default", "tabs.addAll": "Add all", "tabs.agent": "Agent Strategy", diff --git a/web/i18n/ja-JP/workflow.json b/web/i18n/ja-JP/workflow.json index ed7abb48a3..1435f672e8 100644 --- a/web/i18n/ja-JP/workflow.json +++ b/web/i18n/ja-JP/workflow.json @@ -203,7 +203,7 @@ "common.runApp": "アプリを実行", "common.runHistory": "実行履歴", "common.running": "実行中", - "common.searchAgent": "エージェントを検索", + "common.searchAgent": "エージェントを検索...", "common.searchVar": "変数を検索", "common.setVarValuePlaceholder": "変数値を設定", "common.showRunHistory": "実行履歴を表示", @@ -215,7 +215,7 @@ "common.variableNamePlaceholder": "変数名を入力", "common.versionHistory": "バージョン履歴", "common.viewDetailInTracingPanel": "詳細を表示", - "common.viewInternals": "内部を表示", + "common.viewInternals": "内部構造を表示", "common.viewOnly": "閲覧のみ", "common.viewRunHistory": "実行履歴を表示", "common.workflowAsTool": "ワークフローをツールとして公開する", @@ -985,6 +985,18 @@ "singleRun.testRun": "テスト実行", "singleRun.testRunIteration": "テスト実行(イテレーション)", "singleRun.testRunLoop": "テスト実行ループ", + "subGraphModal.canvasPlaceholder": "クリックして内部構造を設定", + "subGraphModal.internalStructure": "内部構造", + "subGraphModal.lastRun": "前回の実行", + "subGraphModal.noRunHistory": "実行履歴がありません", + "subGraphModal.outputVariables": "出力変数", + "subGraphModal.settings": "設定", + "subGraphModal.sourceNode": "ソース", + "subGraphModal.title": "内部構造", + "subGraphModal.whenOutputIsNone": "出力が空の場合", + "subGraphModal.whenOutputNone.default": "デフォルト値を使用", + "subGraphModal.whenOutputNone.error": "エラーを発生させる", + "subGraphModal.whenOutputNone.skip": "このステップをスキップ", "tabs.-": "デフォルト", "tabs.addAll": "すべてを追加する", "tabs.agent": "エージェント戦略", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index 24be2d6c67..2b863487b0 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -171,7 +171,7 @@ "common.needConnectTip": "此节点尚未连接到其他节点", "common.needOutputNode": "必须添加输出节点", "common.needStartNode": "必须添加至少一个开始节点", - "common.noAgentNodes": "没有可用的代理节点", + "common.noAgentNodes": "没有可用的 Agent 节点", "common.noHistory": "没有历史版本", "common.noVar": "没有变量", "common.notRunning": "尚未运行", @@ -203,7 +203,7 @@ "common.runApp": "运行", "common.runHistory": "运行历史", "common.running": "运行中", - "common.searchAgent": "搜索代理", + "common.searchAgent": "搜索 Agent...", "common.searchVar": "搜索变量", "common.setVarValuePlaceholder": "设置变量值", "common.showRunHistory": "显示运行历史", @@ -215,7 +215,7 @@ "common.variableNamePlaceholder": "变量名", "common.versionHistory": "版本历史", "common.viewDetailInTracingPanel": "查看详细信息", - "common.viewInternals": "查看内部", + "common.viewInternals": "查看内部结构", "common.viewOnly": "只读", "common.viewRunHistory": "查看运行历史", "common.workflowAsTool": "发布为工具", @@ -986,6 +986,18 @@ "singleRun.testRun": "测试运行", "singleRun.testRunIteration": "测试运行迭代", "singleRun.testRunLoop": "测试运行循环", + "subGraphModal.canvasPlaceholder": "点击配置内部结构", + "subGraphModal.internalStructure": "内部结构", + "subGraphModal.lastRun": "上次运行", + "subGraphModal.noRunHistory": "暂无运行记录", + "subGraphModal.outputVariables": "输出变量", + "subGraphModal.settings": "设置", + "subGraphModal.sourceNode": "来源", + "subGraphModal.title": "内部结构", + "subGraphModal.whenOutputIsNone": "当输出为空时", + "subGraphModal.whenOutputNone.default": "使用默认值", + "subGraphModal.whenOutputNone.error": "抛出错误", + "subGraphModal.whenOutputNone.skip": "跳过此步骤", "tabs.-": "默认", "tabs.addAll": "添加全部", "tabs.agent": "Agent 策略", diff --git a/web/i18n/zh-Hant/workflow.json b/web/i18n/zh-Hant/workflow.json index e6f8e88fc5..11e91a7a9d 100644 --- a/web/i18n/zh-Hant/workflow.json +++ b/web/i18n/zh-Hant/workflow.json @@ -171,7 +171,7 @@ "common.needConnectTip": "此節點尚未連接到其他節點", "common.needOutputNode": "必須新增輸出節點", "common.needStartNode": "至少必須新增一個起始節點", - "common.noAgentNodes": "沒有可用的代理節點", + "common.noAgentNodes": "沒有可用的 Agent 節點", "common.noHistory": "無歷史記錄", "common.noVar": "沒有變數", "common.notRunning": "尚未運行", @@ -203,7 +203,7 @@ "common.runApp": "運行", "common.runHistory": "運行歷史", "common.running": "運行中", - "common.searchAgent": "搜索代理", + "common.searchAgent": "搜尋 Agent...", "common.searchVar": "搜索變數", "common.setVarValuePlaceholder": "設置變數值", "common.showRunHistory": "顯示運行歷史", @@ -215,7 +215,7 @@ "common.variableNamePlaceholder": "變數名", "common.versionHistory": "版本歷史", "common.viewDetailInTracingPanel": "查看詳細信息", - "common.viewInternals": "查看內部", + "common.viewInternals": "檢視內部結構", "common.viewOnly": "只讀", "common.viewRunHistory": "查看運行歷史", "common.workflowAsTool": "發佈為工具", @@ -985,6 +985,18 @@ "singleRun.testRun": "測試運行", "singleRun.testRunIteration": "測試運行迭代", "singleRun.testRunLoop": "測試運行循環", + "subGraphModal.canvasPlaceholder": "點擊配置內部結構", + "subGraphModal.internalStructure": "內部結構", + "subGraphModal.lastRun": "上次執行", + "subGraphModal.noRunHistory": "暫無執行記錄", + "subGraphModal.outputVariables": "輸出變數", + "subGraphModal.settings": "設定", + "subGraphModal.sourceNode": "來源", + "subGraphModal.title": "內部結構", + "subGraphModal.whenOutputIsNone": "當輸出為空時", + "subGraphModal.whenOutputNone.default": "使用預設值", + "subGraphModal.whenOutputNone.error": "拋出錯誤", + "subGraphModal.whenOutputNone.skip": "跳過此步驟", "tabs.-": "預設", "tabs.addAll": "全部新增", "tabs.agent": "代理策略", From d91087492d79b731e95a19e379d2bdb254246eba Mon Sep 17 00:00:00 2001 From: zhsama Date: Mon, 12 Jan 2026 15:00:41 +0800 Subject: [PATCH 28/82] Refactor sub-graph components structure --- .../components}/config-panel.tsx | 9 +- .../tool/components/sub-graph-modal/store.ts | 49 ----------- .../tool/components/sub-graph-modal/types.ts | 86 +------------------ 3 files changed, 9 insertions(+), 135 deletions(-) rename web/app/components/{workflow/nodes/tool/components/sub-graph-modal => sub-graph/components}/config-panel.tsx (92%) delete mode 100644 web/app/components/workflow/nodes/tool/components/sub-graph-modal/store.ts diff --git a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/config-panel.tsx b/web/app/components/sub-graph/components/config-panel.tsx similarity index 92% rename from web/app/components/workflow/nodes/tool/components/sub-graph-modal/config-panel.tsx rename to web/app/components/sub-graph/components/config-panel.tsx index acc9929ee1..5dc914f2e2 100644 --- a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/config-panel.tsx +++ b/web/app/components/sub-graph/components/config-panel.tsx @@ -1,11 +1,18 @@ 'use client' import type { FC } from 'react' -import type { ConfigPanelProps, WhenOutputNoneOption } from './types' +import type { WhenOutputNoneOption } from '../types' import { memo, useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Field from '@/app/components/workflow/nodes/_base/components/field' import { cn } from '@/utils/classnames' +type ConfigPanelProps = { + toolNodeId: string + paramKey: string + activeTab: 'settings' | 'lastRun' + onTabChange: (tab: 'settings' | 'lastRun') => void +} + const outputVariables = [ { name: 'text', type: 'string' }, { name: 'structured_output', type: 'object' }, diff --git a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/store.ts b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/store.ts deleted file mode 100644 index 63723459f0..0000000000 --- a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/store.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { CreateSubGraphSlice, SubGraphSliceShape } from './types' - -const initialState: Omit = { - parentToolNodeId: '', - parameterKey: '', - sourceAgentNodeId: '', - sourceVariable: [], - - subGraphNodes: [], - subGraphEdges: [], - - selectedOutputVar: [], - whenOutputNone: 'skip', - defaultValue: '', - - showDebugPanel: false, - isRunning: false, - - parentAvailableVars: [], -} - -export const createSubGraphSlice: CreateSubGraphSlice = set => ({ - ...initialState, - - setSubGraphContext: context => set(() => ({ - parentToolNodeId: context.parentToolNodeId, - parameterKey: context.parameterKey, - sourceAgentNodeId: context.sourceAgentNodeId, - sourceVariable: context.sourceVariable, - })), - - setSubGraphNodes: nodes => set(() => ({ subGraphNodes: nodes })), - - setSubGraphEdges: edges => set(() => ({ subGraphEdges: edges })), - - setSelectedOutputVar: selector => set(() => ({ selectedOutputVar: selector })), - - setWhenOutputNone: option => set(() => ({ whenOutputNone: option })), - - setDefaultValue: value => set(() => ({ defaultValue: value })), - - setShowDebugPanel: show => set(() => ({ showDebugPanel: show })), - - setIsRunning: running => set(() => ({ isRunning: running })), - - setParentAvailableVars: vars => set(() => ({ parentAvailableVars: vars })), - - resetSubGraph: () => set(() => ({ ...initialState })), -}) diff --git a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/types.ts b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/types.ts index a8ce0496be..4b33b0cfde 100644 --- a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/types.ts +++ b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/types.ts @@ -1,44 +1,4 @@ -import type { StateCreator } from 'zustand' -import type { Edge, Node, NodeOutPutVar, ValueSelector, VarType } from '@/app/components/workflow/types' - -export type SubGraphNodeData = { - isInSubGraph: boolean - subGraph_id: string - subGraphParamKey: string -} - -export type SubGraphNode = Node & { - data: Node['data'] & SubGraphNodeData -} - -export type SubGraphSourceNodeData = { - title: string - sourceAgentNodeId: string - sourceVariable: ValueSelector - sourceVarType: VarType - isReadOnly: true - isInSubGraph: true - subGraph_id: string - subGraphParamKey: string -} - -export type WhenOutputNoneOption = 'skip' | 'error' | 'default' - -export type SubGraphConfig = { - enabled: boolean - startNodeId: string - selectedOutputVar: ValueSelector - whenOutputNone: WhenOutputNoneOption - defaultValue?: string -} - -export type SubGraphOutputVariable = { - nodeId: string - nodeName: string - variable: string - type: VarType - description?: string -} +import type { ValueSelector } from '@/app/components/workflow/types' export type SubGraphModalProps = { isOpen: boolean @@ -50,13 +10,6 @@ export type SubGraphModalProps = { agentNodeId: string } -export type ConfigPanelProps = { - toolNodeId: string - paramKey: string - activeTab: 'settings' | 'lastRun' - onTabChange: (tab: 'settings' | 'lastRun') => void -} - export type SubGraphCanvasProps = { toolNodeId: string paramKey: string @@ -64,40 +17,3 @@ export type SubGraphCanvasProps = { agentNodeId: string agentName: string } - -export type SubGraphSliceShape = { - parentToolNodeId: string - parameterKey: string - sourceAgentNodeId: string - sourceVariable: ValueSelector - - subGraphNodes: SubGraphNode[] - subGraphEdges: Edge[] - - selectedOutputVar: ValueSelector - whenOutputNone: WhenOutputNoneOption - defaultValue: string - - showDebugPanel: boolean - isRunning: boolean - - parentAvailableVars: NodeOutPutVar[] - - setSubGraphContext: (context: { - parentToolNodeId: string - parameterKey: string - sourceAgentNodeId: string - sourceVariable: ValueSelector - }) => void - setSubGraphNodes: (nodes: SubGraphNode[]) => void - setSubGraphEdges: (edges: Edge[]) => void - setSelectedOutputVar: (selector: ValueSelector) => void - setWhenOutputNone: (option: WhenOutputNoneOption) => void - setDefaultValue: (value: string) => void - setShowDebugPanel: (show: boolean) => void - setIsRunning: (running: boolean) => void - setParentAvailableVars: (vars: NodeOutPutVar[]) => void - resetSubGraph: () => void -} - -export type CreateSubGraphSlice = StateCreator From f4e8f64bf70b0f7646c43f31ddcfa0a570bad92a Mon Sep 17 00:00:00 2001 From: zhsama Date: Mon, 12 Jan 2026 17:04:13 +0800 Subject: [PATCH 29/82] refactor:Change sub-graph output handling from skip to default --- web/app/components/sub-graph/components/config-panel.tsx | 9 +++------ .../components/sub-graph/components/sub-graph-main.tsx | 2 +- .../sub-graph/hooks/use-sub-graph-persistence.ts | 2 +- web/app/components/sub-graph/store/index.ts | 2 +- web/app/components/sub-graph/types.ts | 2 +- 5 files changed, 7 insertions(+), 10 deletions(-) diff --git a/web/app/components/sub-graph/components/config-panel.tsx b/web/app/components/sub-graph/components/config-panel.tsx index 5dc914f2e2..9f0245c23d 100644 --- a/web/app/components/sub-graph/components/config-panel.tsx +++ b/web/app/components/sub-graph/components/config-panel.tsx @@ -24,7 +24,7 @@ const ConfigPanel: FC = ({ activeTab, }) => { const { t } = useTranslation() - const [whenOutputNone, setWhenOutputNone] = useState('skip') + const [whenOutputNone, setWhenOutputNone] = useState('default') const handleWhenOutputNoneChange = useCallback((e: React.ChangeEvent) => { setWhenOutputNone(e.target.value as WhenOutputNoneOption) @@ -72,15 +72,12 @@ const ConfigPanel: FC = ({ value={whenOutputNone} onChange={handleWhenOutputNoneChange} > - -
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 efd1009692..e4d784fb96 100644 --- a/web/app/components/sub-graph/components/sub-graph-main.tsx +++ b/web/app/components/sub-graph/components/sub-graph-main.tsx @@ -35,7 +35,7 @@ const SubGraphMain: FC = ({ enabled: true, startNodeId: updatedNodes[0]?.id || '', selectedOutputVar: [], - whenOutputNone: 'skip', + whenOutputNone: 'default', } saveSubGraphData({ diff --git a/web/app/components/sub-graph/hooks/use-sub-graph-persistence.ts b/web/app/components/sub-graph/hooks/use-sub-graph-persistence.ts index f3fd97565d..70a5f9a151 100644 --- a/web/app/components/sub-graph/hooks/use-sub-graph-persistence.ts +++ b/web/app/components/sub-graph/hooks/use-sub-graph-persistence.ts @@ -107,7 +107,7 @@ export const useSubGraphPersistence = ({ enabled: true, startNodeId: nodes[0]?.id || '', selectedOutputVar: [], - whenOutputNone: 'skip', + whenOutputNone: 'default', } saveSubGraphData({ diff --git a/web/app/components/sub-graph/store/index.ts b/web/app/components/sub-graph/store/index.ts index 52accd4a21..798fc93e6a 100644 --- a/web/app/components/sub-graph/store/index.ts +++ b/web/app/components/sub-graph/store/index.ts @@ -10,7 +10,7 @@ const initialState: Omit Date: Mon, 12 Jan 2026 17:05:00 +0800 Subject: [PATCH 30/82] refactor:Refactor agent variable handling in mixed variable text input --- .../mixed-variable-text-input/index.tsx | 62 ++++++++++--------- 1 file changed, 34 insertions(+), 28 deletions(-) diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx index 8dfb63c2e2..031802d51d 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx @@ -1,4 +1,4 @@ -import type { AgentBlockType } from '@/app/components/base/prompt-editor/types' +import type { AgentNode } from '@/app/components/base/prompt-editor/types' import type { Node, NodeOutPutVar, @@ -20,10 +20,10 @@ import AgentHeaderBar from './agent-header-bar' import Placeholder from './placeholder' /** - * Matches workflow variable syntax: {{#nodeId.varName#}} - * Example: {{#agent-123.text#}} -> captures "agent-123.text" + * Matches agent context variable syntax: {{#nodeId.context#}} + * Example: {{#agent-123.context#}} -> captures "agent-123" */ -const WORKFLOW_VAR_PATTERN = /\{\{#([^#]+)#\}\}/g +const AGENT_CONTEXT_VAR_PATTERN = /\{\{#([^.#]+)\.context#\}\}/g type MixedVariableTextInputProps = { readOnly?: boolean @@ -62,11 +62,16 @@ const MixedVariableTextInput = ({ }, {} as Record) }, [availableNodes]) - const detectedAgentFromValue = useMemo(() => { + type DetectedAgent = { + nodeId: string + name: string + } + + const detectedAgentFromValue: DetectedAgent | null = useMemo(() => { if (!value) return null - const matches = value.matchAll(WORKFLOW_VAR_PATTERN) + const matches = value.matchAll(AGENT_CONTEXT_VAR_PATTERN) for (const match of matches) { const variablePath = match[1] const nodeId = variablePath.split('.')[0] @@ -81,8 +86,6 @@ const MixedVariableTextInput = ({ return null }, [value, nodesByIdMap]) - const [selectedAgent, setSelectedAgent] = useState<{ id: string, title: string } | null>(null) - const agentNodes = useMemo(() => { return availableNodes .filter(node => node.data.type === BlockEnum.Agent) @@ -92,27 +95,30 @@ const MixedVariableTextInput = ({ })) }, [availableNodes]) - const handleAgentSelect = useCallback((agent: { id: string, title: string }) => { - setSelectedAgent(agent) - }, []) - const handleAgentRemove = useCallback(() => { - const agentNodeId = detectedAgentFromValue?.nodeId || selectedAgent?.id + const agentNodeId = detectedAgentFromValue?.nodeId if (!agentNodeId || !onChange) return - const pattern = /\{\{#([^#]+)#\}\}/g - const valueWithoutAgentVars = value.replace(pattern, (match, variablePath) => { + const valueWithoutAgentVars = value.replace(AGENT_CONTEXT_VAR_PATTERN, (match, variablePath) => { const nodeId = variablePath.split('.')[0] return nodeId === agentNodeId ? '' : match }).trim() onChange(valueWithoutAgentVars) - setSelectedAgent(null) setControlPromptEditorRerenderKey(Date.now()) - }, [detectedAgentFromValue?.nodeId, selectedAgent?.id, value, onChange, setControlPromptEditorRerenderKey]) + }, [detectedAgentFromValue?.nodeId, value, onChange, setControlPromptEditorRerenderKey]) - const displayedAgent = detectedAgentFromValue || (selectedAgent ? { nodeId: selectedAgent.id, name: selectedAgent.title } : null) + const handleAgentSelect = useCallback((agent: AgentNode) => { + if (!onChange) + return + + console.log('handleAgentSelect', value) + const newValue = `{{#${agent.id}.context#}}` + + onChange(newValue) + setControlPromptEditorRerenderKey(Date.now()) + }, [value, onChange, setControlPromptEditorRerenderKey]) const handleOpenSubGraphModal = useCallback(() => { setIsSubGraphModalOpen(true) @@ -122,8 +128,8 @@ const MixedVariableTextInput = ({ setIsSubGraphModalOpen(false) }, []) - const sourceVariable: ValueSelector | undefined = displayedAgent - ? [displayedAgent.nodeId, 'text'] + const sourceVariable: ValueSelector | undefined = detectedAgentFromValue + ? [detectedAgentFromValue.nodeId, 'context'] : undefined return ( @@ -133,9 +139,9 @@ const MixedVariableTextInput = ({ 'focus-within:border-components-input-border-active focus-within:bg-components-input-bg-active focus-within:shadow-xs', )} > - {displayedAgent && ( + {detectedAgentFromValue && ( @@ -166,22 +172,22 @@ const MixedVariableTextInput = ({ onManageInputField, }} agentBlock={{ - show: agentNodes.length > 0 && !displayedAgent, + show: agentNodes.length > 0 && !detectedAgentFromValue, agentNodes, onSelect: handleAgentSelect, - } as AgentBlockType} - placeholder={} + }} + placeholder={} onChange={onChange} /> - {toolNodeId && displayedAgent && sourceVariable && ( + {toolNodeId && detectedAgentFromValue && sourceVariable && ( )}
From bb190f961013b2d82e9d78c7b3e11f8d20d97887 Mon Sep 17 00:00:00 2001 From: Novice Date: Mon, 12 Jan 2026 17:39:36 +0800 Subject: [PATCH 31/82] feat: add mention type variable --- api/core/tools/tool_manager.py | 7 + api/core/variables/__init__.py | 4 + api/core/variables/segments.py | 11 + api/core/variables/types.py | 1 + api/core/variables/utils.py | 8 +- api/core/variables/variables.py | 6 + api/core/workflow/graph/graph.py | 6 +- .../event_management/event_handlers.py | 52 ++--- api/core/workflow/graph_events/node.py | 6 - api/core/workflow/nodes/base/__init__.py | 5 - api/core/workflow/nodes/base/entities.py | 29 +-- api/core/workflow/nodes/base/node.py | 95 +++++--- .../nodes/base/virtual_node_executor.py | 213 ------------------ api/core/workflow/nodes/llm/entities.py | 25 +- api/core/workflow/nodes/llm/node.py | 168 ++++++++++++-- api/core/workflow/nodes/tool/entities.py | 41 +++- api/core/workflow/nodes/tool/tool_node.py | 63 ++++-- api/core/workflow/runtime/variable_pool.py | 52 +++++ api/factories/variable_factory.py | 11 + api/models/workflow.py | 4 +- api/services/variable_truncator.py | 5 + .../workflow/entities/test_virtual_node.py | 77 ------- .../event_management/test_event_handlers.py | 7 + 23 files changed, 457 insertions(+), 439 deletions(-) delete mode 100644 api/core/workflow/nodes/base/virtual_node_executor.py delete mode 100644 api/tests/unit_tests/core/workflow/entities/test_virtual_node.py diff --git a/api/core/tools/tool_manager.py b/api/core/tools/tool_manager.py index f8213d9fd7..4d29f419d1 100644 --- a/api/core/tools/tool_manager.py +++ b/api/core/tools/tool_manager.py @@ -1047,6 +1047,8 @@ class ToolManager: continue tool_input = ToolNodeData.ToolInput.model_validate(tool_configurations.get(parameter.name, {})) if tool_input.type == "variable": + if not isinstance(tool_input.value, list): + raise ToolParameterError(f"Invalid variable selector for {parameter.name}") variable = variable_pool.get(tool_input.value) if variable is None: raise ToolParameterError(f"Variable {tool_input.value} does not exist") @@ -1056,6 +1058,11 @@ class ToolManager: elif tool_input.type == "mixed": segment_group = variable_pool.convert_template(str(tool_input.value)) parameter_value = segment_group.text + elif tool_input.type == "mention": + # Mention type not supported in agent mode + raise ToolParameterError( + f"Mention type not supported in agent for parameter '{parameter.name}'" + ) else: raise ToolParameterError(f"Unknown tool input type '{tool_input.type}'") runtime_parameters[parameter.name] = parameter_value diff --git a/api/core/variables/__init__.py b/api/core/variables/__init__.py index 7a1cbf9940..e00cfceedd 100644 --- a/api/core/variables/__init__.py +++ b/api/core/variables/__init__.py @@ -4,6 +4,7 @@ from .segments import ( ArrayFileSegment, ArrayNumberSegment, ArrayObjectSegment, + ArrayPromptMessageSegment, ArraySegment, ArrayStringSegment, FileSegment, @@ -20,6 +21,7 @@ from .variables import ( ArrayFileVariable, ArrayNumberVariable, ArrayObjectVariable, + ArrayPromptMessageVariable, ArrayStringVariable, ArrayVariable, FileVariable, @@ -41,6 +43,8 @@ __all__ = [ "ArrayNumberVariable", "ArrayObjectSegment", "ArrayObjectVariable", + "ArrayPromptMessageSegment", + "ArrayPromptMessageVariable", "ArraySegment", "ArrayStringSegment", "ArrayStringVariable", diff --git a/api/core/variables/segments.py b/api/core/variables/segments.py index 406b4e6f93..61bd62628c 100644 --- a/api/core/variables/segments.py +++ b/api/core/variables/segments.py @@ -6,6 +6,7 @@ from typing import Annotated, Any, TypeAlias from pydantic import BaseModel, ConfigDict, Discriminator, Tag, field_validator from core.file import File +from core.model_runtime.entities import PromptMessage from .types import SegmentType @@ -208,6 +209,15 @@ class ArrayBooleanSegment(ArraySegment): value: Sequence[bool] +class ArrayPromptMessageSegment(ArraySegment): + value_type: SegmentType = SegmentType.ARRAY_PROMPT_MESSAGE + value: Sequence[PromptMessage] + + def to_object(self): + """Convert to JSON-serializable format for database storage and frontend.""" + return [msg.model_dump() for msg in self.value] + + def get_segment_discriminator(v: Any) -> SegmentType | None: if isinstance(v, Segment): return v.value_type @@ -248,6 +258,7 @@ SegmentUnion: TypeAlias = Annotated[ | Annotated[ArrayObjectSegment, Tag(SegmentType.ARRAY_OBJECT)] | Annotated[ArrayFileSegment, Tag(SegmentType.ARRAY_FILE)] | Annotated[ArrayBooleanSegment, Tag(SegmentType.ARRAY_BOOLEAN)] + | Annotated[ArrayPromptMessageSegment, Tag(SegmentType.ARRAY_PROMPT_MESSAGE)] ), Discriminator(get_segment_discriminator), ] diff --git a/api/core/variables/types.py b/api/core/variables/types.py index 13b926c978..ac055ae232 100644 --- a/api/core/variables/types.py +++ b/api/core/variables/types.py @@ -45,6 +45,7 @@ class SegmentType(StrEnum): ARRAY_OBJECT = "array[object]" ARRAY_FILE = "array[file]" ARRAY_BOOLEAN = "array[boolean]" + ARRAY_PROMPT_MESSAGE = "array[message]" NONE = "none" diff --git a/api/core/variables/utils.py b/api/core/variables/utils.py index 8e738f8fd5..799a923084 100644 --- a/api/core/variables/utils.py +++ b/api/core/variables/utils.py @@ -3,8 +3,10 @@ from typing import Any import orjson +from core.model_runtime.entities import PromptMessage + from .segment_group import SegmentGroup -from .segments import ArrayFileSegment, FileSegment, Segment +from .segments import ArrayFileSegment, ArrayPromptMessageSegment, FileSegment, Segment def to_selector(node_id: str, name: str, paths: Iterable[str] = ()) -> Sequence[str]: @@ -16,7 +18,7 @@ def to_selector(node_id: str, name: str, paths: Iterable[str] = ()) -> Sequence[ def segment_orjson_default(o: Any): """Default function for orjson serialization of Segment types""" - if isinstance(o, ArrayFileSegment): + if isinstance(o, (ArrayFileSegment, ArrayPromptMessageSegment)): return [v.model_dump() for v in o.value] elif isinstance(o, FileSegment): return o.value.model_dump() @@ -24,6 +26,8 @@ def segment_orjson_default(o: Any): return [segment_orjson_default(seg) for seg in o.value] elif isinstance(o, Segment): return o.value + elif isinstance(o, PromptMessage): + return o.model_dump() raise TypeError(f"Object of type {type(o).__name__} is not JSON serializable") diff --git a/api/core/variables/variables.py b/api/core/variables/variables.py index 9fd0bbc5b2..5ef13ad4f0 100644 --- a/api/core/variables/variables.py +++ b/api/core/variables/variables.py @@ -12,6 +12,7 @@ from .segments import ( ArrayFileSegment, ArrayNumberSegment, ArrayObjectSegment, + ArrayPromptMessageSegment, ArraySegment, ArrayStringSegment, BooleanSegment, @@ -110,6 +111,10 @@ class ArrayBooleanVariable(ArrayBooleanSegment, ArrayVariable): pass +class ArrayPromptMessageVariable(ArrayPromptMessageSegment, ArrayVariable): + pass + + class RAGPipelineVariable(BaseModel): belong_to_node_id: str = Field(description="belong to which node id, shared means public") type: str = Field(description="variable type, text-input, paragraph, select, number, file, file-list") @@ -160,6 +165,7 @@ VariableUnion: TypeAlias = Annotated[ | Annotated[ArrayObjectVariable, Tag(SegmentType.ARRAY_OBJECT)] | Annotated[ArrayFileVariable, Tag(SegmentType.ARRAY_FILE)] | Annotated[ArrayBooleanVariable, Tag(SegmentType.ARRAY_BOOLEAN)] + | Annotated[ArrayPromptMessageVariable, Tag(SegmentType.ARRAY_PROMPT_MESSAGE)] | Annotated[SecretVariable, Tag(SegmentType.SECRET)] ), Discriminator(get_segment_discriminator), diff --git a/api/core/workflow/graph/graph.py b/api/core/workflow/graph/graph.py index d38d1eba96..bd2326e84f 100644 --- a/api/core/workflow/graph/graph.py +++ b/api/core/workflow/graph/graph.py @@ -311,9 +311,9 @@ class Graph: # - custom-note: top-level type (node_config.type == "custom-note") # - group: data-level type (node_config.data.type == "group") node_configs = [ - node_config for node_config in node_configs - if node_config.get("type", "") != "custom-note" - and node_config.get("data", {}).get("type", "") != "group" + node_config + for node_config in node_configs + if node_config.get("type", "") != "custom-note" and node_config.get("data", {}).get("type", "") != "group" ] # Parse node configurations diff --git a/api/core/workflow/graph_engine/event_management/event_handlers.py b/api/core/workflow/graph_engine/event_management/event_handlers.py index 7a10b0f291..c90faf6e5e 100644 --- a/api/core/workflow/graph_engine/event_management/event_handlers.py +++ b/api/core/workflow/graph_engine/event_management/event_handlers.py @@ -125,9 +125,9 @@ class EventHandler: Args: event: The node started event """ - # Check if this is a virtual node (extraction node) - if self._is_virtual_node(event.node_id): - self._handle_virtual_node_started(event) + # Check if this is an extractor node (has parent_node_id) + if self._is_extractor_node(event.node_id): + self._handle_extractor_node_started(event) return # Track execution in domain model @@ -169,9 +169,9 @@ class EventHandler: Args: event: The node succeeded event """ - # Check if this is a virtual node (extraction node) - if self._is_virtual_node(event.node_id): - self._handle_virtual_node_success(event) + # Check if this is an extractor node (has parent_node_id) + if self._is_extractor_node(event.node_id): + self._handle_extractor_node_success(event) return # Update domain model @@ -236,9 +236,9 @@ class EventHandler: Args: event: The node failed event """ - # Check if this is a virtual node (extraction node) - if self._is_virtual_node(event.node_id): - self._handle_virtual_node_failed(event) + # Check if this is an extractor node (has parent_node_id) + if self._is_extractor_node(event.node_id): + self._handle_extractor_node_failed(event) return # Update domain model @@ -361,23 +361,23 @@ class EventHandler: else: self._graph_runtime_state.set_output(key, value) - def _is_virtual_node(self, node_id: str) -> bool: + def _is_extractor_node(self, node_id: str) -> bool: """ - Check if node_id represents a virtual sub-node. + Check if node_id represents an extractor node (has parent_node_id). - Virtual nodes have IDs in the format: {parent_node_id}.{local_id} - We check if the part before '.' exists in graph nodes. + Extractor nodes extract values from list[PromptMessage] for their parent node. + They have a parent_node_id field pointing to their parent node. """ - if "." in node_id: - parent_id = node_id.rsplit(".", 1)[0] - return parent_id in self._graph.nodes - return False + node = self._graph.nodes.get(node_id) + if node is None: + return False + return node.node_data.is_extractor_node - def _handle_virtual_node_started(self, event: NodeRunStartedEvent) -> None: + def _handle_extractor_node_started(self, event: NodeRunStartedEvent) -> None: """ - Handle virtual node started event. + Handle extractor node started event. - Virtual nodes don't need full execution tracking, just collect the event. + Extractor nodes don't need full execution tracking, just collect the event. """ # Track in response coordinator for stream ordering self._response_coordinator.track_node_execution(event.node_id, event.id) @@ -385,11 +385,11 @@ class EventHandler: # Collect the event self._event_collector.collect(event) - def _handle_virtual_node_success(self, event: NodeRunSucceededEvent) -> None: + def _handle_extractor_node_success(self, event: NodeRunSucceededEvent) -> None: """ - Handle virtual node success event. + Handle extractor node success event. - Virtual nodes (extraction nodes) need special handling: + Extractor nodes need special handling: - Store outputs in variable pool (for reference by other nodes) - Accumulate token usage - Collect the event for logging @@ -403,11 +403,11 @@ class EventHandler: # Collect the event self._event_collector.collect(event) - def _handle_virtual_node_failed(self, event: NodeRunFailedEvent) -> None: + def _handle_extractor_node_failed(self, event: NodeRunFailedEvent) -> None: """ - Handle virtual node failed event. + Handle extractor node failed event. - Virtual nodes (extraction nodes) failures are collected for logging, + Extractor node failures are collected for logging, but the parent node is responsible for handling the error. """ self._accumulate_node_usage(event.node_run_result.llm_usage) diff --git a/api/core/workflow/graph_events/node.py b/api/core/workflow/graph_events/node.py index 52345ece82..f225798d41 100644 --- a/api/core/workflow/graph_events/node.py +++ b/api/core/workflow/graph_events/node.py @@ -20,12 +20,6 @@ class NodeRunStartedEvent(GraphNodeEventBase): provider_type: str = "" provider_id: str = "" - # Virtual node fields for extraction - is_virtual: bool = False - parent_node_id: str | None = None - extraction_source: str | None = None # e.g., "llm1.context" - extraction_prompt: str | None = None - class NodeRunStreamChunkEvent(GraphNodeEventBase): # Spec-compliant fields diff --git a/api/core/workflow/nodes/base/__init__.py b/api/core/workflow/nodes/base/__init__.py index e6cde91bea..87fd6c5b32 100644 --- a/api/core/workflow/nodes/base/__init__.py +++ b/api/core/workflow/nodes/base/__init__.py @@ -4,10 +4,8 @@ from .entities import ( BaseLoopNodeData, BaseLoopState, BaseNodeData, - VirtualNodeConfig, ) from .usage_tracking_mixin import LLMUsageTrackingMixin -from .virtual_node_executor import VirtualNodeExecutionError, VirtualNodeExecutor __all__ = [ "BaseIterationNodeData", @@ -16,7 +14,4 @@ __all__ = [ "BaseLoopState", "BaseNodeData", "LLMUsageTrackingMixin", - "VirtualNodeConfig", - "VirtualNodeExecutionError", - "VirtualNodeExecutor", ] diff --git a/api/core/workflow/nodes/base/entities.py b/api/core/workflow/nodes/base/entities.py index 41469d6ee8..fa8673db5f 100644 --- a/api/core/workflow/nodes/base/entities.py +++ b/api/core/workflow/nodes/base/entities.py @@ -167,24 +167,6 @@ class DefaultValue(BaseModel): return self -class VirtualNodeConfig(BaseModel): - """Configuration for a virtual sub-node embedded within a parent node.""" - - # Local ID within parent node (e.g., "ext_1") - # Will be converted to global ID: "{parent_id}.{id}" - id: str - - # Node type (e.g., "llm", "code", "tool") - type: str - - # Full node data configuration - data: dict[str, Any] = {} - - def get_global_id(self, parent_node_id: str) -> str: - """Get the global node ID by combining parent ID and local ID.""" - return f"{parent_node_id}.{self.id}" - - class BaseNodeData(ABC, BaseModel): title: str desc: str | None = None @@ -193,8 +175,15 @@ class BaseNodeData(ABC, BaseModel): default_value: list[DefaultValue] | None = None retry_config: RetryConfig = RetryConfig() - # Virtual sub-nodes that execute before the main node - virtual_nodes: list[VirtualNodeConfig] = [] + # Parent node ID when this node is used as an extractor. + # If set, this node is an "attached" extractor node that extracts values + # from list[PromptMessage] for the parent node's parameters. + parent_node_id: str | None = None + + @property + def is_extractor_node(self) -> bool: + """Check if this node is an extractor node (has parent_node_id).""" + return self.parent_node_id is not None @property def default_value_dict(self) -> dict[str, Any]: diff --git a/api/core/workflow/nodes/base/node.py b/api/core/workflow/nodes/base/node.py index d49910c9fb..50314ea630 100644 --- a/api/core/workflow/nodes/base/node.py +++ b/api/core/workflow/nodes/base/node.py @@ -229,7 +229,6 @@ class Node(Generic[NodeDataT]): self._node_id = node_id self._node_execution_id: str = "" self._start_at = naive_utc_now() - self._virtual_node_outputs: dict[str, Any] = {} # Outputs from virtual sub-nodes raw_node_data = config.get("data") or {} if not isinstance(raw_node_data, Mapping): @@ -271,51 +270,81 @@ class Node(Generic[NodeDataT]): """Check if execution should be stopped.""" return self.graph_runtime_state.stop_event.is_set() - def _execute_virtual_nodes(self) -> Generator[GraphNodeEventBase, None, dict[str, Any]]: + def _find_extractor_node_configs(self) -> list[dict[str, Any]]: """ - Execute all virtual sub-nodes defined in node configuration. - - Virtual nodes are complete node definitions that execute before the main node. - Each virtual node: - - Has its own global ID: "{parent_id}.{local_id}" - - Generates standard node events - - Stores outputs in the variable pool (via event handling) - - Supports retry via parent node's retry config + Find all extractor node configurations that have parent_node_id == self._node_id. Returns: - dict mapping local_id -> outputs dict + List of node configuration dicts for extractor nodes """ - from .virtual_node_executor import VirtualNodeExecutor + nodes = self.graph_config.get("nodes", []) + extractor_configs = [] + for node_config in nodes: + node_data = node_config.get("data", {}) + if node_data.get("parent_node_id") == self._node_id: + extractor_configs.append(node_config) + return extractor_configs - virtual_nodes = self.node_data.virtual_nodes - if not virtual_nodes: - return {} - - executor = VirtualNodeExecutor( - graph_init_params=self._graph_init_params, - graph_runtime_state=self.graph_runtime_state, - parent_node_id=self._node_id, - parent_retry_config=self.retry_config, - ) - - return (yield from executor.execute_virtual_nodes(virtual_nodes)) - - @property - def virtual_node_outputs(self) -> dict[str, Any]: + def _execute_extractor_nodes(self) -> Generator[GraphNodeEventBase, None, None]: """ - Get the outputs from virtual sub-nodes. + Execute all extractor nodes associated with this node. - Returns: - dict mapping local_id -> outputs dict + Extractor nodes are nodes with parent_node_id == self._node_id. + They are executed before the main node to extract values from list[PromptMessage]. """ - return self._virtual_node_outputs + from core.workflow.nodes.node_mapping import LATEST_VERSION, NODE_TYPE_CLASSES_MAPPING + + extractor_configs = self._find_extractor_node_configs() + logger.debug("[Extractor] Found %d extractor nodes for parent '%s'", len(extractor_configs), self._node_id) + if not extractor_configs: + return + + for config in extractor_configs: + node_id = config.get("id") + node_data = config.get("data", {}) + node_type_str = node_data.get("type") + + if not node_id or not node_type_str: + continue + + # Get node class + try: + node_type = NodeType(node_type_str) + except ValueError: + continue + + node_mapping = NODE_TYPE_CLASSES_MAPPING.get(node_type) + if not node_mapping: + continue + + node_version = str(node_data.get("version", "1")) + node_cls = node_mapping.get(node_version) or node_mapping.get(LATEST_VERSION) + if not node_cls: + continue + + # Instantiate and execute the extractor node + extractor_node = node_cls( + id=node_id, + config=config, + graph_init_params=self._graph_init_params, + graph_runtime_state=self.graph_runtime_state, + ) + + # Execute and process extractor node events + for event in extractor_node.run(): + if isinstance(event, NodeRunSucceededEvent): + # Store extractor node outputs in variable pool + outputs = event.node_run_result.outputs + for variable_name, variable_value in outputs.items(): + self.graph_runtime_state.variable_pool.add((node_id, variable_name), variable_value) + yield event def run(self) -> Generator[GraphNodeEventBase, None, None]: execution_id = self.ensure_execution_id() self._start_at = naive_utc_now() - # Step 1: Execute virtual sub-nodes before main node execution - self._virtual_node_outputs = yield from self._execute_virtual_nodes() + # Step 1: Execute associated extractor nodes before main node execution + yield from self._execute_extractor_nodes() # Create and push start event with required fields start_event = NodeRunStartedEvent( diff --git a/api/core/workflow/nodes/base/virtual_node_executor.py b/api/core/workflow/nodes/base/virtual_node_executor.py deleted file mode 100644 index 3f3b8f1f99..0000000000 --- a/api/core/workflow/nodes/base/virtual_node_executor.py +++ /dev/null @@ -1,213 +0,0 @@ -""" -Virtual Node Executor for running embedded sub-nodes within a parent node. - -This module handles the execution of virtual nodes defined in a parent node's -`virtual_nodes` configuration. Virtual nodes are complete node definitions -that execute before the parent node. - -Example configuration: - virtual_nodes: - - id: ext_1 - type: llm - data: - model: {...} - prompt_template: [...] -""" - -import time -from collections.abc import Generator -from typing import TYPE_CHECKING, Any -from uuid import uuid4 - -from core.workflow.enums import NodeType -from core.workflow.graph_events import ( - GraphNodeEventBase, - NodeRunFailedEvent, - NodeRunRetryEvent, - NodeRunStartedEvent, - NodeRunSucceededEvent, -) -from libs.datetime_utils import naive_utc_now - -from .entities import RetryConfig, VirtualNodeConfig - -if TYPE_CHECKING: - from core.workflow.entities import GraphInitParams - from core.workflow.runtime import GraphRuntimeState - - -class VirtualNodeExecutionError(Exception): - """Error during virtual node execution""" - - def __init__(self, node_id: str, original_error: Exception): - self.node_id = node_id - self.original_error = original_error - super().__init__(f"Virtual node {node_id} execution failed: {original_error}") - - -class VirtualNodeExecutor: - """ - Executes virtual sub-nodes embedded within a parent node. - - Virtual nodes are complete node definitions that execute before the parent node. - Each virtual node: - - Has its own global ID: "{parent_id}.{local_id}" - - Generates standard node events - - Stores outputs in the variable pool - - Supports retry via parent node's retry config - """ - - def __init__( - self, - *, - graph_init_params: "GraphInitParams", - graph_runtime_state: "GraphRuntimeState", - parent_node_id: str, - parent_retry_config: RetryConfig | None = None, - ): - self._graph_init_params = graph_init_params - self._graph_runtime_state = graph_runtime_state - self._parent_node_id = parent_node_id - self._parent_retry_config = parent_retry_config or RetryConfig() - - def execute_virtual_nodes( - self, - virtual_nodes: list[VirtualNodeConfig], - ) -> Generator[GraphNodeEventBase, None, dict[str, Any]]: - """ - Execute all virtual nodes in order. - - Args: - virtual_nodes: List of virtual node configurations - - Yields: - Node events from each virtual node execution - - Returns: - dict mapping local_id -> outputs dict - """ - results: dict[str, Any] = {} - - for vnode_config in virtual_nodes: - global_id = vnode_config.get_global_id(self._parent_node_id) - - # Execute with retry - outputs = yield from self._execute_with_retry(vnode_config, global_id) - results[vnode_config.id] = outputs - - return results - - def _execute_with_retry( - self, - vnode_config: VirtualNodeConfig, - global_id: str, - ) -> Generator[GraphNodeEventBase, None, dict[str, Any]]: - """ - Execute virtual node with retry support. - """ - retry_config = self._parent_retry_config - last_error: Exception | None = None - - for attempt in range(retry_config.max_retries + 1): - try: - return (yield from self._execute_single_node(vnode_config, global_id)) - except Exception as e: - last_error = e - - if attempt < retry_config.max_retries: - # Yield retry event - yield NodeRunRetryEvent( - id=str(uuid4()), - node_id=global_id, - node_type=self._get_node_type(vnode_config.type), - node_title=vnode_config.data.get("title", f"Virtual: {vnode_config.id}"), - start_at=naive_utc_now(), - error=str(e), - retry_index=attempt + 1, - ) - - time.sleep(retry_config.retry_interval_seconds) - continue - - raise VirtualNodeExecutionError(global_id, e) from e - - raise last_error or VirtualNodeExecutionError(global_id, Exception("Unknown error")) - - def _execute_single_node( - self, - vnode_config: VirtualNodeConfig, - global_id: str, - ) -> Generator[GraphNodeEventBase, None, dict[str, Any]]: - """ - Execute a single virtual node by instantiating and running it. - """ - from core.workflow.nodes.node_mapping import LATEST_VERSION, NODE_TYPE_CLASSES_MAPPING - - # Build node config - node_config: dict[str, Any] = { - "id": global_id, - "data": { - **vnode_config.data, - "title": vnode_config.data.get("title", f"Virtual: {vnode_config.id}"), - }, - } - - # Get the node class for this type - node_type = self._get_node_type(vnode_config.type) - node_mapping = NODE_TYPE_CLASSES_MAPPING.get(node_type) - if not node_mapping: - raise ValueError(f"No class mapping found for node type: {node_type}") - - node_version = str(vnode_config.data.get("version", "1")) - node_cls = node_mapping.get(node_version) or node_mapping.get(LATEST_VERSION) - if not node_cls: - raise ValueError(f"No class found for node type: {node_type}") - - # Instantiate the node - node = node_cls( - id=global_id, - config=node_config, - graph_init_params=self._graph_init_params, - graph_runtime_state=self._graph_runtime_state, - ) - - # Run and collect events - outputs: dict[str, Any] = {} - - for event in node.run(): - # Mark event as coming from virtual node - self._mark_event_as_virtual(event, vnode_config) - yield event - - if isinstance(event, NodeRunSucceededEvent): - outputs = event.node_run_result.outputs or {} - elif isinstance(event, NodeRunFailedEvent): - raise Exception(event.error or "Virtual node execution failed") - - return outputs - - def _mark_event_as_virtual( - self, - event: GraphNodeEventBase, - vnode_config: VirtualNodeConfig, - ) -> None: - """Mark event as coming from a virtual node.""" - if isinstance(event, NodeRunStartedEvent): - event.is_virtual = True - event.parent_node_id = self._parent_node_id - - def _get_node_type(self, type_str: str) -> NodeType: - """Convert type string to NodeType enum.""" - type_mapping = { - "llm": NodeType.LLM, - "code": NodeType.CODE, - "tool": NodeType.TOOL, - "if-else": NodeType.IF_ELSE, - "question-classifier": NodeType.QUESTION_CLASSIFIER, - "parameter-extractor": NodeType.PARAMETER_EXTRACTOR, - "template-transform": NodeType.TEMPLATE_TRANSFORM, - "variable-assigner": NodeType.VARIABLE_ASSIGNER, - "http-request": NodeType.HTTP_REQUEST, - "knowledge-retrieval": NodeType.KNOWLEDGE_RETRIEVAL, - } - return type_mapping.get(type_str, NodeType.LLM) diff --git a/api/core/workflow/nodes/llm/entities.py b/api/core/workflow/nodes/llm/entities.py index fe6f2290aa..c7db88891f 100644 --- a/api/core/workflow/nodes/llm/entities.py +++ b/api/core/workflow/nodes/llm/entities.py @@ -1,7 +1,7 @@ from collections.abc import Mapping, Sequence -from typing import Any, Literal +from typing import Annotated, Any, Literal, TypeAlias -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator from core.model_runtime.entities import ImagePromptMessageContent, LLMMode from core.prompt.entities.advanced_prompt_entities import ChatModelMessage, CompletionModelPromptTemplate, MemoryConfig @@ -58,9 +58,28 @@ class LLMNodeCompletionModelPromptTemplate(CompletionModelPromptTemplate): jinja2_text: str | None = None +class PromptMessageContext(BaseModel): + """Context variable reference in prompt template. + + YAML/JSON format: { "$context": ["node_id", "variable_name"] } + This will be expanded to list[PromptMessage] at runtime. + """ + + model_config = ConfigDict(populate_by_name=True) + + value_selector: Sequence[str] = Field(alias="$context") + + +# Union type for prompt template items (static message or context variable reference) +PromptTemplateItem: TypeAlias = Annotated[ + LLMNodeChatModelMessage | PromptMessageContext, + Field(discriminator=None), +] + + class LLMNodeData(BaseNodeData): model: ModelConfig - prompt_template: Sequence[LLMNodeChatModelMessage] | LLMNodeCompletionModelPromptTemplate + prompt_template: Sequence[PromptTemplateItem] | LLMNodeCompletionModelPromptTemplate prompt_config: PromptConfig = Field(default_factory=PromptConfig) memory: MemoryConfig | None = None context: ContextConfig diff --git a/api/core/workflow/nodes/llm/node.py b/api/core/workflow/nodes/llm/node.py index e69186e2b0..02ab4ee7a0 100644 --- a/api/core/workflow/nodes/llm/node.py +++ b/api/core/workflow/nodes/llm/node.py @@ -7,7 +7,7 @@ import logging import re import time from collections.abc import Generator, Mapping, Sequence -from typing import TYPE_CHECKING, Any, Literal +from typing import TYPE_CHECKING, Any, Literal, cast from sqlalchemy import select @@ -52,6 +52,7 @@ from core.rag.entities.citation_metadata import RetrievalSourceMetadata from core.tools.signature import sign_upload_file from core.variables import ( ArrayFileSegment, + ArrayPromptMessageSegment, ArraySegment, FileSegment, NoneSegment, @@ -88,6 +89,7 @@ from .entities import ( LLMNodeCompletionModelPromptTemplate, LLMNodeData, ModelConfig, + PromptMessageContext, ) from .exc import ( InvalidContextStructureError, @@ -160,8 +162,9 @@ class LLMNode(Node[LLMNodeData]): variable_pool = self.graph_runtime_state.variable_pool try: - # init messages template - self.node_data.prompt_template = self._transform_chat_messages(self.node_data.prompt_template) + # Parse prompt template to separate static messages and context references + prompt_template = self.node_data.prompt_template + static_messages, context_refs, template_order = self._parse_prompt_template() # fetch variables and fetch values from variable pool inputs = self._fetch_inputs(node_data=self.node_data) @@ -223,21 +226,40 @@ class LLMNode(Node[LLMNodeData]): ): query = query_variable.text - prompt_messages, stop = LLMNode.fetch_prompt_messages( - sys_query=query, - sys_files=files, - context=context, - memory=memory, - model_config=model_config, - prompt_template=self.node_data.prompt_template, - memory_config=self.node_data.memory, - vision_enabled=self.node_data.vision.enabled, - vision_detail=self.node_data.vision.configs.detail, - variable_pool=variable_pool, - jinja2_variables=self.node_data.prompt_config.jinja2_variables, - tenant_id=self.tenant_id, - context_files=context_files, - ) + # Get prompt messages + prompt_messages: Sequence[PromptMessage] + stop: Sequence[str] | None + if isinstance(prompt_template, list) and context_refs: + prompt_messages, stop = self._build_prompt_messages_with_context( + context_refs=context_refs, + template_order=template_order, + static_messages=static_messages, + query=query, + files=files, + context=context, + memory=memory, + model_config=model_config, + context_files=context_files, + ) + else: + prompt_messages, stop = LLMNode.fetch_prompt_messages( + sys_query=query, + sys_files=files, + context=context, + memory=memory, + model_config=model_config, + prompt_template=cast( + Sequence[LLMNodeChatModelMessage] | LLMNodeCompletionModelPromptTemplate, + self.node_data.prompt_template, + ), + memory_config=self.node_data.memory, + vision_enabled=self.node_data.vision.enabled, + vision_detail=self.node_data.vision.configs.detail, + variable_pool=variable_pool, + jinja2_variables=self.node_data.prompt_config.jinja2_variables, + tenant_id=self.tenant_id, + context_files=context_files, + ) # handle invoke result generator = LLMNode.invoke_llm( @@ -304,7 +326,7 @@ class LLMNode(Node[LLMNodeData]): "reasoning_content": reasoning_content, "usage": jsonable_encoder(usage), "finish_reason": finish_reason, - "context": self._build_context(prompt_messages, clean_text, model_config.mode), + "context": self._build_context(prompt_messages, clean_text), } if structured_output: outputs["structured_output"] = structured_output.structured_output @@ -602,17 +624,15 @@ class LLMNode(Node[LLMNodeData]): def _build_context( prompt_messages: Sequence[PromptMessage], assistant_response: str, - model_mode: str, - ) -> list[dict[str, Any]]: + ) -> list[PromptMessage]: """ Build context from prompt messages and assistant response. Excludes system messages and includes the current LLM response. + Returns list[PromptMessage] for use with ArrayPromptMessageSegment. """ context_messages: list[PromptMessage] = [m for m in prompt_messages if m.role != PromptMessageRole.SYSTEM] context_messages.append(AssistantPromptMessage(content=assistant_response)) - return PromptMessageUtil.prompt_messages_to_prompt_for_saving( - model_mode=model_mode, prompt_messages=context_messages - ) + return context_messages def _transform_chat_messages( self, messages: Sequence[LLMNodeChatModelMessage] | LLMNodeCompletionModelPromptTemplate, / @@ -629,6 +649,106 @@ class LLMNode(Node[LLMNodeData]): return messages + def _parse_prompt_template( + self, + ) -> tuple[list[LLMNodeChatModelMessage], list[PromptMessageContext], list[tuple[int, str]]]: + """ + Parse prompt_template to separate static messages and context references. + + Returns: + Tuple of (static_messages, context_refs, template_order) + - static_messages: list of LLMNodeChatModelMessage + - context_refs: list of PromptMessageContext + - template_order: list of (index, type) tuples preserving original order + """ + prompt_template = self.node_data.prompt_template + static_messages: list[LLMNodeChatModelMessage] = [] + context_refs: list[PromptMessageContext] = [] + template_order: list[tuple[int, str]] = [] + + if isinstance(prompt_template, list): + for idx, item in enumerate(prompt_template): + if isinstance(item, PromptMessageContext): + context_refs.append(item) + template_order.append((idx, "context")) + else: + static_messages.append(item) + template_order.append((idx, "static")) + # Transform static messages for jinja2 + if static_messages: + self.node_data.prompt_template = self._transform_chat_messages(static_messages) + + return static_messages, context_refs, template_order + + def _build_prompt_messages_with_context( + self, + *, + context_refs: list[PromptMessageContext], + template_order: list[tuple[int, str]], + static_messages: list[LLMNodeChatModelMessage], + query: str | None, + files: Sequence[File], + context: str | None, + memory: BaseMemory | None, + model_config: ModelConfigWithCredentialsEntity, + context_files: list[File], + ) -> tuple[list[PromptMessage], Sequence[str] | None]: + """ + Build prompt messages by combining static messages and context references in DSL order. + + Returns: + Tuple of (prompt_messages, stop_sequences) + """ + variable_pool = self.graph_runtime_state.variable_pool + + # Build a map from context index to its messages + context_messages_map: dict[int, list[PromptMessage]] = {} + context_idx = 0 + for idx, type_ in template_order: + if type_ == "context": + ctx_ref = context_refs[context_idx] + ctx_var = variable_pool.get(ctx_ref.value_selector) + if ctx_var is None: + raise VariableNotFoundError(f"Variable {'.'.join(ctx_ref.value_selector)} not found") + if not isinstance(ctx_var, ArrayPromptMessageSegment): + raise InvalidVariableTypeError(f"Variable {'.'.join(ctx_ref.value_selector)} is not array[message]") + context_messages_map[idx] = list(ctx_var.value) + context_idx += 1 + + # Process static messages + static_prompt_messages: Sequence[PromptMessage] = [] + stop: Sequence[str] | None = None + if static_messages: + static_prompt_messages, stop = LLMNode.fetch_prompt_messages( + sys_query=query, + sys_files=files, + context=context, + memory=memory, + model_config=model_config, + prompt_template=cast(Sequence[LLMNodeChatModelMessage], self.node_data.prompt_template), + memory_config=self.node_data.memory, + vision_enabled=self.node_data.vision.enabled, + vision_detail=self.node_data.vision.configs.detail, + variable_pool=variable_pool, + jinja2_variables=self.node_data.prompt_config.jinja2_variables, + tenant_id=self.tenant_id, + context_files=context_files, + ) + + # Combine messages according to original DSL order + combined_messages: list[PromptMessage] = [] + static_msg_iter = iter(static_prompt_messages) + for idx, type_ in template_order: + if type_ == "context": + combined_messages.extend(context_messages_map[idx]) + else: + if msg := next(static_msg_iter, None): + combined_messages.append(msg) + # Append any remaining static messages (e.g., memory messages) + combined_messages.extend(static_msg_iter) + + return combined_messages, stop + def _fetch_jinja_inputs(self, node_data: LLMNodeData) -> dict[str, str]: variables: dict[str, Any] = {} diff --git a/api/core/workflow/nodes/tool/entities.py b/api/core/workflow/nodes/tool/entities.py index c1cfbb1edc..72e71b020b 100644 --- a/api/core/workflow/nodes/tool/entities.py +++ b/api/core/workflow/nodes/tool/entities.py @@ -1,3 +1,4 @@ +from collections.abc import Sequence from typing import Any, Literal, Union from pydantic import BaseModel, field_validator @@ -7,6 +8,31 @@ from core.tools.entities.tool_entities import ToolProviderType from core.workflow.nodes.base.entities import BaseNodeData +class MentionValue(BaseModel): + """Value structure for mention type parameters. + + Used when a tool parameter needs to be extracted from conversation context + using an extractor LLM node. + """ + + # Variable selector for list[PromptMessage] input to extractor + variable_selector: Sequence[str] + + # ID of the extractor LLM node + extractor_node_id: str + + # Output variable selector from extractor node + # e.g., ["text"], ["structured_output", "query"] + output_selector: Sequence[str] + + # Strategy when output is None + null_strategy: Literal["raise_error", "use_default"] = "raise_error" + + # Default value when null_strategy is "use_default" + # Type should match the parameter's expected type + default_value: Any = None + + class ToolEntity(BaseModel): provider_id: str provider_type: ToolProviderType @@ -34,8 +60,8 @@ class ToolEntity(BaseModel): class ToolNodeData(BaseNodeData, ToolEntity): class ToolInput(BaseModel): # TODO: check this type - value: Union[Any, list[str]] - type: Literal["mixed", "variable", "constant"] + value: Union[Any, list[str], MentionValue] + type: Literal["mixed", "variable", "constant", "mention"] @field_validator("type", mode="before") @classmethod @@ -56,6 +82,17 @@ class ToolNodeData(BaseNodeData, ToolEntity): raise ValueError("value must be a list of strings") elif typ == "constant" and not isinstance(value, str | int | float | bool | dict): raise ValueError("value must be a string, int, float, bool or dict") + elif typ == "mention": + # Mention type: value should be a MentionValue or dict with required fields + if isinstance(value, MentionValue): + pass # Already validated by Pydantic + elif isinstance(value, dict): + if "extractor_node_id" not in value: + raise ValueError("value must contain extractor_node_id for mention type") + if "output_selector" not in value: + raise ValueError("value must contain output_selector for mention type") + else: + raise ValueError("value must be a MentionValue or dict for mention type") return typ tool_parameters: dict[str, ToolInput] diff --git a/api/core/workflow/nodes/tool/tool_node.py b/api/core/workflow/nodes/tool/tool_node.py index 0ba58a9560..7752dc0f46 100644 --- a/api/core/workflow/nodes/tool/tool_node.py +++ b/api/core/workflow/nodes/tool/tool_node.py @@ -1,7 +1,10 @@ +import logging from collections.abc import Generator, Mapping, Sequence from typing import TYPE_CHECKING, Any from sqlalchemy import select + +logger = logging.getLogger(__name__) from sqlalchemy.orm import Session from core.callback_handler.workflow_tool_callback_handler import DifyWorkflowCallbackHandler @@ -89,20 +92,18 @@ class ToolNode(Node[ToolNodeData]): ) return - # get parameters (use virtual_node_outputs from base class) + # get parameters tool_parameters = tool_runtime.get_merged_runtime_parameters() or [] parameters = self._generate_parameters( tool_parameters=tool_parameters, variable_pool=self.graph_runtime_state.variable_pool, node_data=self.node_data, - virtual_node_outputs=self.virtual_node_outputs, ) parameters_for_log = self._generate_parameters( tool_parameters=tool_parameters, variable_pool=self.graph_runtime_state.variable_pool, node_data=self.node_data, for_log=True, - virtual_node_outputs=self.virtual_node_outputs, ) # get conversation id conversation_id = self.graph_runtime_state.variable_pool.get(["sys", SystemVariableKey.CONVERSATION_ID]) @@ -178,7 +179,6 @@ class ToolNode(Node[ToolNodeData]): variable_pool: "VariablePool", node_data: ToolNodeData, for_log: bool = False, - virtual_node_outputs: dict[str, Any] | None = None, ) -> dict[str, Any]: """ Generate parameters based on the given tool parameters, variable pool, and node data. @@ -188,16 +188,12 @@ class ToolNode(Node[ToolNodeData]): variable_pool (VariablePool): The variable pool containing the variables. node_data (ToolNodeData): The data associated with the tool node. for_log (bool): Whether to generate parameters for logging. - virtual_node_outputs (dict[str, Any] | None): Outputs from virtual sub-nodes. - Maps local_id -> outputs dict. Virtual node outputs are also in variable_pool - with global IDs like "{parent_id}.{local_id}". Returns: Mapping[str, Any]: A dictionary containing the generated parameters. """ tool_parameters_dictionary = {parameter.name: parameter for parameter in tool_parameters} - virtual_node_outputs = virtual_node_outputs or {} result: dict[str, Any] = {} for parameter_name in node_data.tool_parameters: @@ -207,22 +203,39 @@ class ToolNode(Node[ToolNodeData]): continue tool_input = node_data.tool_parameters[parameter_name] if tool_input.type == "variable": - # Check if this references a virtual node output (local ID like [ext_1, text]) + if not isinstance(tool_input.value, list): + raise ToolParameterError(f"Invalid variable selector for parameter '{parameter_name}'") selector = tool_input.value - if len(selector) >= 2 and selector[0] in virtual_node_outputs: - # Reference to virtual node output - local_id = selector[0] - var_name = selector[1] - outputs = virtual_node_outputs.get(local_id, {}) - parameter_value = outputs.get(var_name) + variable = variable_pool.get(selector) + if variable is None: + if parameter.required: + raise ToolParameterError(f"Variable {selector} does not exist") + continue + parameter_value = variable.value + elif tool_input.type == "mention": + # Mention type: get value from extractor node's output + from .entities import MentionValue + + mention_value = tool_input.value + if isinstance(mention_value, MentionValue): + mention_config = mention_value.model_dump() + elif isinstance(mention_value, dict): + mention_config = mention_value else: - # Normal variable reference - variable = variable_pool.get(selector) - if variable is None: - if parameter.required: - raise ToolParameterError(f"Variable {selector} does not exist") + raise ToolParameterError(f"Invalid mention value for parameter '{parameter_name}'") + + try: + parameter_value, found = variable_pool.resolve_mention( + mention_config, parameter_name=parameter_name + ) + if not found and parameter.required: + raise ToolParameterError( + f"Extractor output not found for required parameter '{parameter_name}'" + ) + if not found: continue - parameter_value = variable.value + except ValueError as e: + raise ToolParameterError(str(e)) from e elif tool_input.type in {"mixed", "constant"}: template = str(tool_input.value) segment_group = variable_pool.convert_template(template) @@ -507,8 +520,12 @@ class ToolNode(Node[ToolNodeData]): for selector in selectors: result[selector.variable] = selector.value_selector elif input.type == "variable": - selector_key = ".".join(input.value) - result[f"#{selector_key}#"] = input.value + if isinstance(input.value, list): + selector_key = ".".join(input.value) + result[f"#{selector_key}#"] = input.value + elif input.type == "mention": + # Mention type handled by extractor node, no direct variable reference + pass elif input.type == "constant": pass diff --git a/api/core/workflow/runtime/variable_pool.py b/api/core/workflow/runtime/variable_pool.py index 85ceb9d59e..f456f61dd0 100644 --- a/api/core/workflow/runtime/variable_pool.py +++ b/api/core/workflow/runtime/variable_pool.py @@ -268,6 +268,58 @@ class VariablePool(BaseModel): continue self.add(selector, value) + def resolve_mention( + self, + mention_config: Mapping[str, Any], + /, + *, + parameter_name: str = "", + ) -> tuple[Any, bool]: + """ + Resolve a mention parameter value from an extractor node's output. + + Mention parameters reference values extracted by an extractor LLM node + from list[PromptMessage] context. + + Args: + mention_config: A dict containing: + - extractor_node_id: ID of the extractor LLM node + - output_selector: Selector path for the output variable (e.g., ["text"]) + - null_strategy: "raise_error" or "use_default" + - default_value: Value to use when null_strategy is "use_default" + parameter_name: Name of the parameter being resolved (for error messages) + + Returns: + Tuple of (resolved_value, found): + - resolved_value: The extracted value, or default_value if not found + - found: True if value was found, False if using default + + Raises: + ValueError: If extractor_node_id is missing, or if null_strategy is + "raise_error" and the value is not found + """ + extractor_node_id = mention_config.get("extractor_node_id") + if not extractor_node_id: + raise ValueError(f"Missing extractor_node_id for mention parameter '{parameter_name}'") + + output_selector = list(mention_config.get("output_selector", [])) + null_strategy = mention_config.get("null_strategy", "raise_error") + default_value = mention_config.get("default_value") + + # Build full selector: [extractor_node_id, ...output_selector] + full_selector = [extractor_node_id] + output_selector + variable = self.get(full_selector) + + if variable is None: + if null_strategy == "use_default": + return default_value, False + raise ValueError( + f"Extractor node '{extractor_node_id}' output '{'.'.join(output_selector)}' " + f"not found for parameter '{parameter_name}'" + ) + + return variable.value, True + @classmethod def empty(cls) -> VariablePool: """Create an empty variable pool.""" diff --git a/api/factories/variable_factory.py b/api/factories/variable_factory.py index 494194369a..fb697a8c29 100644 --- a/api/factories/variable_factory.py +++ b/api/factories/variable_factory.py @@ -4,6 +4,7 @@ from uuid import uuid4 from configs import dify_config from core.file import File +from core.model_runtime.entities import PromptMessage from core.variables.exc import VariableError from core.variables.segments import ( ArrayAnySegment, @@ -11,6 +12,7 @@ from core.variables.segments import ( ArrayFileSegment, ArrayNumberSegment, ArrayObjectSegment, + ArrayPromptMessageSegment, ArraySegment, ArrayStringSegment, BooleanSegment, @@ -29,6 +31,7 @@ from core.variables.variables import ( ArrayFileVariable, ArrayNumberVariable, ArrayObjectVariable, + ArrayPromptMessageVariable, ArrayStringVariable, BooleanVariable, FileVariable, @@ -61,6 +64,7 @@ SEGMENT_TO_VARIABLE_MAP = { ArrayFileSegment: ArrayFileVariable, ArrayNumberSegment: ArrayNumberVariable, ArrayObjectSegment: ArrayObjectVariable, + ArrayPromptMessageSegment: ArrayPromptMessageVariable, ArrayStringSegment: ArrayStringVariable, BooleanSegment: BooleanVariable, FileSegment: FileVariable, @@ -156,7 +160,13 @@ def build_segment(value: Any, /) -> Segment: return ObjectSegment(value=value) if isinstance(value, File): return FileSegment(value=value) + if isinstance(value, PromptMessage): + # Single PromptMessage should be wrapped in a list + return ArrayPromptMessageSegment(value=[value]) if isinstance(value, list): + # Check if all items are PromptMessage + if value and all(isinstance(item, PromptMessage) for item in value): + return ArrayPromptMessageSegment(value=value) items = [build_segment(item) for item in value] types = {item.value_type for item in items} if all(isinstance(item, ArraySegment) for item in items): @@ -200,6 +210,7 @@ _segment_factory: Mapping[SegmentType, type[Segment]] = { SegmentType.ARRAY_OBJECT: ArrayObjectSegment, SegmentType.ARRAY_FILE: ArrayFileSegment, SegmentType.ARRAY_BOOLEAN: ArrayBooleanSegment, + SegmentType.ARRAY_PROMPT_MESSAGE: ArrayPromptMessageSegment, } diff --git a/api/models/workflow.py b/api/models/workflow.py index 072c6100b5..7be51c05b6 100644 --- a/api/models/workflow.py +++ b/api/models/workflow.py @@ -1291,7 +1291,7 @@ class WorkflowDraftVariable(Base): # which may differ from the original value's type. Typically, they are the same, # but in cases where the structurally truncated value still exceeds the size limit, # text slicing is applied, and the `value_type` is converted to `STRING`. - value_type: Mapped[SegmentType] = mapped_column(EnumText(SegmentType, length=20)) + value_type: Mapped[SegmentType] = mapped_column(EnumText(SegmentType, length=21)) # The variable's value serialized as a JSON string # @@ -1665,7 +1665,7 @@ class WorkflowDraftVariableFile(Base): # The `value_type` field records the type of the original value. value_type: Mapped[SegmentType] = mapped_column( - EnumText(SegmentType, length=20), + EnumText(SegmentType, length=21), nullable=False, ) diff --git a/api/services/variable_truncator.py b/api/services/variable_truncator.py index f973361341..9d587c7850 100644 --- a/api/services/variable_truncator.py +++ b/api/services/variable_truncator.py @@ -7,6 +7,7 @@ from typing import Any, Generic, TypeAlias, TypeVar, overload from configs import dify_config from core.file.models import File +from core.model_runtime.entities import PromptMessage from core.variables.segments import ( ArrayFileSegment, ArraySegment, @@ -287,6 +288,10 @@ class VariableTruncator(BaseTruncator): if isinstance(item, File): truncated_value.append(item) continue + # Handle PromptMessage types - convert to dict for truncation + if isinstance(item, PromptMessage): + truncated_value.append(item) + continue if i >= target_length: return _PartResult(truncated_value, used_size, True) if i > 0: diff --git a/api/tests/unit_tests/core/workflow/entities/test_virtual_node.py b/api/tests/unit_tests/core/workflow/entities/test_virtual_node.py deleted file mode 100644 index ffffccfa1b..0000000000 --- a/api/tests/unit_tests/core/workflow/entities/test_virtual_node.py +++ /dev/null @@ -1,77 +0,0 @@ -""" -Unit tests for virtual node configuration. -""" - -from core.workflow.nodes.base.entities import VirtualNodeConfig - - -class TestVirtualNodeConfig: - """Tests for VirtualNodeConfig entity.""" - - def test_create_basic_config(self): - """Test creating a basic virtual node config.""" - config = VirtualNodeConfig( - id="ext_1", - type="llm", - data={ - "title": "Extract keywords", - "model": {"provider": "openai", "name": "gpt-4o-mini"}, - }, - ) - - assert config.id == "ext_1" - assert config.type == "llm" - assert config.data["title"] == "Extract keywords" - - def test_get_global_id(self): - """Test generating global ID from parent ID.""" - config = VirtualNodeConfig( - id="ext_1", - type="llm", - data={}, - ) - - global_id = config.get_global_id("tool1") - assert global_id == "tool1.ext_1" - - def test_get_global_id_with_different_parents(self): - """Test global ID generation with different parent IDs.""" - config = VirtualNodeConfig(id="sub_node", type="code", data={}) - - assert config.get_global_id("parent1") == "parent1.sub_node" - assert config.get_global_id("node_123") == "node_123.sub_node" - - def test_empty_data(self): - """Test virtual node config with empty data.""" - config = VirtualNodeConfig( - id="test", - type="tool", - ) - - assert config.id == "test" - assert config.type == "tool" - assert config.data == {} - - def test_complex_data(self): - """Test virtual node config with complex data.""" - config = VirtualNodeConfig( - id="llm_1", - type="llm", - data={ - "title": "Generate summary", - "model": { - "provider": "openai", - "name": "gpt-4", - "mode": "chat", - "completion_params": {"temperature": 0.7, "max_tokens": 500}, - }, - "prompt_template": [ - {"role": "user", "text": "{{#llm1.context#}}"}, - {"role": "user", "text": "Please summarize the conversation"}, - ], - }, - ) - - assert config.data["model"]["provider"] == "openai" - assert len(config.data["prompt_template"]) == 2 - diff --git a/api/tests/unit_tests/core/workflow/graph_engine/event_management/test_event_handlers.py b/api/tests/unit_tests/core/workflow/graph_engine/event_management/test_event_handlers.py index 5d17b7a243..65bd3d87d4 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/event_management/test_event_handlers.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/event_management/test_event_handlers.py @@ -25,6 +25,12 @@ class _StubErrorHandler: """Minimal error handler stub for tests.""" +class _StubNodeData: + """Simple node data stub with is_extractor_node property.""" + + is_extractor_node = False + + class _StubNode: """Simple node stub exposing the attributes needed by the state manager.""" @@ -36,6 +42,7 @@ class _StubNode: self.error_strategy = None self.retry_config = RetryConfig() self.retry = False + self.node_data = _StubNodeData() def _build_event_handler(node_id: str) -> tuple[EventHandler, EventManager, GraphExecution]: From b25b0699173c5e31d6ab3dc2300ef086d5c0de7b Mon Sep 17 00:00:00 2001 From: zhsama Date: Mon, 12 Jan 2026 18:01:02 +0800 Subject: [PATCH 32/82] fix: refine agent variable logic --- .../plugins/workflow-variable-block/component.tsx | 4 ++-- .../nodes/tool/components/mixed-variable-text-input/index.tsx | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/component.tsx b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/component.tsx index 4ba321e92a..1ff0da78ab 100644 --- a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/component.tsx +++ b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/component.tsx @@ -67,7 +67,7 @@ const WorkflowVariableBlockComponent = ({ )() const [localWorkflowNodesMap, setLocalWorkflowNodesMap] = useState(workflowNodesMap) const node = localWorkflowNodesMap![variables[isRagVar ? 1 : 0]] - const isAgentVariable = node?.type === BlockEnum.Agent + const isAgentContextVariable = node?.type === BlockEnum.Agent && variables[variablesLength - 1] === 'context' const isException = isExceptionVariable(varName, node?.type) const variableValid = useMemo(() => { @@ -136,7 +136,7 @@ const WorkflowVariableBlockComponent = ({ }) }, [node, reactflow, store]) - if (isAgentVariable) + if (isAgentContextVariable) return const Item = ( diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx index 031802d51d..c9781adb97 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx @@ -113,7 +113,6 @@ const MixedVariableTextInput = ({ if (!onChange) return - console.log('handleAgentSelect', value) const newValue = `{{#${agent.id}.context#}}` onChange(newValue) From 47790b49d402e0461d2b486de8c8eb8c14eb737a Mon Sep 17 00:00:00 2001 From: zhsama Date: Mon, 12 Jan 2026 18:12:01 +0800 Subject: [PATCH 33/82] fix: Fix agent context variable insertion to preserve existing text --- .../nodes/tool/components/mixed-variable-text-input/index.tsx | 3 ++- web/package.json | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx index c9781adb97..238b20bdc1 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx @@ -113,7 +113,8 @@ const MixedVariableTextInput = ({ if (!onChange) return - const newValue = `{{#${agent.id}.context#}}` + const valueWithoutTrigger = value.replace(/@$/, '') + const newValue = `{{#${agent.id}.context#}}${valueWithoutTrigger}` onChange(newValue) setControlPromptEditorRerenderKey(Date.now()) diff --git a/web/package.json b/web/package.json index 4019e49cd9..850c4ae4be 100644 --- a/web/package.json +++ b/web/package.json @@ -3,7 +3,7 @@ "type": "module", "version": "1.11.2", "private": true, - "packageManager": "pnpm@10.27.0+sha512.72d699da16b1179c14ba9e64dc71c9a40988cbdc65c264cb0e489db7de917f20dcf4d64d8723625f2969ba52d4b7e2a1170682d9ac2a5dcaeaab732b7e16f04a", + "packageManager": "pnpm@10.28.0+sha512.05df71d1421f21399e053fde567cea34d446fa02c76571441bfc1c7956e98e363088982d940465fd34480d4d90a0668bc12362f8aa88000a64e83d0b0e47be48", "imports": { "#i18n": { "react-server": "./i18n-config/lib.server.ts", From 03e0c4c617095703e3ea4d328c6c11ae4db537fd Mon Sep 17 00:00:00 2001 From: zhsama Date: Mon, 12 Jan 2026 20:08:41 +0800 Subject: [PATCH 34/82] feat: Add VarKindType parameter metion to mixed variable text input --- .../workflow/nodes/_base/components/form-input-item.tsx | 4 ++-- web/app/components/workflow/nodes/_base/types.ts | 1 + .../tool/components/mixed-variable-text-input/index.tsx | 8 +++++--- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/web/app/components/workflow/nodes/_base/components/form-input-item.tsx b/web/app/components/workflow/nodes/_base/components/form-input-item.tsx index caafd34e97..99789d8afe 100644 --- a/web/app/components/workflow/nodes/_base/components/form-input-item.tsx +++ b/web/app/components/workflow/nodes/_base/components/form-input-item.tsx @@ -233,12 +233,12 @@ const FormInputItem: FC = ({ } } - const handleValueChange = (newValue: any) => { + const handleValueChange = (newValue: any, newType?: VarKindType) => { onChange({ ...value, [variable]: { ...varInput, - type: getVarKindType(), + type: newType ?? getVarKindType(), value: isNumber ? Number.parseFloat(newValue) : newValue, }, }) diff --git a/web/app/components/workflow/nodes/_base/types.ts b/web/app/components/workflow/nodes/_base/types.ts index 18ad9c4e71..8f15c89881 100644 --- a/web/app/components/workflow/nodes/_base/types.ts +++ b/web/app/components/workflow/nodes/_base/types.ts @@ -5,6 +5,7 @@ export enum VarKindType { variable = 'variable', constant = 'constant', mixed = 'mixed', + mention = 'mention', } // Generic resource variable inputs diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx index 238b20bdc1..d0beeabb0c 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx @@ -1,4 +1,5 @@ import type { AgentNode } from '@/app/components/base/prompt-editor/types' +import type { VarKindType } from '@/app/components/workflow/nodes/_base/types' import type { Node, NodeOutPutVar, @@ -12,6 +13,7 @@ import { } from 'react' import { useTranslation } from 'react-i18next' import PromptEditor from '@/app/components/base/prompt-editor' +import { VarKindType as VarKindTypeEnum } from '@/app/components/workflow/nodes/_base/types' import { useStore } from '@/app/components/workflow/store' import { BlockEnum } from '@/app/components/workflow/types' import { cn } from '@/utils/classnames' @@ -30,7 +32,7 @@ type MixedVariableTextInputProps = { nodesOutputVars?: NodeOutPutVar[] availableNodes?: Node[] value?: string - onChange?: (text: string) => void + onChange?: (text: string, type?: VarKindType) => void showManageInputField?: boolean onManageInputField?: () => void disableVariableInsertion?: boolean @@ -105,7 +107,7 @@ const MixedVariableTextInput = ({ return nodeId === agentNodeId ? '' : match }).trim() - onChange(valueWithoutAgentVars) + onChange(valueWithoutAgentVars, VarKindTypeEnum.mixed) setControlPromptEditorRerenderKey(Date.now()) }, [detectedAgentFromValue?.nodeId, value, onChange, setControlPromptEditorRerenderKey]) @@ -116,7 +118,7 @@ const MixedVariableTextInput = ({ const valueWithoutTrigger = value.replace(/@$/, '') const newValue = `{{#${agent.id}.context#}}${valueWithoutTrigger}` - onChange(newValue) + onChange(newValue, VarKindTypeEnum.mention) setControlPromptEditorRerenderKey(Date.now()) }, [value, onChange, setControlPromptEditorRerenderKey]) From 969c96b07029bb8ad602a92a03c637e2d5dcaa89 Mon Sep 17 00:00:00 2001 From: Novice Date: Tue, 13 Jan 2026 14:12:57 +0800 Subject: [PATCH 35/82] feat: add stream response --- .../generate_response_converter.py | 6 +- .../agent_chat/generate_response_converter.py | 6 +- .../apps/chat/generate_response_converter.py | 6 +- .../common/workflow_response_converter.py | 6 + .../completion/generate_response_converter.py | 6 +- .../pipeline/generate_response_converter.py | 4 +- .../workflow/generate_response_converter.py | 4 +- api/core/app/apps/workflow_app_runner.py | 7 ++ api/core/app/entities/queue_entities.py | 12 ++ api/core/app/entities/task_entities.py | 6 + api/core/prompt/advanced_prompt_transform.py | 10 +- api/core/prompt/prompt_transform.py | 8 +- api/core/workflow/enums.py | 1 + .../event_management/event_handlers.py | 4 +- .../graph_engine/layers/persistence.py | 3 + api/core/workflow/graph_events/base.py | 6 + api/core/workflow/nodes/agent/agent_node.py | 110 ++++++++++++++++-- api/core/workflow/nodes/base/node.py | 9 +- api/core/workflow/nodes/llm/llm_utils.py | 44 +++++++ api/core/workflow/nodes/llm/node.py | 37 ++---- .../parameter_extractor_node.py | 24 ++-- .../question_classifier_node.py | 14 ++- api/core/workflow/nodes/tool/entities.py | 61 ++++++---- api/core/workflow/nodes/tool/tool_node.py | 17 +-- api/tests/fixtures/pav-test-extraction.yml | 94 ++++++++++----- 25 files changed, 371 insertions(+), 134 deletions(-) diff --git a/api/core/app/apps/advanced_chat/generate_response_converter.py b/api/core/app/apps/advanced_chat/generate_response_converter.py index 02ec96f209..2c3df9e910 100644 --- a/api/core/app/apps/advanced_chat/generate_response_converter.py +++ b/api/core/app/apps/advanced_chat/generate_response_converter.py @@ -82,7 +82,7 @@ class AdvancedChatAppGenerateResponseConverter(AppGenerateResponseConverter): data = cls._error_to_stream_response(sub_stream_response.err) response_chunk.update(data) else: - response_chunk.update(sub_stream_response.model_dump(mode="json")) + response_chunk.update(sub_stream_response.model_dump(mode="json", exclude_none=True)) yield response_chunk @classmethod @@ -110,7 +110,7 @@ class AdvancedChatAppGenerateResponseConverter(AppGenerateResponseConverter): } if isinstance(sub_stream_response, MessageEndStreamResponse): - sub_stream_response_dict = sub_stream_response.model_dump(mode="json") + sub_stream_response_dict = sub_stream_response.model_dump(mode="json", exclude_none=True) metadata = sub_stream_response_dict.get("metadata", {}) sub_stream_response_dict["metadata"] = cls._get_simple_metadata(metadata) response_chunk.update(sub_stream_response_dict) @@ -120,6 +120,6 @@ class AdvancedChatAppGenerateResponseConverter(AppGenerateResponseConverter): elif isinstance(sub_stream_response, NodeStartStreamResponse | NodeFinishStreamResponse): response_chunk.update(sub_stream_response.to_ignore_detail_dict()) else: - response_chunk.update(sub_stream_response.model_dump(mode="json")) + response_chunk.update(sub_stream_response.model_dump(mode="json", exclude_none=True)) yield response_chunk diff --git a/api/core/app/apps/agent_chat/generate_response_converter.py b/api/core/app/apps/agent_chat/generate_response_converter.py index e35e9d9408..f328425fb7 100644 --- a/api/core/app/apps/agent_chat/generate_response_converter.py +++ b/api/core/app/apps/agent_chat/generate_response_converter.py @@ -81,7 +81,7 @@ class AgentChatAppGenerateResponseConverter(AppGenerateResponseConverter): data = cls._error_to_stream_response(sub_stream_response.err) response_chunk.update(data) else: - response_chunk.update(sub_stream_response.model_dump(mode="json")) + response_chunk.update(sub_stream_response.model_dump(mode="json", exclude_none=True)) yield response_chunk @classmethod @@ -109,7 +109,7 @@ class AgentChatAppGenerateResponseConverter(AppGenerateResponseConverter): } if isinstance(sub_stream_response, MessageEndStreamResponse): - sub_stream_response_dict = sub_stream_response.model_dump(mode="json") + sub_stream_response_dict = sub_stream_response.model_dump(mode="json", exclude_none=True) metadata = sub_stream_response_dict.get("metadata", {}) sub_stream_response_dict["metadata"] = cls._get_simple_metadata(metadata) response_chunk.update(sub_stream_response_dict) @@ -117,6 +117,6 @@ class AgentChatAppGenerateResponseConverter(AppGenerateResponseConverter): data = cls._error_to_stream_response(sub_stream_response.err) response_chunk.update(data) else: - response_chunk.update(sub_stream_response.model_dump(mode="json")) + response_chunk.update(sub_stream_response.model_dump(mode="json", exclude_none=True)) yield response_chunk diff --git a/api/core/app/apps/chat/generate_response_converter.py b/api/core/app/apps/chat/generate_response_converter.py index 3aa1161fd8..da02f6b750 100644 --- a/api/core/app/apps/chat/generate_response_converter.py +++ b/api/core/app/apps/chat/generate_response_converter.py @@ -81,7 +81,7 @@ class ChatAppGenerateResponseConverter(AppGenerateResponseConverter): data = cls._error_to_stream_response(sub_stream_response.err) response_chunk.update(data) else: - response_chunk.update(sub_stream_response.model_dump(mode="json")) + response_chunk.update(sub_stream_response.model_dump(mode="json", exclude_none=True)) yield response_chunk @classmethod @@ -109,7 +109,7 @@ class ChatAppGenerateResponseConverter(AppGenerateResponseConverter): } if isinstance(sub_stream_response, MessageEndStreamResponse): - sub_stream_response_dict = sub_stream_response.model_dump(mode="json") + sub_stream_response_dict = sub_stream_response.model_dump(mode="json", exclude_none=True) metadata = sub_stream_response_dict.get("metadata", {}) sub_stream_response_dict["metadata"] = cls._get_simple_metadata(metadata) response_chunk.update(sub_stream_response_dict) @@ -117,6 +117,6 @@ class ChatAppGenerateResponseConverter(AppGenerateResponseConverter): data = cls._error_to_stream_response(sub_stream_response.err) response_chunk.update(data) else: - response_chunk.update(sub_stream_response.model_dump(mode="json")) + response_chunk.update(sub_stream_response.model_dump(mode="json", exclude_none=True)) yield response_chunk diff --git a/api/core/app/apps/common/workflow_response_converter.py b/api/core/app/apps/common/workflow_response_converter.py index 38ecec5d30..c2ba712e7a 100644 --- a/api/core/app/apps/common/workflow_response_converter.py +++ b/api/core/app/apps/common/workflow_response_converter.py @@ -70,6 +70,8 @@ class _NodeSnapshot: """Empty string means the node is not executing inside an iteration.""" loop_id: str = "" """Empty string means the node is not executing inside a loop.""" + mention_parent_id: str = "" + """Empty string means the node is not an extractor node.""" class WorkflowResponseConverter: @@ -131,6 +133,7 @@ class WorkflowResponseConverter: start_at=event.start_at, iteration_id=event.in_iteration_id or "", loop_id=event.in_loop_id or "", + mention_parent_id=event.in_mention_parent_id or "", ) node_execution_id = NodeExecutionId(event.node_execution_id) self._node_snapshots[node_execution_id] = snapshot @@ -287,6 +290,7 @@ class WorkflowResponseConverter: created_at=int(snapshot.start_at.timestamp()), iteration_id=event.in_iteration_id, loop_id=event.in_loop_id, + mention_parent_id=event.in_mention_parent_id, agent_strategy=event.agent_strategy, ), ) @@ -373,6 +377,7 @@ class WorkflowResponseConverter: files=self.fetch_files_from_node_outputs(event.outputs or {}), iteration_id=event.in_iteration_id, loop_id=event.in_loop_id, + mention_parent_id=event.in_mention_parent_id, ), ) @@ -422,6 +427,7 @@ class WorkflowResponseConverter: files=self.fetch_files_from_node_outputs(event.outputs or {}), iteration_id=event.in_iteration_id, loop_id=event.in_loop_id, + mention_parent_id=event.in_mention_parent_id, retry_index=event.retry_index, ), ) diff --git a/api/core/app/apps/completion/generate_response_converter.py b/api/core/app/apps/completion/generate_response_converter.py index a4f574642d..cff0235b66 100644 --- a/api/core/app/apps/completion/generate_response_converter.py +++ b/api/core/app/apps/completion/generate_response_converter.py @@ -79,7 +79,7 @@ class CompletionAppGenerateResponseConverter(AppGenerateResponseConverter): data = cls._error_to_stream_response(sub_stream_response.err) response_chunk.update(data) else: - response_chunk.update(sub_stream_response.model_dump(mode="json")) + response_chunk.update(sub_stream_response.model_dump(mode="json", exclude_none=True)) yield response_chunk @classmethod @@ -106,7 +106,7 @@ class CompletionAppGenerateResponseConverter(AppGenerateResponseConverter): } if isinstance(sub_stream_response, MessageEndStreamResponse): - sub_stream_response_dict = sub_stream_response.model_dump(mode="json") + sub_stream_response_dict = sub_stream_response.model_dump(mode="json", exclude_none=True) metadata = sub_stream_response_dict.get("metadata", {}) if not isinstance(metadata, dict): metadata = {} @@ -116,6 +116,6 @@ class CompletionAppGenerateResponseConverter(AppGenerateResponseConverter): data = cls._error_to_stream_response(sub_stream_response.err) response_chunk.update(data) else: - response_chunk.update(sub_stream_response.model_dump(mode="json")) + response_chunk.update(sub_stream_response.model_dump(mode="json", exclude_none=True)) yield response_chunk diff --git a/api/core/app/apps/pipeline/generate_response_converter.py b/api/core/app/apps/pipeline/generate_response_converter.py index cfacd8640d..d1aee51293 100644 --- a/api/core/app/apps/pipeline/generate_response_converter.py +++ b/api/core/app/apps/pipeline/generate_response_converter.py @@ -60,7 +60,7 @@ class WorkflowAppGenerateResponseConverter(AppGenerateResponseConverter): data = cls._error_to_stream_response(sub_stream_response.err) response_chunk.update(cast(dict, data)) else: - response_chunk.update(sub_stream_response.model_dump()) + response_chunk.update(sub_stream_response.model_dump(exclude_none=True)) yield response_chunk @classmethod @@ -91,5 +91,5 @@ class WorkflowAppGenerateResponseConverter(AppGenerateResponseConverter): elif isinstance(sub_stream_response, NodeStartStreamResponse | NodeFinishStreamResponse): response_chunk.update(cast(dict, sub_stream_response.to_ignore_detail_dict())) else: - response_chunk.update(sub_stream_response.model_dump()) + response_chunk.update(sub_stream_response.model_dump(exclude_none=True)) yield response_chunk diff --git a/api/core/app/apps/workflow/generate_response_converter.py b/api/core/app/apps/workflow/generate_response_converter.py index c64f44a603..6d774be6f7 100644 --- a/api/core/app/apps/workflow/generate_response_converter.py +++ b/api/core/app/apps/workflow/generate_response_converter.py @@ -60,7 +60,7 @@ class WorkflowAppGenerateResponseConverter(AppGenerateResponseConverter): data = cls._error_to_stream_response(sub_stream_response.err) response_chunk.update(data) else: - response_chunk.update(sub_stream_response.model_dump(mode="json")) + response_chunk.update(sub_stream_response.model_dump(mode="json", exclude_none=True)) yield response_chunk @classmethod @@ -91,5 +91,5 @@ class WorkflowAppGenerateResponseConverter(AppGenerateResponseConverter): elif isinstance(sub_stream_response, NodeStartStreamResponse | NodeFinishStreamResponse): response_chunk.update(sub_stream_response.to_ignore_detail_dict()) else: - response_chunk.update(sub_stream_response.model_dump(mode="json")) + response_chunk.update(sub_stream_response.model_dump(mode="json", exclude_none=True)) yield response_chunk diff --git a/api/core/app/apps/workflow_app_runner.py b/api/core/app/apps/workflow_app_runner.py index 7adf3504ac..cd31b2706d 100644 --- a/api/core/app/apps/workflow_app_runner.py +++ b/api/core/app/apps/workflow_app_runner.py @@ -385,6 +385,7 @@ class WorkflowBasedAppRunner: start_at=event.start_at, in_iteration_id=event.in_iteration_id, in_loop_id=event.in_loop_id, + in_mention_parent_id=event.in_mention_parent_id, inputs=inputs, process_data=process_data, outputs=outputs, @@ -405,6 +406,7 @@ class WorkflowBasedAppRunner: start_at=event.start_at, in_iteration_id=event.in_iteration_id, in_loop_id=event.in_loop_id, + in_mention_parent_id=event.in_mention_parent_id, agent_strategy=event.agent_strategy, provider_type=event.provider_type, provider_id=event.provider_id, @@ -428,6 +430,7 @@ class WorkflowBasedAppRunner: execution_metadata=execution_metadata, in_iteration_id=event.in_iteration_id, in_loop_id=event.in_loop_id, + in_mention_parent_id=event.in_mention_parent_id, ) ) elif isinstance(event, NodeRunFailedEvent): @@ -444,6 +447,7 @@ class WorkflowBasedAppRunner: execution_metadata=event.node_run_result.metadata, in_iteration_id=event.in_iteration_id, in_loop_id=event.in_loop_id, + in_mention_parent_id=event.in_mention_parent_id, ) ) elif isinstance(event, NodeRunExceptionEvent): @@ -460,6 +464,7 @@ class WorkflowBasedAppRunner: execution_metadata=event.node_run_result.metadata, in_iteration_id=event.in_iteration_id, in_loop_id=event.in_loop_id, + in_mention_parent_id=event.in_mention_parent_id, ) ) elif isinstance(event, NodeRunStreamChunkEvent): @@ -469,6 +474,7 @@ class WorkflowBasedAppRunner: from_variable_selector=list(event.selector), in_iteration_id=event.in_iteration_id, in_loop_id=event.in_loop_id, + in_mention_parent_id=event.in_mention_parent_id, ) ) elif isinstance(event, NodeRunRetrieverResourceEvent): @@ -477,6 +483,7 @@ class WorkflowBasedAppRunner: retriever_resources=event.retriever_resources, in_iteration_id=event.in_iteration_id, in_loop_id=event.in_loop_id, + in_mention_parent_id=event.in_mention_parent_id, ) ) elif isinstance(event, NodeRunAgentLogEvent): diff --git a/api/core/app/entities/queue_entities.py b/api/core/app/entities/queue_entities.py index 77d6bf03b4..bbc3a08867 100644 --- a/api/core/app/entities/queue_entities.py +++ b/api/core/app/entities/queue_entities.py @@ -190,6 +190,8 @@ class QueueTextChunkEvent(AppQueueEvent): """iteration id if node is in iteration""" in_loop_id: str | None = None """loop id if node is in loop""" + in_mention_parent_id: str | None = None + """parent node id if this is an extractor node event""" class QueueAgentMessageEvent(AppQueueEvent): @@ -229,6 +231,8 @@ class QueueRetrieverResourcesEvent(AppQueueEvent): """iteration id if node is in iteration""" in_loop_id: str | None = None """loop id if node is in loop""" + in_mention_parent_id: str | None = None + """parent node id if this is an extractor node event""" class QueueAnnotationReplyEvent(AppQueueEvent): @@ -306,6 +310,8 @@ class QueueNodeStartedEvent(AppQueueEvent): node_run_index: int = 1 # FIXME(-LAN-): may not used in_iteration_id: str | None = None in_loop_id: str | None = None + in_mention_parent_id: str | None = None + """parent node id if this is an extractor node event""" start_at: datetime agent_strategy: AgentNodeStrategyInit | None = None @@ -328,6 +334,8 @@ class QueueNodeSucceededEvent(AppQueueEvent): """iteration id if node is in iteration""" in_loop_id: str | None = None """loop id if node is in loop""" + in_mention_parent_id: str | None = None + """parent node id if this is an extractor node event""" start_at: datetime inputs: Mapping[str, object] = Field(default_factory=dict) @@ -383,6 +391,8 @@ class QueueNodeExceptionEvent(AppQueueEvent): """iteration id if node is in iteration""" in_loop_id: str | None = None """loop id if node is in loop""" + in_mention_parent_id: str | None = None + """parent node id if this is an extractor node event""" start_at: datetime inputs: Mapping[str, object] = Field(default_factory=dict) @@ -407,6 +417,8 @@ class QueueNodeFailedEvent(AppQueueEvent): """iteration id if node is in iteration""" in_loop_id: str | None = None """loop id if node is in loop""" + in_mention_parent_id: str | None = None + """parent node id if this is an extractor node event""" start_at: datetime inputs: Mapping[str, object] = Field(default_factory=dict) diff --git a/api/core/app/entities/task_entities.py b/api/core/app/entities/task_entities.py index 79a5e657b3..1ebcea7771 100644 --- a/api/core/app/entities/task_entities.py +++ b/api/core/app/entities/task_entities.py @@ -262,6 +262,7 @@ class NodeStartStreamResponse(StreamResponse): extras: dict[str, object] = Field(default_factory=dict) iteration_id: str | None = None loop_id: str | None = None + mention_parent_id: str | None = None agent_strategy: AgentNodeStrategyInit | None = None event: StreamEvent = StreamEvent.NODE_STARTED @@ -285,6 +286,7 @@ class NodeStartStreamResponse(StreamResponse): "extras": {}, "iteration_id": self.data.iteration_id, "loop_id": self.data.loop_id, + "mention_parent_id": self.data.mention_parent_id, }, } @@ -320,6 +322,7 @@ class NodeFinishStreamResponse(StreamResponse): files: Sequence[Mapping[str, Any]] | None = [] iteration_id: str | None = None loop_id: str | None = None + mention_parent_id: str | None = None event: StreamEvent = StreamEvent.NODE_FINISHED workflow_run_id: str @@ -349,6 +352,7 @@ class NodeFinishStreamResponse(StreamResponse): "files": [], "iteration_id": self.data.iteration_id, "loop_id": self.data.loop_id, + "mention_parent_id": self.data.mention_parent_id, }, } @@ -384,6 +388,7 @@ class NodeRetryStreamResponse(StreamResponse): files: Sequence[Mapping[str, Any]] | None = [] iteration_id: str | None = None loop_id: str | None = None + mention_parent_id: str | None = None retry_index: int = 0 event: StreamEvent = StreamEvent.NODE_RETRY @@ -414,6 +419,7 @@ class NodeRetryStreamResponse(StreamResponse): "files": [], "iteration_id": self.data.iteration_id, "loop_id": self.data.loop_id, + "mention_parent_id": self.data.mention_parent_id, "retry_index": self.data.retry_index, }, } diff --git a/api/core/prompt/advanced_prompt_transform.py b/api/core/prompt/advanced_prompt_transform.py index d74b2bddf5..ffc2bb0083 100644 --- a/api/core/prompt/advanced_prompt_transform.py +++ b/api/core/prompt/advanced_prompt_transform.py @@ -5,7 +5,7 @@ from core.app.entities.app_invoke_entities import ModelConfigWithCredentialsEnti from core.file import file_manager from core.file.models import File from core.helper.code_executor.jinja2.jinja2_formatter import Jinja2Formatter -from core.memory.token_buffer_memory import TokenBufferMemory +from core.memory.base import BaseMemory from core.model_runtime.entities import ( AssistantPromptMessage, PromptMessage, @@ -43,7 +43,7 @@ class AdvancedPromptTransform(PromptTransform): files: Sequence[File], context: str | None, memory_config: MemoryConfig | None, - memory: TokenBufferMemory | None, + memory: BaseMemory | None, model_config: ModelConfigWithCredentialsEntity, image_detail_config: ImagePromptMessageContent.DETAIL | None = None, ) -> list[PromptMessage]: @@ -84,7 +84,7 @@ class AdvancedPromptTransform(PromptTransform): files: Sequence[File], context: str | None, memory_config: MemoryConfig | None, - memory: TokenBufferMemory | None, + memory: BaseMemory | None, model_config: ModelConfigWithCredentialsEntity, image_detail_config: ImagePromptMessageContent.DETAIL | None = None, ) -> list[PromptMessage]: @@ -145,7 +145,7 @@ class AdvancedPromptTransform(PromptTransform): files: Sequence[File], context: str | None, memory_config: MemoryConfig | None, - memory: TokenBufferMemory | None, + memory: BaseMemory | None, model_config: ModelConfigWithCredentialsEntity, image_detail_config: ImagePromptMessageContent.DETAIL | None = None, ) -> list[PromptMessage]: @@ -270,7 +270,7 @@ class AdvancedPromptTransform(PromptTransform): def _set_histories_variable( self, - memory: TokenBufferMemory, + memory: BaseMemory, memory_config: MemoryConfig, raw_prompt: str, role_prefix: MemoryConfig.RolePrefix, diff --git a/api/core/prompt/prompt_transform.py b/api/core/prompt/prompt_transform.py index a6e873d587..c0031de6bf 100644 --- a/api/core/prompt/prompt_transform.py +++ b/api/core/prompt/prompt_transform.py @@ -1,7 +1,7 @@ from typing import Any from core.app.entities.app_invoke_entities import ModelConfigWithCredentialsEntity -from core.memory.token_buffer_memory import TokenBufferMemory +from core.memory.base import BaseMemory from core.model_manager import ModelInstance from core.model_runtime.entities.message_entities import PromptMessage from core.model_runtime.entities.model_entities import ModelPropertyKey @@ -11,7 +11,7 @@ from core.prompt.entities.advanced_prompt_entities import MemoryConfig class PromptTransform: def _append_chat_histories( self, - memory: TokenBufferMemory, + memory: BaseMemory, memory_config: MemoryConfig, prompt_messages: list[PromptMessage], model_config: ModelConfigWithCredentialsEntity, @@ -52,7 +52,7 @@ class PromptTransform: def _get_history_messages_from_memory( self, - memory: TokenBufferMemory, + memory: BaseMemory, memory_config: MemoryConfig, max_token_limit: int, human_prefix: str | None = None, @@ -73,7 +73,7 @@ class PromptTransform: return memory.get_history_prompt_text(**kwargs) def _get_history_messages_list_from_memory( - self, memory: TokenBufferMemory, memory_config: MemoryConfig, max_token_limit: int + self, memory: BaseMemory, memory_config: MemoryConfig, max_token_limit: int ) -> list[PromptMessage]: """Get memory messages.""" return list( diff --git a/api/core/workflow/enums.py b/api/core/workflow/enums.py index fb152d184f..6fc596fc05 100644 --- a/api/core/workflow/enums.py +++ b/api/core/workflow/enums.py @@ -253,6 +253,7 @@ class WorkflowNodeExecutionMetadataKey(StrEnum): LOOP_VARIABLE_MAP = "loop_variable_map" # single loop variable output DATASOURCE_INFO = "datasource_info" COMPLETED_REASON = "completed_reason" # completed reason for loop node + MENTION_PARENT_ID = "mention_parent_id" # parent node id for extractor nodes class WorkflowNodeExecutionStatus(StrEnum): diff --git a/api/core/workflow/graph_engine/event_management/event_handlers.py b/api/core/workflow/graph_engine/event_management/event_handlers.py index c90faf6e5e..9f2d8bcff4 100644 --- a/api/core/workflow/graph_engine/event_management/event_handlers.py +++ b/api/core/workflow/graph_engine/event_management/event_handlers.py @@ -93,8 +93,8 @@ class EventHandler: Args: event: The event to handle """ - # Events in loops or iterations are always collected - if event.in_loop_id or event.in_iteration_id: + # Events in loops, iterations, or extractor groups are always collected + if event.in_loop_id or event.in_iteration_id or event.in_mention_parent_id: self._event_collector.collect(event) return return self._dispatch(event) diff --git a/api/core/workflow/graph_engine/layers/persistence.py b/api/core/workflow/graph_engine/layers/persistence.py index e81df4f3b7..6f7c76defe 100644 --- a/api/core/workflow/graph_engine/layers/persistence.py +++ b/api/core/workflow/graph_engine/layers/persistence.py @@ -68,6 +68,7 @@ class _NodeRuntimeSnapshot: predecessor_node_id: str | None iteration_id: str | None loop_id: str | None + mention_parent_id: str | None created_at: datetime @@ -230,6 +231,7 @@ class WorkflowPersistenceLayer(GraphEngineLayer): metadata = { WorkflowNodeExecutionMetadataKey.ITERATION_ID: event.in_iteration_id, WorkflowNodeExecutionMetadataKey.LOOP_ID: event.in_loop_id, + WorkflowNodeExecutionMetadataKey.MENTION_PARENT_ID: event.in_mention_parent_id, } domain_execution = WorkflowNodeExecution( @@ -256,6 +258,7 @@ class WorkflowPersistenceLayer(GraphEngineLayer): predecessor_node_id=event.predecessor_node_id, iteration_id=event.in_iteration_id, loop_id=event.in_loop_id, + mention_parent_id=event.in_mention_parent_id, created_at=event.start_at, ) self._node_snapshots[event.id] = snapshot diff --git a/api/core/workflow/graph_events/base.py b/api/core/workflow/graph_events/base.py index 3714679201..16dd49c7ad 100644 --- a/api/core/workflow/graph_events/base.py +++ b/api/core/workflow/graph_events/base.py @@ -21,6 +21,12 @@ class GraphNodeEventBase(GraphEngineEvent): """iteration id if node is in iteration""" in_loop_id: str | None = None """loop id if node is in loop""" + in_mention_parent_id: str | None = None + """Parent node id if this is an extractor node event. + + When set, indicates this event belongs to an extractor node that + is extracting values for the specified parent node. + """ # The version of the node, or "1" if not specified. node_version: str = "1" diff --git a/api/core/workflow/nodes/agent/agent_node.py b/api/core/workflow/nodes/agent/agent_node.py index 234651ce96..ebbacafcff 100644 --- a/api/core/workflow/nodes/agent/agent_node.py +++ b/api/core/workflow/nodes/agent/agent_node.py @@ -12,11 +12,14 @@ from sqlalchemy.orm import Session from core.agent.entities import AgentToolEntity from core.agent.plugin_entities import AgentStrategyParameter from core.file import File, FileTransferMethod +from core.memory.base import BaseMemory +from core.memory.node_token_buffer_memory import NodeTokenBufferMemory from core.memory.token_buffer_memory import TokenBufferMemory from core.model_manager import ModelInstance, ModelManager from core.model_runtime.entities.llm_entities import LLMUsage, LLMUsageMetadata from core.model_runtime.entities.model_entities import AIModelEntity, ModelType from core.model_runtime.utils.encoders import jsonable_encoder +from core.prompt.entities.advanced_prompt_entities import MemoryMode from core.provider_manager import ProviderManager from core.tools.entities.tool_entities import ( ToolIdentity, @@ -136,6 +139,9 @@ class AgentNode(Node[AgentNodeData]): ) return + # Fetch memory for node memory saving + memory = self._fetch_memory_for_save() + try: yield from self._transform_message( messages=message_stream, @@ -149,6 +155,7 @@ class AgentNode(Node[AgentNodeData]): node_type=self.node_type, node_id=self._node_id, node_execution_id=self.id, + memory=memory, ) except PluginDaemonClientSideError as e: transform_error = AgentMessageTransformError( @@ -395,8 +402,20 @@ class AgentNode(Node[AgentNodeData]): icon = None return icon - def _fetch_memory(self, model_instance: ModelInstance) -> TokenBufferMemory | None: - # get conversation id + def _fetch_memory(self, model_instance: ModelInstance) -> BaseMemory | None: + """ + Fetch memory based on configuration mode. + + Returns TokenBufferMemory for conversation mode (default), + or NodeTokenBufferMemory for node mode (Chatflow only). + """ + node_data = self.node_data + memory_config = node_data.memory + + if not memory_config: + return None + + # get conversation id (required for both modes in Chatflow) conversation_id_variable = self.graph_runtime_state.variable_pool.get( ["sys", SystemVariableKey.CONVERSATION_ID] ) @@ -404,16 +423,26 @@ class AgentNode(Node[AgentNodeData]): return None conversation_id = conversation_id_variable.value - with Session(db.engine, expire_on_commit=False) as session: - stmt = select(Conversation).where(Conversation.app_id == self.app_id, Conversation.id == conversation_id) - conversation = session.scalar(stmt) - - if not conversation: - return None - - memory = TokenBufferMemory(conversation=conversation, model_instance=model_instance) - - return memory + # Return appropriate memory type based on mode + if memory_config.mode == MemoryMode.NODE: + # Node-level memory (Chatflow only) + return NodeTokenBufferMemory( + app_id=self.app_id, + conversation_id=conversation_id, + node_id=self._node_id, + tenant_id=self.tenant_id, + model_instance=model_instance, + ) + else: + # Conversation-level memory (default) + with Session(db.engine, expire_on_commit=False) as session: + stmt = select(Conversation).where( + Conversation.app_id == self.app_id, Conversation.id == conversation_id + ) + conversation = session.scalar(stmt) + if not conversation: + return None + return TokenBufferMemory(conversation=conversation, model_instance=model_instance) def _fetch_model(self, value: dict[str, Any]) -> tuple[ModelInstance, AIModelEntity | None]: provider_manager = ProviderManager() @@ -457,6 +486,47 @@ class AgentNode(Node[AgentNodeData]): else: return [tool for tool in tools if tool.get("type") != ToolProviderType.MCP] + def _fetch_memory_for_save(self) -> BaseMemory | None: + """ + Fetch memory instance for saving node memory. + This is a simplified version that doesn't require model_instance. + """ + from core.model_manager import ModelManager + from core.model_runtime.entities.model_entities import ModelType + + node_data = self.node_data + if not node_data.memory: + return None + + # Get conversation_id + conversation_id_var = self.graph_runtime_state.variable_pool.get(["sys", SystemVariableKey.CONVERSATION_ID]) + if not isinstance(conversation_id_var, StringSegment): + return None + conversation_id = conversation_id_var.value + + # Return appropriate memory type based on mode + if node_data.memory.mode == MemoryMode.NODE: + # For node memory, we need a model_instance for token counting + # Use a simple default model for this purpose + try: + model_instance = ModelManager().get_default_model_instance( + tenant_id=self.tenant_id, + model_type=ModelType.LLM, + ) + except Exception: + return None + + return NodeTokenBufferMemory( + app_id=self.app_id, + conversation_id=conversation_id, + node_id=self._node_id, + tenant_id=self.tenant_id, + model_instance=model_instance, + ) + else: + # Conversation-level memory doesn't need saving here + return None + def _transform_message( self, messages: Generator[ToolInvokeMessage, None, None], @@ -467,6 +537,7 @@ class AgentNode(Node[AgentNodeData]): node_type: NodeType, node_id: str, node_execution_id: str, + memory: BaseMemory | None = None, ) -> Generator[NodeEventBase, None, None]: """ Convert ToolInvokeMessages into tuple[plain_text, files] @@ -711,6 +782,21 @@ class AgentNode(Node[AgentNodeData]): is_final=True, ) + # Save to node memory if in node memory mode + from core.workflow.nodes.llm import llm_utils + + # Get user query from sys.query + user_query_var = self.graph_runtime_state.variable_pool.get(["sys", SystemVariableKey.QUERY]) + user_query = user_query_var.text if user_query_var else "" + + llm_utils.save_node_memory( + memory=memory, + variable_pool=self.graph_runtime_state.variable_pool, + user_query=user_query, + assistant_response=text, + assistant_files=files, + ) + yield StreamCompletedEvent( node_run_result=NodeRunResult( status=WorkflowNodeExecutionStatus.SUCCEEDED, diff --git a/api/core/workflow/nodes/base/node.py b/api/core/workflow/nodes/base/node.py index 50314ea630..d4a8a92569 100644 --- a/api/core/workflow/nodes/base/node.py +++ b/api/core/workflow/nodes/base/node.py @@ -332,12 +332,17 @@ class Node(Generic[NodeDataT]): # Execute and process extractor node events for event in extractor_node.run(): + # Tag event with parent node id for stream ordering and history tracking + if isinstance(event, GraphNodeEventBase): + event.in_mention_parent_id = self._node_id + if isinstance(event, NodeRunSucceededEvent): # Store extractor node outputs in variable pool - outputs = event.node_run_result.outputs + outputs: Mapping[str, Any] = event.node_run_result.outputs for variable_name, variable_value in outputs.items(): self.graph_runtime_state.variable_pool.add((node_id, variable_name), variable_value) - yield event + if not isinstance(event, NodeRunStreamChunkEvent): + yield event def run(self) -> Generator[GraphNodeEventBase, None, None]: execution_id = self.ensure_execution_id() diff --git a/api/core/workflow/nodes/llm/llm_utils.py b/api/core/workflow/nodes/llm/llm_utils.py index 2a5e63d354..9b170a237b 100644 --- a/api/core/workflow/nodes/llm/llm_utils.py +++ b/api/core/workflow/nodes/llm/llm_utils.py @@ -139,6 +139,50 @@ def fetch_memory( return TokenBufferMemory(conversation=conversation, model_instance=model_instance) +def save_node_memory( + memory: BaseMemory | None, + variable_pool: VariablePool, + user_query: str, + assistant_response: str, + user_files: Sequence["File"] | None = None, + assistant_files: Sequence["File"] | None = None, +) -> None: + """ + Save dialogue turn to node memory if applicable. + + This function handles the storage logic for NodeTokenBufferMemory. + For TokenBufferMemory (conversation-level), no action is taken as it uses + the Message table which is managed elsewhere. + + :param memory: Memory instance (NodeTokenBufferMemory or TokenBufferMemory) + :param variable_pool: Variable pool containing system variables + :param user_query: User's input text + :param assistant_response: Assistant's response text + :param user_files: Files attached by user (optional) + :param assistant_files: Files generated by assistant (optional) + """ + if not isinstance(memory, NodeTokenBufferMemory): + return + + # Get workflow_run_id as the key for this execution + workflow_run_id_var = variable_pool.get(["sys", SystemVariableKey.WORKFLOW_EXECUTION_ID]) + if not isinstance(workflow_run_id_var, StringSegment): + return + + workflow_run_id = workflow_run_id_var.value + if not workflow_run_id: + return + + memory.add_messages( + workflow_run_id=workflow_run_id, + user_content=user_query, + user_files=list(user_files) if user_files else None, + assistant_content=assistant_response, + assistant_files=list(assistant_files) if assistant_files else None, + ) + memory.flush() + + def deduct_llm_quota(tenant_id: str, model_instance: ModelInstance, usage: LLMUsage): provider_model_bundle = model_instance.provider_model_bundle provider_configuration = provider_model_bundle.configuration diff --git a/api/core/workflow/nodes/llm/node.py b/api/core/workflow/nodes/llm/node.py index 02ab4ee7a0..5777d831d5 100644 --- a/api/core/workflow/nodes/llm/node.py +++ b/api/core/workflow/nodes/llm/node.py @@ -17,7 +17,6 @@ from core.helper.code_executor import CodeExecutor, CodeLanguage from core.llm_generator.output_parser.errors import OutputParserError from core.llm_generator.output_parser.structured_output import invoke_llm_with_structured_output from core.memory.base import BaseMemory -from core.memory.node_token_buffer_memory import NodeTokenBufferMemory from core.model_manager import ModelInstance, ModelManager from core.model_runtime.entities import ( ImagePromptMessageContent, @@ -334,32 +333,16 @@ class LLMNode(Node[LLMNodeData]): outputs["files"] = ArrayFileSegment(value=self._file_outputs) # Write to Node Memory if in node memory mode - if isinstance(memory, NodeTokenBufferMemory): - # Get workflow_run_id as the key for this execution - workflow_run_id_var = variable_pool.get(["sys", SystemVariableKey.WORKFLOW_EXECUTION_ID]) - workflow_run_id = workflow_run_id_var.value if isinstance(workflow_run_id_var, StringSegment) else "" - - if workflow_run_id: - # Resolve the query template to get actual user content - # query may be a template like "{{#sys.query#}}" or "{{#node_id.output#}}" - actual_query = variable_pool.convert_template(query or "").text - - # Get user files from sys.files - user_files_var = variable_pool.get(["sys", SystemVariableKey.FILES]) - user_files: list[File] = [] - if isinstance(user_files_var, ArrayFileSegment): - user_files = list(user_files_var.value) - elif isinstance(user_files_var, FileSegment): - user_files = [user_files_var.value] - - memory.add_messages( - workflow_run_id=workflow_run_id, - user_content=actual_query, - user_files=user_files, - assistant_content=clean_text, - assistant_files=self._file_outputs, - ) - memory.flush() + # Resolve the query template to get actual user content + actual_query = variable_pool.convert_template(query or "").text + llm_utils.save_node_memory( + memory=memory, + variable_pool=variable_pool, + user_query=actual_query, + assistant_response=clean_text, + user_files=files, + assistant_files=self._file_outputs, + ) # Send final chunk event to indicate streaming is complete yield StreamChunkEvent( diff --git a/api/core/workflow/nodes/parameter_extractor/parameter_extractor_node.py b/api/core/workflow/nodes/parameter_extractor/parameter_extractor_node.py index 08e0542d61..ddb48de145 100644 --- a/api/core/workflow/nodes/parameter_extractor/parameter_extractor_node.py +++ b/api/core/workflow/nodes/parameter_extractor/parameter_extractor_node.py @@ -7,7 +7,7 @@ from typing import Any, cast from core.app.entities.app_invoke_entities import ModelConfigWithCredentialsEntity from core.file import File -from core.memory.token_buffer_memory import TokenBufferMemory +from core.memory.base import BaseMemory from core.model_manager import ModelInstance from core.model_runtime.entities import ImagePromptMessageContent from core.model_runtime.entities.llm_entities import LLMUsage @@ -145,8 +145,10 @@ class ParameterExtractorNode(Node[ParameterExtractorNodeData]): memory = llm_utils.fetch_memory( variable_pool=variable_pool, app_id=self.app_id, + tenant_id=self.tenant_id, node_data_memory=node_data.memory, model_instance=model_instance, + node_id=self._node_id, ) if ( @@ -244,6 +246,14 @@ class ParameterExtractorNode(Node[ParameterExtractorNodeData]): # transform result into standard format result = self._transform_result(data=node_data, result=result or {}) + # Save to node memory if in node memory mode + llm_utils.save_node_memory( + memory=memory, + variable_pool=variable_pool, + user_query=query, + assistant_response=json.dumps(result, ensure_ascii=False), + ) + return NodeRunResult( status=WorkflowNodeExecutionStatus.SUCCEEDED, inputs=inputs, @@ -299,7 +309,7 @@ class ParameterExtractorNode(Node[ParameterExtractorNodeData]): query: str, variable_pool: VariablePool, model_config: ModelConfigWithCredentialsEntity, - memory: TokenBufferMemory | None, + memory: BaseMemory | None, files: Sequence[File], vision_detail: ImagePromptMessageContent.DETAIL | None = None, ) -> tuple[list[PromptMessage], list[PromptMessageTool]]: @@ -381,7 +391,7 @@ class ParameterExtractorNode(Node[ParameterExtractorNodeData]): query: str, variable_pool: VariablePool, model_config: ModelConfigWithCredentialsEntity, - memory: TokenBufferMemory | None, + memory: BaseMemory | None, files: Sequence[File], vision_detail: ImagePromptMessageContent.DETAIL | None = None, ) -> list[PromptMessage]: @@ -419,7 +429,7 @@ class ParameterExtractorNode(Node[ParameterExtractorNodeData]): query: str, variable_pool: VariablePool, model_config: ModelConfigWithCredentialsEntity, - memory: TokenBufferMemory | None, + memory: BaseMemory | None, files: Sequence[File], vision_detail: ImagePromptMessageContent.DETAIL | None = None, ) -> list[PromptMessage]: @@ -453,7 +463,7 @@ class ParameterExtractorNode(Node[ParameterExtractorNodeData]): query: str, variable_pool: VariablePool, model_config: ModelConfigWithCredentialsEntity, - memory: TokenBufferMemory | None, + memory: BaseMemory | None, files: Sequence[File], vision_detail: ImagePromptMessageContent.DETAIL | None = None, ) -> list[PromptMessage]: @@ -681,7 +691,7 @@ class ParameterExtractorNode(Node[ParameterExtractorNodeData]): node_data: ParameterExtractorNodeData, query: str, variable_pool: VariablePool, - memory: TokenBufferMemory | None, + memory: BaseMemory | None, max_token_limit: int = 2000, ) -> list[ChatModelMessage]: model_mode = ModelMode(node_data.model.mode) @@ -708,7 +718,7 @@ class ParameterExtractorNode(Node[ParameterExtractorNodeData]): node_data: ParameterExtractorNodeData, query: str, variable_pool: VariablePool, - memory: TokenBufferMemory | None, + memory: BaseMemory | None, max_token_limit: int = 2000, ): model_mode = ModelMode(node_data.model.mode) diff --git a/api/core/workflow/nodes/question_classifier/question_classifier_node.py b/api/core/workflow/nodes/question_classifier/question_classifier_node.py index 4a3e8e56f8..6d72fcfe25 100644 --- a/api/core/workflow/nodes/question_classifier/question_classifier_node.py +++ b/api/core/workflow/nodes/question_classifier/question_classifier_node.py @@ -4,7 +4,7 @@ from collections.abc import Mapping, Sequence from typing import TYPE_CHECKING, Any from core.app.entities.app_invoke_entities import ModelConfigWithCredentialsEntity -from core.memory.token_buffer_memory import TokenBufferMemory +from core.memory.base import BaseMemory from core.model_manager import ModelInstance from core.model_runtime.entities import LLMUsage, ModelPropertyKey, PromptMessageRole from core.model_runtime.utils.encoders import jsonable_encoder @@ -96,8 +96,10 @@ class QuestionClassifierNode(Node[QuestionClassifierNodeData]): memory = llm_utils.fetch_memory( variable_pool=variable_pool, app_id=self.app_id, + tenant_id=self.tenant_id, node_data_memory=node_data.memory, model_instance=model_instance, + node_id=self._node_id, ) # fetch instruction node_data.instruction = node_data.instruction or "" @@ -203,6 +205,14 @@ class QuestionClassifierNode(Node[QuestionClassifierNodeData]): "usage": jsonable_encoder(usage), } + # Save to node memory if in node memory mode + llm_utils.save_node_memory( + memory=memory, + variable_pool=variable_pool, + user_query=query or "", + assistant_response=f"class_name: {category_name}, class_id: {category_id}", + ) + return NodeRunResult( status=WorkflowNodeExecutionStatus.SUCCEEDED, inputs=variables, @@ -312,7 +322,7 @@ class QuestionClassifierNode(Node[QuestionClassifierNodeData]): self, node_data: QuestionClassifierNodeData, query: str, - memory: TokenBufferMemory | None, + memory: BaseMemory | None, max_token_limit: int = 2000, ): model_mode = ModelMode(node_data.model.mode) diff --git a/api/core/workflow/nodes/tool/entities.py b/api/core/workflow/nodes/tool/entities.py index 72e71b020b..b2b1cd7421 100644 --- a/api/core/workflow/nodes/tool/entities.py +++ b/api/core/workflow/nodes/tool/entities.py @@ -1,22 +1,26 @@ +import re from collections.abc import Sequence -from typing import Any, Literal, Union +from typing import Any, Literal, Self, Union -from pydantic import BaseModel, field_validator +from pydantic import BaseModel, field_validator, model_validator from pydantic_core.core_schema import ValidationInfo from core.tools.entities.tool_entities import ToolProviderType from core.workflow.nodes.base.entities import BaseNodeData +# Pattern to match a single variable reference like {{#llm.context#}} +SINGLE_VARIABLE_PATTERN = re.compile(r"^\s*\{\{#[a-zA-Z0-9_]+(?:\.[a-zA-Z_][a-zA-Z0-9_]*)+#\}\}\s*$") -class MentionValue(BaseModel): - """Value structure for mention type parameters. - Used when a tool parameter needs to be extracted from conversation context - using an extractor LLM node. +class MentionConfig(BaseModel): + """Configuration for extracting value from context variable. + + Used when a tool parameter needs to be extracted from list[PromptMessage] + context using an extractor LLM node. """ - # Variable selector for list[PromptMessage] input to extractor - variable_selector: Sequence[str] + # Instruction for the extractor LLM to extract the value + instruction: str # ID of the extractor LLM node extractor_node_id: str @@ -60,8 +64,10 @@ class ToolEntity(BaseModel): class ToolNodeData(BaseNodeData, ToolEntity): class ToolInput(BaseModel): # TODO: check this type - value: Union[Any, list[str], MentionValue] + value: Union[Any, list[str]] type: Literal["mixed", "variable", "constant", "mention"] + # Required config for mention type, extracting value from context variable + mention_config: MentionConfig | None = None @field_validator("type", mode="before") @classmethod @@ -74,6 +80,9 @@ class ToolNodeData(BaseNodeData, ToolEntity): if typ == "mixed" and not isinstance(value, str): raise ValueError("value must be a string") + elif typ == "mention": + # Skip here, will be validated in model_validator + pass elif typ == "variable": if not isinstance(value, list): raise ValueError("value must be a list") @@ -82,19 +91,31 @@ class ToolNodeData(BaseNodeData, ToolEntity): raise ValueError("value must be a list of strings") elif typ == "constant" and not isinstance(value, str | int | float | bool | dict): raise ValueError("value must be a string, int, float, bool or dict") - elif typ == "mention": - # Mention type: value should be a MentionValue or dict with required fields - if isinstance(value, MentionValue): - pass # Already validated by Pydantic - elif isinstance(value, dict): - if "extractor_node_id" not in value: - raise ValueError("value must contain extractor_node_id for mention type") - if "output_selector" not in value: - raise ValueError("value must contain output_selector for mention type") - else: - raise ValueError("value must be a MentionValue or dict for mention type") return typ + @model_validator(mode="after") + def check_mention_type(self) -> Self: + """Validate mention type with mention_config.""" + if self.type != "mention": + return self + + value = self.value + if value is None: + return self + + if not isinstance(value, str): + raise ValueError("value must be a string for mention type") + # For mention type, value must be a single variable reference + if not SINGLE_VARIABLE_PATTERN.match(value): + raise ValueError( + "For mention type, value must be a single variable reference " + "like {{#node.variable#}}, cannot contain other content" + ) + # mention_config is required for mention type + if self.mention_config is None: + raise ValueError("mention_config is required for mention type") + return self + tool_parameters: dict[str, ToolInput] # The version of the tool parameter. # If this value is None, it indicates this is a previous version diff --git a/api/core/workflow/nodes/tool/tool_node.py b/api/core/workflow/nodes/tool/tool_node.py index 7752dc0f46..549851302a 100644 --- a/api/core/workflow/nodes/tool/tool_node.py +++ b/api/core/workflow/nodes/tool/tool_node.py @@ -214,16 +214,11 @@ class ToolNode(Node[ToolNodeData]): parameter_value = variable.value elif tool_input.type == "mention": # Mention type: get value from extractor node's output - from .entities import MentionValue - - mention_value = tool_input.value - if isinstance(mention_value, MentionValue): - mention_config = mention_value.model_dump() - elif isinstance(mention_value, dict): - mention_config = mention_value - else: - raise ToolParameterError(f"Invalid mention value for parameter '{parameter_name}'") - + if tool_input.mention_config is None: + raise ToolParameterError( + f"mention_config is required for mention type parameter '{parameter_name}'" + ) + mention_config = tool_input.mention_config.model_dump() try: parameter_value, found = variable_pool.resolve_mention( mention_config, parameter_name=parameter_name @@ -524,7 +519,7 @@ class ToolNode(Node[ToolNodeData]): selector_key = ".".join(input.value) result[f"#{selector_key}#"] = input.value elif input.type == "mention": - # Mention type handled by extractor node, no direct variable reference + # Mention type: value is handled by extractor node, no direct variable reference pass elif input.type == "constant": pass diff --git a/api/tests/fixtures/pav-test-extraction.yml b/api/tests/fixtures/pav-test-extraction.yml index d1b9d55add..4c1eca2b7e 100644 --- a/api/tests/fixtures/pav-test-extraction.yml +++ b/api/tests/fixtures/pav-test-extraction.yml @@ -11,6 +11,11 @@ dependencies: value: marketplace_plugin_unique_identifier: langgenius/google:0.0.8@3efcf55ffeef9d0f77715e0afb23534952ae0cb385c051d0637e86d71199d1a6 version: null +- current_identifier: null + type: marketplace + value: + marketplace_plugin_unique_identifier: langgenius/openai:0.2.3@5a7f82fa86e28332ad51941d0b491c1e8a38ead539656442f7bf4c6129cd15fa + version: null - current_identifier: null type: marketplace value: @@ -115,7 +120,8 @@ workflow: enabled: false variable_selector: [] memory: - query_prompt_template: '' + mode: node + query_prompt_template: '{{#sys.query#}}' role_prefix: assistant: '' user: '' @@ -201,29 +207,17 @@ workflow: tool_node_version: '2' tool_parameters: query: - type: variable - value: - - ext_1 - - text + mention_config: + default_value: '' + extractor_node_id: 1767773709491_ext_query + instruction: 请从对话历史中提取用户想要搜索的关键词,只返回关键词本身 + null_strategy: use_default + output_selector: + - structured_output + - query + type: mention + value: '{{#llm.context#}}' type: tool - virtual_nodes: - - data: - model: - completion_params: - temperature: 0.7 - mode: chat - name: qwen-max - provider: langgenius/tongyi/tongyi - context: - enabled: false - prompt_template: - - role: user - text: '{{#llm.context#}}' - - role: user - text: 请从对话历史中提取用户想要搜索的关键词,只返回关键词本身,不要返回其他内容 - title: 提取搜索关键词 - id: ext_1 - type: llm height: 52 id: '1767773709491' position: @@ -237,6 +231,54 @@ workflow: targetPosition: left type: custom width: 242 + - data: + context: + enabled: false + variable_selector: [] + model: + completion_params: + temperature: 0.7 + mode: chat + name: gpt-4o-mini + provider: langgenius/openai/openai + parent_node_id: '1767773709491' + prompt_template: + - $context: + - llm + - context + id: 75d58e22-dc59-40c8-ba6f-aeb28f4f305a + - id: 18ba6710-77f5-47f4-b144-9191833bb547 + role: user + text: 请从对话历史中提取用户想要搜索的关键词,只返回关键词本身,不要返回其他内容 + selected: false + structured_output: + schema: + additionalProperties: false + properties: + query: + description: 搜索的关键词 + type: string + required: + - query + type: object + structured_output_enabled: true + title: 提取搜索关键词 + type: llm + vision: + enabled: false + height: 88 + id: 1767773709491_ext_query + position: + x: 531 + y: 382 + positionAbsolute: + x: 531 + y: 382 + selected: true + sourcePosition: right + targetPosition: left + type: custom + width: 242 - data: answer: '搜索结果: @@ -254,13 +296,13 @@ workflow: positionAbsolute: x: 984 y: 282 - selected: true + selected: false sourcePosition: right targetPosition: left type: custom width: 242 viewport: - x: 151 - y: 141.5 + x: -151 + y: 123 zoom: 1 rag_pipeline_variables: [] From 4f79d09d7bbf853bf7ed62022d6669caf04d070d Mon Sep 17 00:00:00 2001 From: Novice Date: Tue, 13 Jan 2026 16:08:32 +0800 Subject: [PATCH 36/82] chore: change the DSL design --- api/core/workflow/nodes/tool/entities.py | 41 ++++++++++++++++------ api/tests/fixtures/pav-test-extraction.yml | 9 +++-- 2 files changed, 34 insertions(+), 16 deletions(-) diff --git a/api/core/workflow/nodes/tool/entities.py b/api/core/workflow/nodes/tool/entities.py index b2b1cd7421..30bca3b7f2 100644 --- a/api/core/workflow/nodes/tool/entities.py +++ b/api/core/workflow/nodes/tool/entities.py @@ -8,8 +8,31 @@ from pydantic_core.core_schema import ValidationInfo from core.tools.entities.tool_entities import ToolProviderType from core.workflow.nodes.base.entities import BaseNodeData -# Pattern to match a single variable reference like {{#llm.context#}} -SINGLE_VARIABLE_PATTERN = re.compile(r"^\s*\{\{#[a-zA-Z0-9_]+(?:\.[a-zA-Z_][a-zA-Z0-9_]*)+#\}\}\s*$") +# Pattern to match mention value format: {{@node.context@}}instruction +# The placeholder {{@node.context@}} must appear at the beginning +# Format: {{@agent_node_id.context@}} where agent_node_id is dynamic, context is fixed +MENTION_VALUE_PATTERN = re.compile(r"^\{\{@([a-zA-Z0-9_]+)\.context@\}\}(.*)$", re.DOTALL) + + +def parse_mention_value(value: str) -> tuple[str, str]: + """Parse mention value into (node_id, instruction). + + Args: + value: The mention value string like "{{@llm.context@}}extract keywords" + + Returns: + Tuple of (node_id, instruction) + + Raises: + ValueError: If value format is invalid + """ + match = MENTION_VALUE_PATTERN.match(value) + if not match: + raise ValueError( + "For mention type, value must start with {{@node.context@}} placeholder, " + "e.g., '{{@llm.context@}}extract keywords'" + ) + return match.group(1), match.group(2) class MentionConfig(BaseModel): @@ -17,10 +40,9 @@ class MentionConfig(BaseModel): Used when a tool parameter needs to be extracted from list[PromptMessage] context using an extractor LLM node. - """ - # Instruction for the extractor LLM to extract the value - instruction: str + Note: instruction is embedded in the value field as "{{@node.context@}}instruction" + """ # ID of the extractor LLM node extractor_node_id: str @@ -105,12 +127,9 @@ class ToolNodeData(BaseNodeData, ToolEntity): if not isinstance(value, str): raise ValueError("value must be a string for mention type") - # For mention type, value must be a single variable reference - if not SINGLE_VARIABLE_PATTERN.match(value): - raise ValueError( - "For mention type, value must be a single variable reference " - "like {{#node.variable#}}, cannot contain other content" - ) + # For mention type, value must match format: {{@node.context@}}instruction + # This will raise ValueError if format is invalid + parse_mention_value(value) # mention_config is required for mention type if self.mention_config is None: raise ValueError("mention_config is required for mention type") diff --git a/api/tests/fixtures/pav-test-extraction.yml b/api/tests/fixtures/pav-test-extraction.yml index 4c1eca2b7e..69fe73c493 100644 --- a/api/tests/fixtures/pav-test-extraction.yml +++ b/api/tests/fixtures/pav-test-extraction.yml @@ -207,16 +207,15 @@ workflow: tool_node_version: '2' tool_parameters: query: + type: mention + value: '{{@llm.context@}}请从对话历史中提取用户想要搜索的关键词,只返回关键词本身' mention_config: - default_value: '' extractor_node_id: 1767773709491_ext_query - instruction: 请从对话历史中提取用户想要搜索的关键词,只返回关键词本身 - null_strategy: use_default output_selector: - structured_output - query - type: mention - value: '{{#llm.context#}}' + null_strategy: use_default + default_value: '' type: tool height: 52 id: '1767773709491' From 9b961fb41e5301f821c8c4ba92b63a125bc2e9c2 Mon Sep 17 00:00:00 2001 From: Novice Date: Tue, 13 Jan 2026 16:48:01 +0800 Subject: [PATCH 37/82] feat: structured output support file type --- .../llm_generator/output_parser/file_ref.py | 188 ++++++++++++ .../output_parser/structured_output.py | 44 ++- api/core/workflow/nodes/llm/node.py | 34 ++- api/tests/fixtures/file output schema.yml | 181 ++++++++++++ .../output_parser/test_file_ref.py | 269 ++++++++++++++++++ 5 files changed, 709 insertions(+), 7 deletions(-) create mode 100644 api/core/llm_generator/output_parser/file_ref.py create mode 100644 api/tests/fixtures/file output schema.yml create mode 100644 api/tests/unit_tests/core/llm_generator/output_parser/test_file_ref.py diff --git a/api/core/llm_generator/output_parser/file_ref.py b/api/core/llm_generator/output_parser/file_ref.py new file mode 100644 index 0000000000..83489e6a79 --- /dev/null +++ b/api/core/llm_generator/output_parser/file_ref.py @@ -0,0 +1,188 @@ +""" +File reference detection and conversion for structured output. + +This module provides utilities to: +1. Detect file reference fields in JSON Schema (format: "dify-file-ref") +2. Convert file ID strings to File objects after LLM returns +""" + +import uuid +from collections.abc import Mapping +from typing import Any + +from core.file import File +from core.variables.segments import ArrayFileSegment, FileSegment +from factories.file_factory import build_from_mapping + +FILE_REF_FORMAT = "dify-file-ref" + + +def is_file_ref_property(schema: dict) -> bool: + """Check if a schema property is a file reference.""" + return schema.get("type") == "string" and schema.get("format") == FILE_REF_FORMAT + + +def detect_file_ref_fields(schema: Mapping[str, Any], path: str = "") -> list[str]: + """ + Recursively detect file reference fields in schema. + + Args: + schema: JSON Schema to analyze + path: Current path in the schema (used for recursion) + + Returns: + List of JSON paths containing file refs, e.g., ["image_id", "files[*]"] + """ + file_ref_paths: list[str] = [] + schema_type = schema.get("type") + + if schema_type == "object": + for prop_name, prop_schema in schema.get("properties", {}).items(): + current_path = f"{path}.{prop_name}" if path else prop_name + + if is_file_ref_property(prop_schema): + file_ref_paths.append(current_path) + elif isinstance(prop_schema, dict): + file_ref_paths.extend(detect_file_ref_fields(prop_schema, current_path)) + + elif schema_type == "array": + items_schema = schema.get("items", {}) + array_path = f"{path}[*]" if path else "[*]" + + if is_file_ref_property(items_schema): + file_ref_paths.append(array_path) + elif isinstance(items_schema, dict): + file_ref_paths.extend(detect_file_ref_fields(items_schema, array_path)) + + return file_ref_paths + + +def convert_file_refs_in_output( + output: Mapping[str, Any], + json_schema: Mapping[str, Any], + tenant_id: str, +) -> dict[str, Any]: + """ + Convert file ID strings to File objects based on schema. + + Args: + output: The structured_output from LLM result + json_schema: The original JSON schema (to detect file ref fields) + tenant_id: Tenant ID for file lookup + + Returns: + Output with file references converted to File objects + """ + file_ref_paths = detect_file_ref_fields(json_schema) + if not file_ref_paths: + return dict(output) + + result = _deep_copy_dict(output) + + for path in file_ref_paths: + _convert_path_in_place(result, path.split("."), tenant_id) + + return result + + +def _deep_copy_dict(obj: Mapping[str, Any]) -> dict[str, Any]: + """Deep copy a mapping to a mutable dict.""" + result: dict[str, Any] = {} + for key, value in obj.items(): + if isinstance(value, Mapping): + result[key] = _deep_copy_dict(value) + elif isinstance(value, list): + result[key] = [_deep_copy_dict(item) if isinstance(item, Mapping) else item for item in value] + else: + result[key] = value + return result + + +def _convert_path_in_place(obj: dict, path_parts: list[str], tenant_id: str) -> None: + """Convert file refs at the given path in place, wrapping in Segment types.""" + if not path_parts: + return + + current = path_parts[0] + remaining = path_parts[1:] + + # Handle array notation like "files[*]" + if current.endswith("[*]"): + key = current[:-3] if current != "[*]" else None + target = obj.get(key) if key else obj + + if isinstance(target, list): + if remaining: + # Nested array with remaining path - recurse into each item + for item in target: + if isinstance(item, dict): + _convert_path_in_place(item, remaining, tenant_id) + else: + # Array of file IDs - convert all and wrap in ArrayFileSegment + files: list[File] = [] + for item in target: + file = _convert_file_id(item, tenant_id) + if file is not None: + files.append(file) + # Replace the array with ArrayFileSegment + if key: + obj[key] = ArrayFileSegment(value=files) + return + + if not remaining: + # Leaf node - convert the value and wrap in FileSegment + if current in obj: + file = _convert_file_id(obj[current], tenant_id) + if file is not None: + obj[current] = FileSegment(value=file) + else: + obj[current] = None + else: + # Recurse into nested object + if current in obj and isinstance(obj[current], dict): + _convert_path_in_place(obj[current], remaining, tenant_id) + + +def _convert_file_id(file_id: Any, tenant_id: str) -> File | None: + """ + Convert a file ID string to a File object. + + Tries multiple file sources in order: + 1. ToolFile (files generated by tools/workflows) + 2. UploadFile (files uploaded by users) + """ + if not isinstance(file_id, str): + return None + + # Validate UUID format + try: + uuid.UUID(file_id) + except ValueError: + return None + + # Try ToolFile first (files generated by tools/workflows) + try: + return build_from_mapping( + mapping={ + "transfer_method": "tool_file", + "tool_file_id": file_id, + }, + tenant_id=tenant_id, + ) + except ValueError: + pass + + # Try UploadFile (files uploaded by users) + try: + return build_from_mapping( + mapping={ + "transfer_method": "local_file", + "upload_file_id": file_id, + }, + tenant_id=tenant_id, + ) + except ValueError: + pass + + # File not found in any source + return None diff --git a/api/core/llm_generator/output_parser/structured_output.py b/api/core/llm_generator/output_parser/structured_output.py index 686529c3ca..250acf14fd 100644 --- a/api/core/llm_generator/output_parser/structured_output.py +++ b/api/core/llm_generator/output_parser/structured_output.py @@ -8,6 +8,7 @@ import json_repair from pydantic import TypeAdapter, ValidationError from core.llm_generator.output_parser.errors import OutputParserError +from core.llm_generator.output_parser.file_ref import convert_file_refs_in_output from core.llm_generator.prompts import STRUCTURED_OUTPUT_PROMPT from core.model_manager import ModelInstance from core.model_runtime.callbacks.base_callback import Callback @@ -57,6 +58,7 @@ def invoke_llm_with_structured_output( stream: Literal[True], user: str | None = None, callbacks: list[Callback] | None = None, + tenant_id: str | None = None, ) -> Generator[LLMResultChunkWithStructuredOutput, None, None]: ... @overload def invoke_llm_with_structured_output( @@ -72,6 +74,7 @@ def invoke_llm_with_structured_output( stream: Literal[False], user: str | None = None, callbacks: list[Callback] | None = None, + tenant_id: str | None = None, ) -> LLMResultWithStructuredOutput: ... @overload def invoke_llm_with_structured_output( @@ -87,6 +90,7 @@ def invoke_llm_with_structured_output( stream: bool = True, user: str | None = None, callbacks: list[Callback] | None = None, + tenant_id: str | None = None, ) -> LLMResultWithStructuredOutput | Generator[LLMResultChunkWithStructuredOutput, None, None]: ... def invoke_llm_with_structured_output( *, @@ -101,20 +105,28 @@ def invoke_llm_with_structured_output( stream: bool = True, user: str | None = None, callbacks: list[Callback] | None = None, + tenant_id: str | None = None, ) -> LLMResultWithStructuredOutput | Generator[LLMResultChunkWithStructuredOutput, None, None]: """ - Invoke large language model with structured output - 1. This method invokes model_instance.invoke_llm with json_schema - 2. Try to parse the result as structured output + Invoke large language model with structured output. + This method invokes model_instance.invoke_llm with json_schema and parses + the result as structured output. + + :param provider: model provider name + :param model_schema: model schema entity + :param model_instance: model instance to invoke :param prompt_messages: prompt messages - :param json_schema: json schema + :param json_schema: json schema for structured output :param model_parameters: model parameters :param tools: tools for tool calling :param stop: stop words :param stream: is stream response :param user: unique user id :param callbacks: callbacks + :param tenant_id: tenant ID for file reference conversion. When provided and + json_schema contains file reference fields (format: "dify-file-ref"), + file IDs in the output will be automatically converted to File objects. :return: full response or stream response chunk generator result """ @@ -153,8 +165,18 @@ def invoke_llm_with_structured_output( f"Failed to parse structured output, LLM result is not a string: {llm_result.message.content}" ) + structured_output = _parse_structured_output(llm_result.message.content) + + # Convert file references if tenant_id is provided + if tenant_id is not None: + structured_output = convert_file_refs_in_output( + output=structured_output, + json_schema=json_schema, + tenant_id=tenant_id, + ) + return LLMResultWithStructuredOutput( - structured_output=_parse_structured_output(llm_result.message.content), + structured_output=structured_output, model=llm_result.model, message=llm_result.message, usage=llm_result.usage, @@ -186,8 +208,18 @@ def invoke_llm_with_structured_output( delta=event.delta, ) + structured_output = _parse_structured_output(result_text) + + # Convert file references if tenant_id is provided + if tenant_id is not None: + structured_output = convert_file_refs_in_output( + output=structured_output, + json_schema=json_schema, + tenant_id=tenant_id, + ) + yield LLMResultChunkWithStructuredOutput( - structured_output=_parse_structured_output(result_text), + structured_output=structured_output, model=model_schema.model, prompt_messages=prompt_messages, system_fingerprint=system_fingerprint, diff --git a/api/core/workflow/nodes/llm/node.py b/api/core/workflow/nodes/llm/node.py index 5777d831d5..6fb75591dd 100644 --- a/api/core/workflow/nodes/llm/node.py +++ b/api/core/workflow/nodes/llm/node.py @@ -20,6 +20,7 @@ from core.memory.base import BaseMemory from core.model_manager import ModelInstance, ModelManager from core.model_runtime.entities import ( ImagePromptMessageContent, + MultiModalPromptMessageContent, PromptMessage, PromptMessageContentType, TextPromptMessageContent, @@ -274,6 +275,7 @@ class LLMNode(Node[LLMNodeData]): node_id=self._node_id, node_type=self.node_type, reasoning_format=self.node_data.reasoning_format, + tenant_id=self.tenant_id, ) structured_output: LLMStructuredOutput | None = None @@ -404,6 +406,7 @@ class LLMNode(Node[LLMNodeData]): node_id: str, node_type: NodeType, reasoning_format: Literal["separated", "tagged"] = "tagged", + tenant_id: str | None = None, ) -> Generator[NodeEventBase | LLMStructuredOutput, None, None]: model_schema = model_instance.model_type_instance.get_model_schema( node_data_model.name, model_instance.credentials @@ -427,6 +430,7 @@ class LLMNode(Node[LLMNodeData]): stop=list(stop or []), stream=True, user=user_id, + tenant_id=tenant_id, ) else: request_start_time = time.perf_counter() @@ -612,11 +616,39 @@ class LLMNode(Node[LLMNodeData]): Build context from prompt messages and assistant response. Excludes system messages and includes the current LLM response. Returns list[PromptMessage] for use with ArrayPromptMessageSegment. + + Note: Multi-modal content base64 data is truncated to avoid storing large data in context. """ - context_messages: list[PromptMessage] = [m for m in prompt_messages if m.role != PromptMessageRole.SYSTEM] + context_messages: list[PromptMessage] = [ + LLMNode._truncate_multimodal_content(m) for m in prompt_messages if m.role != PromptMessageRole.SYSTEM + ] context_messages.append(AssistantPromptMessage(content=assistant_response)) return context_messages + @staticmethod + def _truncate_multimodal_content(message: PromptMessage) -> PromptMessage: + """ + Truncate multi-modal content base64 data in a message to avoid storing large data. + Preserves the PromptMessage structure for ArrayPromptMessageSegment compatibility. + """ + content = message.content + if content is None or isinstance(content, str): + return message + + # Process list content, truncating multi-modal base64 data + new_content: list[PromptMessageContentUnionTypes] = [] + for item in content: + if isinstance(item, MultiModalPromptMessageContent): + # Truncate base64_data similar to prompt_messages_to_prompt_for_saving + truncated_base64 = "" + if item.base64_data: + truncated_base64 = item.base64_data[:10] + "...[TRUNCATED]..." + item.base64_data[-10:] + new_content.append(item.model_copy(update={"base64_data": truncated_base64})) + else: + new_content.append(item) + + return message.model_copy(update={"content": new_content}) + def _transform_chat_messages( self, messages: Sequence[LLMNodeChatModelMessage] | LLMNodeCompletionModelPromptTemplate, / ) -> Sequence[LLMNodeChatModelMessage] | LLMNodeCompletionModelPromptTemplate: diff --git a/api/tests/fixtures/file output schema.yml b/api/tests/fixtures/file output schema.yml new file mode 100644 index 0000000000..37fc9c72c7 --- /dev/null +++ b/api/tests/fixtures/file output schema.yml @@ -0,0 +1,181 @@ +app: + description: '' + icon: 🤖 + icon_background: '#FFEAD5' + mode: advanced-chat + name: file output schema + use_icon_as_answer_icon: false +dependencies: +- current_identifier: null + type: marketplace + value: + marketplace_plugin_unique_identifier: langgenius/openai:0.2.3@5a7f82fa86e28332ad51941d0b491c1e8a38ead539656442f7bf4c6129cd15fa + version: null +kind: app +version: 0.5.0 +workflow: + conversation_variables: [] + environment_variables: [] + features: + file_upload: + allowed_file_extensions: + - .JPG + - .JPEG + - .PNG + - .GIF + - .WEBP + - .SVG + allowed_file_types: + - image + allowed_file_upload_methods: + - remote_url + - local_file + enabled: true + fileUploadConfig: + attachment_image_file_size_limit: 2 + audio_file_size_limit: 50 + batch_count_limit: 5 + file_size_limit: 15 + file_upload_limit: 10 + image_file_batch_limit: 10 + image_file_size_limit: 10 + single_chunk_attachment_limit: 10 + video_file_size_limit: 100 + workflow_file_upload_limit: 10 + number_limits: 3 + opening_statement: '' + retriever_resource: + enabled: true + sensitive_word_avoidance: + enabled: false + speech_to_text: + enabled: false + suggested_questions: [] + suggested_questions_after_answer: + enabled: false + text_to_speech: + enabled: false + language: '' + voice: '' + graph: + edges: + - data: + sourceType: start + targetType: llm + id: 1768292241666-llm + source: '1768292241666' + sourceHandle: source + target: llm + targetHandle: target + type: custom + - data: + sourceType: llm + targetType: answer + id: llm-answer + source: llm + sourceHandle: source + target: answer + targetHandle: target + type: custom + nodes: + - data: + selected: false + title: User Input + type: start + variables: [] + height: 73 + id: '1768292241666' + position: + x: 80 + y: 282 + positionAbsolute: + x: 80 + y: 282 + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + context: + enabled: false + variable_selector: [] + memory: + query_prompt_template: '{{#sys.query#}} + + + {{#sys.files#}}' + role_prefix: + assistant: '' + user: '' + window: + enabled: false + size: 10 + model: + completion_params: + temperature: 0.7 + mode: chat + name: gpt-4o-mini + provider: langgenius/openai/openai + prompt_template: + - id: e30d75d7-7d85-49ec-be3c-3baf7f6d3c5a + role: system + text: '' + selected: false + structured_output: + schema: + additionalProperties: false + properties: + image: + description: File ID (UUID) of the selected image + format: dify-file-ref + type: string + required: + - image + type: object + structured_output_enabled: true + title: LLM + type: llm + vision: + configs: + detail: high + variable_selector: + - sys + - files + enabled: true + height: 88 + id: llm + position: + x: 380 + y: 282 + positionAbsolute: + x: 380 + y: 282 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + answer: '{{#llm.structured_output.image#}}' + selected: false + title: Answer + type: answer + variables: [] + height: 103 + id: answer + position: + x: 680 + y: 282 + positionAbsolute: + x: 680 + y: 282 + selected: true + sourcePosition: right + targetPosition: left + type: custom + width: 242 + viewport: + x: -149 + y: 97.5 + zoom: 1 + rag_pipeline_variables: [] diff --git a/api/tests/unit_tests/core/llm_generator/output_parser/test_file_ref.py b/api/tests/unit_tests/core/llm_generator/output_parser/test_file_ref.py new file mode 100644 index 0000000000..6d18ac7fc9 --- /dev/null +++ b/api/tests/unit_tests/core/llm_generator/output_parser/test_file_ref.py @@ -0,0 +1,269 @@ +""" +Unit tests for file reference detection and conversion. +""" + +import uuid +from unittest.mock import MagicMock, patch + +import pytest + +from core.file import File, FileTransferMethod, FileType +from core.llm_generator.output_parser.file_ref import ( + FILE_REF_FORMAT, + convert_file_refs_in_output, + detect_file_ref_fields, + is_file_ref_property, +) +from core.variables.segments import ArrayFileSegment, FileSegment + + +class TestIsFileRefProperty: + """Tests for is_file_ref_property function.""" + + def test_valid_file_ref(self): + schema = {"type": "string", "format": FILE_REF_FORMAT} + assert is_file_ref_property(schema) is True + + def test_invalid_type(self): + schema = {"type": "number", "format": FILE_REF_FORMAT} + assert is_file_ref_property(schema) is False + + def test_missing_format(self): + schema = {"type": "string"} + assert is_file_ref_property(schema) is False + + def test_wrong_format(self): + schema = {"type": "string", "format": "uuid"} + assert is_file_ref_property(schema) is False + + +class TestDetectFileRefFields: + """Tests for detect_file_ref_fields function.""" + + def test_simple_file_ref(self): + schema = { + "type": "object", + "properties": { + "image": {"type": "string", "format": FILE_REF_FORMAT}, + }, + } + paths = detect_file_ref_fields(schema) + assert paths == ["image"] + + def test_multiple_file_refs(self): + schema = { + "type": "object", + "properties": { + "image": {"type": "string", "format": FILE_REF_FORMAT}, + "document": {"type": "string", "format": FILE_REF_FORMAT}, + "name": {"type": "string"}, + }, + } + paths = detect_file_ref_fields(schema) + assert set(paths) == {"image", "document"} + + def test_array_of_file_refs(self): + schema = { + "type": "object", + "properties": { + "files": { + "type": "array", + "items": {"type": "string", "format": FILE_REF_FORMAT}, + }, + }, + } + paths = detect_file_ref_fields(schema) + assert paths == ["files[*]"] + + def test_nested_file_ref(self): + schema = { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "image": {"type": "string", "format": FILE_REF_FORMAT}, + }, + }, + }, + } + paths = detect_file_ref_fields(schema) + assert paths == ["data.image"] + + def test_no_file_refs(self): + schema = { + "type": "object", + "properties": { + "name": {"type": "string"}, + "count": {"type": "number"}, + }, + } + paths = detect_file_ref_fields(schema) + assert paths == [] + + def test_empty_schema(self): + schema = {} + paths = detect_file_ref_fields(schema) + assert paths == [] + + def test_mixed_schema(self): + schema = { + "type": "object", + "properties": { + "query": {"type": "string"}, + "image": {"type": "string", "format": FILE_REF_FORMAT}, + "documents": { + "type": "array", + "items": {"type": "string", "format": FILE_REF_FORMAT}, + }, + }, + } + paths = detect_file_ref_fields(schema) + assert set(paths) == {"image", "documents[*]"} + + +class TestConvertFileRefsInOutput: + """Tests for convert_file_refs_in_output function.""" + + @pytest.fixture + def mock_file(self): + """Create a mock File object with all required attributes.""" + file = MagicMock(spec=File) + file.type = FileType.IMAGE + file.transfer_method = FileTransferMethod.TOOL_FILE + file.related_id = "test-related-id" + file.remote_url = None + file.tenant_id = "tenant_123" + file.id = None + file.filename = "test.png" + file.extension = ".png" + file.mime_type = "image/png" + file.size = 1024 + file.dify_model_identity = "__dify__file__" + return file + + @pytest.fixture + def mock_build_from_mapping(self, mock_file): + """Mock the build_from_mapping function.""" + with patch("core.llm_generator.output_parser.file_ref.build_from_mapping") as mock: + mock.return_value = mock_file + yield mock + + def test_convert_simple_file_ref(self, mock_build_from_mapping, mock_file): + file_id = str(uuid.uuid4()) + output = {"image": file_id} + schema = { + "type": "object", + "properties": { + "image": {"type": "string", "format": FILE_REF_FORMAT}, + }, + } + + result = convert_file_refs_in_output(output, schema, "tenant_123") + + # Result should be wrapped in FileSegment + assert isinstance(result["image"], FileSegment) + assert result["image"].value == mock_file + mock_build_from_mapping.assert_called_once_with( + mapping={"transfer_method": "tool_file", "tool_file_id": file_id}, + tenant_id="tenant_123", + ) + + def test_convert_array_of_file_refs(self, mock_build_from_mapping, mock_file): + file_id1 = str(uuid.uuid4()) + file_id2 = str(uuid.uuid4()) + output = {"files": [file_id1, file_id2]} + schema = { + "type": "object", + "properties": { + "files": { + "type": "array", + "items": {"type": "string", "format": FILE_REF_FORMAT}, + }, + }, + } + + result = convert_file_refs_in_output(output, schema, "tenant_123") + + # Result should be wrapped in ArrayFileSegment + assert isinstance(result["files"], ArrayFileSegment) + assert list(result["files"].value) == [mock_file, mock_file] + assert mock_build_from_mapping.call_count == 2 + + def test_no_conversion_without_file_refs(self): + output = {"name": "test", "count": 5} + schema = { + "type": "object", + "properties": { + "name": {"type": "string"}, + "count": {"type": "number"}, + }, + } + + result = convert_file_refs_in_output(output, schema, "tenant_123") + + assert result == {"name": "test", "count": 5} + + def test_invalid_uuid_returns_none(self): + output = {"image": "not-a-valid-uuid"} + schema = { + "type": "object", + "properties": { + "image": {"type": "string", "format": FILE_REF_FORMAT}, + }, + } + + result = convert_file_refs_in_output(output, schema, "tenant_123") + + assert result["image"] is None + + def test_file_not_found_returns_none(self): + file_id = str(uuid.uuid4()) + output = {"image": file_id} + schema = { + "type": "object", + "properties": { + "image": {"type": "string", "format": FILE_REF_FORMAT}, + }, + } + + with patch("core.llm_generator.output_parser.file_ref.build_from_mapping") as mock: + mock.side_effect = ValueError("File not found") + result = convert_file_refs_in_output(output, schema, "tenant_123") + + assert result["image"] is None + + def test_preserves_non_file_fields(self, mock_build_from_mapping, mock_file): + file_id = str(uuid.uuid4()) + output = {"query": "search term", "image": file_id, "count": 10} + schema = { + "type": "object", + "properties": { + "query": {"type": "string"}, + "image": {"type": "string", "format": FILE_REF_FORMAT}, + "count": {"type": "number"}, + }, + } + + result = convert_file_refs_in_output(output, schema, "tenant_123") + + assert result["query"] == "search term" + assert isinstance(result["image"], FileSegment) + assert result["image"].value == mock_file + assert result["count"] == 10 + + def test_does_not_modify_original_output(self, mock_build_from_mapping, mock_file): + file_id = str(uuid.uuid4()) + original = {"image": file_id} + output = dict(original) + schema = { + "type": "object", + "properties": { + "image": {"type": "string", "format": FILE_REF_FORMAT}, + }, + } + + convert_file_refs_in_output(output, schema, "tenant_123") + + # Original should still contain the string ID + assert original["image"] == file_id From ddbbddbd14da4ca140a85164136199714cb0e84e Mon Sep 17 00:00:00 2001 From: zhsama Date: Tue, 13 Jan 2026 16:40:34 +0800 Subject: [PATCH 38/82] refactor: Update variable syntax to support agent context markers Extend variable pattern matching to support both `#` and `@` markers, with `@` specifically used for agent context variables. Update regex patterns, text processing logic, and add sub-graph persistence for agent variable handling. --- .../components/app/overview/trigger-card.tsx | 1 - .../base/prompt-editor/constants.tsx | 7 +- .../plugins/workflow-variable-block/node.tsx | 7 +- .../sub-graph/hooks/use-sub-graph-init.ts | 147 ++++++++++-------- .../_base/components/form-input-item.tsx | 20 ++- .../components/workflow/nodes/_base/types.ts | 8 + .../mixed-variable-text-input/index.tsx | 48 ++++-- .../nodes/tool/use-single-run-form-params.ts | 2 +- web/config/index.spec.ts | 2 + web/config/index.ts | 2 +- 10 files changed, 155 insertions(+), 89 deletions(-) diff --git a/web/app/components/app/overview/trigger-card.tsx b/web/app/components/app/overview/trigger-card.tsx index a2d28606a1..c8f12745bd 100644 --- a/web/app/components/app/overview/trigger-card.tsx +++ b/web/app/components/app/overview/trigger-card.tsx @@ -14,7 +14,6 @@ import { BlockEnum } from '@/app/components/workflow/types' import { useAppContext } from '@/context/app-context' import { useDocLink } from '@/context/i18n' import { - useAppTriggers, useInvalidateAppTriggers, useUpdateTriggerStatus, diff --git a/web/app/components/base/prompt-editor/constants.tsx b/web/app/components/base/prompt-editor/constants.tsx index d6b8e9fcb4..9fcf445bfb 100644 --- a/web/app/components/base/prompt-editor/constants.tsx +++ b/web/app/components/base/prompt-editor/constants.tsx @@ -38,13 +38,16 @@ export const getInputVars = (text: string): ValueSelector[] => { if (!text || typeof text !== 'string') return [] - const allVars = text.match(/\{\{#([^#]*)#\}\}/g) + const allVars = text.match(/\{\{[@#]([^@#]*)[@#]\}\}/g) if (allVars && allVars?.length > 0) { // {{#context#}}, {{#query#}} is not input vars const inputVars = allVars .filter(item => item.includes('.')) .map((item) => { - const valueSelector = item.replace('{{#', '').replace('#}}', '').split('.') + const valueSelector = item + .replace(/^\{\{[@#]/, '') + .replace(/[@#]\}\}$/, '') + .split('.') if (valueSelector[1] === 'sys' && /^\d+$/.test(valueSelector[0])) return valueSelector.slice(1) diff --git a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/node.tsx b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/node.tsx index a241e75233..75ceb82f2d 100644 --- a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/node.tsx +++ b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/node.tsx @@ -2,6 +2,7 @@ import type { LexicalNode, NodeKey, SerializedLexicalNode } from 'lexical' import type { GetVarType, WorkflowVariableBlockType } from '../../types' import type { Var } from '@/app/components/workflow/types' import { DecoratorNode } from 'lexical' +import { BlockEnum } from '@/app/components/workflow/types' import WorkflowVariableBlockComponent from './component' export type WorkflowNodesMap = WorkflowVariableBlockType['workflowNodesMap'] @@ -120,7 +121,11 @@ export class WorkflowVariableBlockNode extends DecoratorNode } getTextContent(): string { - return `{{#${this.getVariables().join('.')}#}}` + const variables = this.getVariables() + const node = this.getWorkflowNodesMap()?.[variables[0]] + const isAgentContextVariable = node?.type === BlockEnum.Agent && variables[variables.length - 1] === 'context' + const marker = isAgentContextVariable ? '@' : '#' + return `{{${marker}${variables.join('.')}${marker}}}` } } export function $createWorkflowVariableBlockNode(variables: string[], workflowNodesMap: WorkflowNodesMap, getVarType?: GetVarType, environmentVariables?: Var[], conversationVariables?: Var[], ragVariables?: Var[]): WorkflowVariableBlockNode { diff --git a/web/app/components/sub-graph/hooks/use-sub-graph-init.ts b/web/app/components/sub-graph/hooks/use-sub-graph-init.ts index ba6f391a83..68d1e1be20 100644 --- a/web/app/components/sub-graph/hooks/use-sub-graph-init.ts +++ b/web/app/components/sub-graph/hooks/use-sub-graph-init.ts @@ -1,86 +1,97 @@ import type { SubGraphProps } from '../types' import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types' import type { StartNodeType } from '@/app/components/workflow/nodes/start/types' -import type { Edge, Node } from '@/app/components/workflow/types' +import type { Edge, Node, ValueSelector } from '@/app/components/workflow/types' import { useMemo } from 'react' import { BlockEnum, PromptRole } from '@/app/components/workflow/types' import { AppModeEnum } from '@/types/app' -const SUBGRAPH_SOURCE_NODE_ID = 'subgraph-source' -const SUBGRAPH_LLM_NODE_ID = 'subgraph-llm' +export const SUBGRAPH_SOURCE_NODE_ID = 'subgraph-source' +export const SUBGRAPH_LLM_NODE_ID = 'subgraph-llm' + +export const getSubGraphInitialNodes = ( + sourceVariable: ValueSelector, + agentName: string, +): Node[] => { + const sourceVarName = sourceVariable.length > 1 + ? sourceVariable.slice(1).join('.') + : 'output' + + const startNode: Node = { + id: SUBGRAPH_SOURCE_NODE_ID, + type: 'custom', + position: { x: 100, y: 150 }, + data: { + type: BlockEnum.Start, + title: `${agentName}: ${sourceVarName}`, + desc: 'Source variable from agent', + _connectedSourceHandleIds: ['source'], + _connectedTargetHandleIds: [], + variables: [], + }, + } + + const llmNode: Node = { + id: SUBGRAPH_LLM_NODE_ID, + type: 'custom', + position: { x: 450, y: 150 }, + data: { + type: BlockEnum.LLM, + title: 'LLM', + desc: 'Transform the output', + _connectedSourceHandleIds: [], + _connectedTargetHandleIds: ['target'], + model: { + provider: '', + name: '', + mode: AppModeEnum.CHAT, + completion_params: { + temperature: 0.7, + }, + }, + prompt_template: [{ + role: PromptRole.system, + text: '', + }], + context: { + enabled: false, + variable_selector: [], + }, + vision: { + enabled: false, + }, + }, + } + + return [startNode, llmNode] +} + +export const getSubGraphInitialEdges = (): Edge[] => { + return [ + { + id: `${SUBGRAPH_SOURCE_NODE_ID}-${SUBGRAPH_LLM_NODE_ID}`, + source: SUBGRAPH_SOURCE_NODE_ID, + sourceHandle: 'source', + target: SUBGRAPH_LLM_NODE_ID, + targetHandle: 'target', + type: 'custom', + data: { + sourceType: BlockEnum.Start, + targetType: BlockEnum.LLM, + }, + }, + ] +} export const useSubGraphInit = (props: SubGraphProps) => { const { sourceVariable, agentName } = props const initialNodes = useMemo((): Node[] => { - const sourceVarName = sourceVariable.length > 1 - ? sourceVariable.slice(1).join('.') - : 'output' - - const startNode: Node = { - id: SUBGRAPH_SOURCE_NODE_ID, - type: 'custom', - position: { x: 100, y: 150 }, - data: { - type: BlockEnum.Start, - title: `${agentName}: ${sourceVarName}`, - desc: 'Source variable from agent', - _connectedSourceHandleIds: ['source'], - _connectedTargetHandleIds: [], - variables: [], - }, - } - - const llmNode: Node = { - id: SUBGRAPH_LLM_NODE_ID, - type: 'custom', - position: { x: 450, y: 150 }, - data: { - type: BlockEnum.LLM, - title: 'LLM', - desc: 'Transform the output', - _connectedSourceHandleIds: [], - _connectedTargetHandleIds: ['target'], - model: { - provider: '', - name: '', - mode: AppModeEnum.CHAT, - completion_params: { - temperature: 0.7, - }, - }, - prompt_template: [{ - role: PromptRole.system, - text: '', - }], - context: { - enabled: false, - variable_selector: [], - }, - vision: { - enabled: false, - }, - }, - } - - return [startNode, llmNode] + return getSubGraphInitialNodes(sourceVariable, agentName) }, [sourceVariable, agentName]) const initialEdges = useMemo((): Edge[] => { - return [ - { - id: `${SUBGRAPH_SOURCE_NODE_ID}-${SUBGRAPH_LLM_NODE_ID}`, - source: SUBGRAPH_SOURCE_NODE_ID, - sourceHandle: 'source', - target: SUBGRAPH_LLM_NODE_ID, - targetHandle: 'target', - type: 'custom', - data: { - sourceType: BlockEnum.Start, - targetType: BlockEnum.LLM, - }, - }, - ] + return getSubGraphInitialEdges() }, []) return { diff --git a/web/app/components/workflow/nodes/_base/components/form-input-item.tsx b/web/app/components/workflow/nodes/_base/components/form-input-item.tsx index 99789d8afe..661c69c4dc 100644 --- a/web/app/components/workflow/nodes/_base/components/form-input-item.tsx +++ b/web/app/components/workflow/nodes/_base/components/form-input-item.tsx @@ -1,6 +1,6 @@ 'use client' import type { FC } from 'react' -import type { ResourceVarInputs } from '../types' +import type { MentionConfig, ResourceVarInputs } from '../types' import type { CredentialFormSchema, FormOption } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { Event, Tool } from '@/app/components/tools/types' import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types' @@ -233,13 +233,25 @@ const FormInputItem: FC = ({ } } - const handleValueChange = (newValue: any, newType?: VarKindType) => { + const handleValueChange = (newValue: any, newType?: VarKindType, mentionConfig?: MentionConfig | null) => { + const normalizedValue = isNumber ? Number.parseFloat(newValue) : newValue + const resolvedType = newType ?? (varInput?.type === VarKindType.mention ? VarKindType.mention : getVarKindType()) + const resolvedMentionConfig = resolvedType === VarKindType.mention + ? (mentionConfig ?? varInput?.mention_config ?? { + extractor_node_id: '', + output_selector: [], + null_strategy: 'use_default', + default_value: '', + }) + : undefined + onChange({ ...value, [variable]: { ...varInput, - type: newType ?? getVarKindType(), - value: isNumber ? Number.parseFloat(newValue) : newValue, + type: resolvedType, + value: normalizedValue, + mention_config: resolvedMentionConfig, }, }) } diff --git a/web/app/components/workflow/nodes/_base/types.ts b/web/app/components/workflow/nodes/_base/types.ts index 8f15c89881..f3c64656b5 100644 --- a/web/app/components/workflow/nodes/_base/types.ts +++ b/web/app/components/workflow/nodes/_base/types.ts @@ -8,10 +8,18 @@ export enum VarKindType { mention = 'mention', } +export type MentionConfig = { + extractor_node_id: string + output_selector: ValueSelector + null_strategy: 'raise_error' | 'use_default' + default_value: unknown +} + // Generic resource variable inputs export type ResourceVarInputs = Record // Base resource interface diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx index d0beeabb0c..1f8ea1adb5 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx @@ -1,5 +1,5 @@ import type { AgentNode } from '@/app/components/base/prompt-editor/types' -import type { VarKindType } from '@/app/components/workflow/nodes/_base/types' +import type { MentionConfig, VarKindType } from '@/app/components/workflow/nodes/_base/types' import type { Node, NodeOutPutVar, @@ -13,6 +13,8 @@ import { } from 'react' import { useTranslation } from 'react-i18next' import PromptEditor from '@/app/components/base/prompt-editor' +import { useSubGraphPersistence } from '@/app/components/sub-graph/hooks' +import { getSubGraphInitialEdges, getSubGraphInitialNodes } from '@/app/components/sub-graph/hooks/use-sub-graph-init' import { VarKindType as VarKindTypeEnum } from '@/app/components/workflow/nodes/_base/types' import { useStore } from '@/app/components/workflow/store' import { BlockEnum } from '@/app/components/workflow/types' @@ -22,17 +24,24 @@ import AgentHeaderBar from './agent-header-bar' import Placeholder from './placeholder' /** - * Matches agent context variable syntax: {{#nodeId.context#}} - * Example: {{#agent-123.context#}} -> captures "agent-123" + * Matches agent context variable syntax: {{@nodeId.context@}} + * Example: {{@agent-123.context@}} -> captures "agent-123" */ -const AGENT_CONTEXT_VAR_PATTERN = /\{\{#([^.#]+)\.context#\}\}/g +const AGENT_CONTEXT_VAR_PATTERN = /\{\{[@#]([^.@#]+)\.context[@#]\}\}/g + +const DEFAULT_MENTION_CONFIG: MentionConfig = { + extractor_node_id: '', + output_selector: [], + null_strategy: 'use_default', + default_value: '', +} type MixedVariableTextInputProps = { readOnly?: boolean nodesOutputVars?: NodeOutPutVar[] availableNodes?: Node[] value?: string - onChange?: (text: string, type?: VarKindType) => void + onChange?: (text: string, type?: VarKindType, mentionConfig?: MentionConfig | null) => void showManageInputField?: boolean onManageInputField?: () => void disableVariableInsertion?: boolean @@ -57,6 +66,15 @@ const MixedVariableTextInput = ({ const setControlPromptEditorRerenderKey = useStore(s => s.setControlPromptEditorRerenderKey) const [isSubGraphModalOpen, setIsSubGraphModalOpen] = useState(false) + const { + loadSubGraphData, + updateSubGraphNodes, + clearSubGraphData, + } = useSubGraphPersistence({ + toolNodeId: toolNodeId || '', + paramKey: paramKey || '', + }) + const nodesByIdMap = useMemo(() => { return availableNodes.reduce((acc, node) => { acc[node.id] = node @@ -107,20 +125,28 @@ const MixedVariableTextInput = ({ return nodeId === agentNodeId ? '' : match }).trim() - onChange(valueWithoutAgentVars, VarKindTypeEnum.mixed) + onChange(valueWithoutAgentVars, VarKindTypeEnum.mixed, null) + if (toolNodeId && paramKey) + clearSubGraphData() setControlPromptEditorRerenderKey(Date.now()) - }, [detectedAgentFromValue?.nodeId, value, onChange, setControlPromptEditorRerenderKey]) + }, [clearSubGraphData, detectedAgentFromValue?.nodeId, onChange, paramKey, setControlPromptEditorRerenderKey, toolNodeId, value]) const handleAgentSelect = useCallback((agent: AgentNode) => { if (!onChange) return const valueWithoutTrigger = value.replace(/@$/, '') - const newValue = `{{#${agent.id}.context#}}${valueWithoutTrigger}` + const newValue = `{{@${agent.id}.context@}}${valueWithoutTrigger}` - onChange(newValue, VarKindTypeEnum.mention) + if (toolNodeId && paramKey && !loadSubGraphData()) { + const initialNodes = getSubGraphInitialNodes([agent.id, 'context'], agent.title) + const initialEdges = getSubGraphInitialEdges() + updateSubGraphNodes(initialNodes, initialEdges) + } + + onChange(newValue, VarKindTypeEnum.mention, DEFAULT_MENTION_CONFIG) setControlPromptEditorRerenderKey(Date.now()) - }, [value, onChange, setControlPromptEditorRerenderKey]) + }, [loadSubGraphData, onChange, paramKey, setControlPromptEditorRerenderKey, toolNodeId, updateSubGraphNodes, value]) const handleOpenSubGraphModal = useCallback(() => { setIsSubGraphModalOpen(true) @@ -179,7 +205,7 @@ const MixedVariableTextInput = ({ onSelect: handleAgentSelect, }} placeholder={} - onChange={onChange} + onChange={text => onChange?.(text)} /> {toolNodeId && detectedAgentFromValue && sourceVariable && ( (id, payload) const hadVarParams = Object.keys(inputs.tool_parameters) - .filter(key => inputs.tool_parameters[key].type !== VarType.constant) + .filter(key => ![VarType.constant, VarType.mention].includes(inputs.tool_parameters[key].type)) .map(k => inputs.tool_parameters[k]) const hadVarSettings = Object.keys(inputs.tool_configurations) diff --git a/web/config/index.spec.ts b/web/config/index.spec.ts index 7b1d91186d..e03ee92dfd 100644 --- a/web/config/index.spec.ts +++ b/web/config/index.spec.ts @@ -70,6 +70,8 @@ describe('config test', () => { // rag variables '{{#rag.1748945155129.a#}}', '{{#rag.shared.bbb#}}', + '{{@1749783300519.llm.a@}}', + '{{@sys.query@}}', ] vars.forEach((variable) => { expect(VAR_REGEX.test(variable)).toBe(true) diff --git a/web/config/index.ts b/web/config/index.ts index b804629048..812076404c 100644 --- a/web/config/index.ts +++ b/web/config/index.ts @@ -340,7 +340,7 @@ Thought: {{agent_scratchpad}} } export const VAR_REGEX - = /\{\{(#[\w-]{1,50}(\.\d+)?(\.[a-z_]\w{0,29}){1,10}#)\}\}/gi + = /\{\{([#@])[\w-]{1,50}(\.\d+)?(\.[a-z_]\w{0,29}){1,10}\1\}\}/gi export const resetReg = () => (VAR_REGEX.lastIndex = 0) From e80bc78780077f63843c0b2ed13818d9f3f12a85 Mon Sep 17 00:00:00 2001 From: zhsama Date: Tue, 13 Jan 2026 17:57:02 +0800 Subject: [PATCH 39/82] fix: clear mock llm node functions --- .../sub-graph/hooks/use-sub-graph-persistence.ts | 12 ------------ .../components/mixed-variable-text-input/index.tsx | 5 +---- 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/web/app/components/sub-graph/hooks/use-sub-graph-persistence.ts b/web/app/components/sub-graph/hooks/use-sub-graph-persistence.ts index 70a5f9a151..ee54000b9c 100644 --- a/web/app/components/sub-graph/hooks/use-sub-graph-persistence.ts +++ b/web/app/components/sub-graph/hooks/use-sub-graph-persistence.ts @@ -65,17 +65,6 @@ export const useSubGraphPersistence = ({ }) }, [getSubGraphDataKey, inputs, setInputs]) - const clearSubGraphData = useCallback(() => { - const dataKey = getSubGraphDataKey() - const newToolParameters = { ...inputs.tool_parameters } - delete newToolParameters[dataKey] - - setInputs({ - ...inputs, - tool_parameters: newToolParameters, - }) - }, [getSubGraphDataKey, inputs, setInputs]) - const hasSubGraphData = useCallback(() => { const dataKey = getSubGraphDataKey() const toolParameters = inputs.tool_parameters || {} @@ -120,7 +109,6 @@ export const useSubGraphPersistence = ({ return { loadSubGraphData, saveSubGraphData, - clearSubGraphData, hasSubGraphData, updateSubGraphConfig, updateSubGraphNodes, diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx index 1f8ea1adb5..64d6330f43 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx @@ -69,7 +69,6 @@ const MixedVariableTextInput = ({ const { loadSubGraphData, updateSubGraphNodes, - clearSubGraphData, } = useSubGraphPersistence({ toolNodeId: toolNodeId || '', paramKey: paramKey || '', @@ -126,10 +125,8 @@ const MixedVariableTextInput = ({ }).trim() onChange(valueWithoutAgentVars, VarKindTypeEnum.mixed, null) - if (toolNodeId && paramKey) - clearSubGraphData() setControlPromptEditorRerenderKey(Date.now()) - }, [clearSubGraphData, detectedAgentFromValue?.nodeId, onChange, paramKey, setControlPromptEditorRerenderKey, toolNodeId, value]) + }, [detectedAgentFromValue?.nodeId, onChange, paramKey, setControlPromptEditorRerenderKey, toolNodeId, value]) const handleAgentSelect = useCallback((agent: AgentNode) => { if (!onChange) From f57d2ef31f5b881d405b01a9922cf3b9b75f7e23 Mon Sep 17 00:00:00 2001 From: zhsama Date: Tue, 13 Jan 2026 18:37:23 +0800 Subject: [PATCH 40/82] refactor: refactor workflow nodes state sync and extractor node lifecycle --- web/app/components/workflow/index.tsx | 3 +- .../mixed-variable-text-input/index.tsx | 78 ++++++++++++++----- 2 files changed, 62 insertions(+), 19 deletions(-) diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx index bd6ab01eac..46967b9688 100644 --- a/web/app/components/workflow/index.tsx +++ b/web/app/components/workflow/index.tsx @@ -193,8 +193,9 @@ export const Workflow: FC = memo(({ id: node.id, data: node.data, })) - if (!isEqual(oldData, nodesData)) + if (!isEqual(oldData, nodesData)) { setNodesInStore(nodes) + } }, [setNodesInStore, workflowStore]) useEffect(() => { setNodesOnlyChangeWithData(currentNodes as Node[]) diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx index 64d6330f43..1cc1480f6d 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx @@ -12,12 +12,13 @@ import { useState, } from 'react' import { useTranslation } from 'react-i18next' +import { useStoreApi } from 'reactflow' import PromptEditor from '@/app/components/base/prompt-editor' -import { useSubGraphPersistence } from '@/app/components/sub-graph/hooks' -import { getSubGraphInitialEdges, getSubGraphInitialNodes } from '@/app/components/sub-graph/hooks/use-sub-graph-init' +import { useNodesMetaData, useNodesSyncDraft } from '@/app/components/workflow/hooks' import { VarKindType as VarKindTypeEnum } from '@/app/components/workflow/nodes/_base/types' import { useStore } from '@/app/components/workflow/store' import { BlockEnum } from '@/app/components/workflow/types' +import { generateNewNode, getNodeCustomTypeByNodeDataType } from '@/app/components/workflow/utils' import { cn } from '@/utils/classnames' import SubGraphModal from '../sub-graph-modal' import AgentHeaderBar from './agent-header-bar' @@ -62,18 +63,13 @@ const MixedVariableTextInput = ({ paramKey = '', }: MixedVariableTextInputProps) => { const { t } = useTranslation() + const reactFlowStore = useStoreApi() const controlPromptEditorRerenderKey = useStore(s => s.controlPromptEditorRerenderKey) const setControlPromptEditorRerenderKey = useStore(s => s.setControlPromptEditorRerenderKey) + const { nodesMap: nodesMetaDataMap } = useNodesMetaData() + const { handleSyncWorkflowDraft } = useNodesSyncDraft() const [isSubGraphModalOpen, setIsSubGraphModalOpen] = useState(false) - const { - loadSubGraphData, - updateSubGraphNodes, - } = useSubGraphPersistence({ - toolNodeId: toolNodeId || '', - paramKey: paramKey || '', - }) - const nodesByIdMap = useMemo(() => { return availableNodes.reduce((acc, node) => { acc[node.id] = node @@ -114,6 +110,21 @@ const MixedVariableTextInput = ({ })) }, [availableNodes]) + const removeExtractorNode = useCallback(() => { + if (!toolNodeId || !paramKey) + return + + const extractorNodeId = `${toolNodeId}_ext_${paramKey}` + const { getNodes, setNodes } = reactFlowStore.getState() + const nodes = getNodes() + const hasExtractorNode = nodes.some(node => node.id === extractorNodeId) + if (!hasExtractorNode) + return + + setNodes(nodes.filter(node => node.id !== extractorNodeId)) + handleSyncWorkflowDraft() + }, [handleSyncWorkflowDraft, paramKey, reactFlowStore, toolNodeId]) + const handleAgentRemove = useCallback(() => { const agentNodeId = detectedAgentFromValue?.nodeId if (!agentNodeId || !onChange) @@ -122,11 +133,12 @@ const MixedVariableTextInput = ({ const valueWithoutAgentVars = value.replace(AGENT_CONTEXT_VAR_PATTERN, (match, variablePath) => { const nodeId = variablePath.split('.')[0] return nodeId === agentNodeId ? '' : match - }).trim() + }) + removeExtractorNode() onChange(valueWithoutAgentVars, VarKindTypeEnum.mixed, null) setControlPromptEditorRerenderKey(Date.now()) - }, [detectedAgentFromValue?.nodeId, onChange, paramKey, setControlPromptEditorRerenderKey, toolNodeId, value]) + }, [detectedAgentFromValue?.nodeId, onChange, removeExtractorNode, setControlPromptEditorRerenderKey, value]) const handleAgentSelect = useCallback((agent: AgentNode) => { if (!onChange) @@ -135,15 +147,37 @@ const MixedVariableTextInput = ({ const valueWithoutTrigger = value.replace(/@$/, '') const newValue = `{{@${agent.id}.context@}}${valueWithoutTrigger}` - if (toolNodeId && paramKey && !loadSubGraphData()) { - const initialNodes = getSubGraphInitialNodes([agent.id, 'context'], agent.title) - const initialEdges = getSubGraphInitialEdges() - updateSubGraphNodes(initialNodes, initialEdges) + if (toolNodeId && paramKey) { + const extractorNodeId = `${toolNodeId}_ext_${paramKey}` + const defaultValue = nodesMetaDataMap?.[BlockEnum.LLM]?.defaultValue + const { getNodes, setNodes } = reactFlowStore.getState() + const nodes = getNodes() + const hasExtractorNode = nodes.some(node => node.id === extractorNodeId) + + if (!hasExtractorNode && defaultValue) { + const { newNode } = generateNewNode({ + id: extractorNodeId, + type: getNodeCustomTypeByNodeDataType(BlockEnum.LLM), + data: { + ...(defaultValue as any), + title: defaultValue.title, + desc: defaultValue.desc || '', + parent_node_id: toolNodeId, + }, + position: { + x: 0, + y: 0, + }, + hidden: true, + }) + setNodes([...nodes, newNode]) + handleSyncWorkflowDraft() + } } onChange(newValue, VarKindTypeEnum.mention, DEFAULT_MENTION_CONFIG) setControlPromptEditorRerenderKey(Date.now()) - }, [loadSubGraphData, onChange, paramKey, setControlPromptEditorRerenderKey, toolNodeId, updateSubGraphNodes, value]) + }, [handleSyncWorkflowDraft, nodesMetaDataMap, onChange, paramKey, reactFlowStore, setControlPromptEditorRerenderKey, toolNodeId, value]) const handleOpenSubGraphModal = useCallback(() => { setIsSubGraphModalOpen(true) @@ -202,7 +236,15 @@ const MixedVariableTextInput = ({ onSelect: handleAgentSelect, }} placeholder={} - onChange={text => onChange?.(text)} + onChange={(text) => { + const hasPlaceholder = new RegExp(AGENT_CONTEXT_VAR_PATTERN.source).test(text) + if (detectedAgentFromValue && !hasPlaceholder) { + removeExtractorNode() + onChange?.(text, VarKindTypeEnum.mixed, null) + return + } + onChange?.(text) + }} /> {toolNodeId && detectedAgentFromValue && sourceVariable && ( Date: Tue, 13 Jan 2026 22:28:30 +0800 Subject: [PATCH 41/82] feat: sub-graph to use dynamic node generation --- .../components/sub-graph-children.tsx | 3 +- .../sub-graph/components/sub-graph-main.tsx | 39 ++----- web/app/components/sub-graph/index.tsx | 107 ++++++++++++++++-- web/app/components/sub-graph/store/index.ts | 1 + web/app/components/sub-graph/types.ts | 5 + web/app/components/workflow/custom-edge.tsx | 59 +++++----- .../components/workflow/hooks-store/store.ts | 3 + .../workflow/hooks/use-shortcuts.ts | 14 ++- .../components/workflow/hooks/use-workflow.ts | 8 +- web/app/components/workflow/index.tsx | 77 ++++++++----- .../nodes/_base/components/node-control.tsx | 25 ++-- .../nodes/_base/components/node-handle.tsx | 28 +++-- .../_base/components/workflow-panel/index.tsx | 16 +-- .../tool/components/sub-graph-modal/index.tsx | 82 +++++++++++++- .../sub-graph-modal/sub-graph-canvas.tsx | 6 + .../tool/components/sub-graph-modal/types.ts | 12 +- 16 files changed, 351 insertions(+), 134 deletions(-) diff --git a/web/app/components/sub-graph/components/sub-graph-children.tsx b/web/app/components/sub-graph/components/sub-graph-children.tsx index ec1f9ee4d6..c9cb989f53 100644 --- a/web/app/components/sub-graph/components/sub-graph-children.tsx +++ b/web/app/components/sub-graph/components/sub-graph-children.tsx @@ -4,6 +4,7 @@ import { memo, useMemo } from 'react' import { useStore as useReactFlowStore } from 'reactflow' import { useShallow } from 'zustand/react/shallow' import { Panel as NodePanel } from '@/app/components/workflow/nodes' +import { BlockEnum } from '@/app/components/workflow/types' type SubGraphChildrenProps = { toolNodeId: string @@ -20,7 +21,7 @@ const SubGraphChildren: FC = ({ const nodes = s.getNodes() const currentNode = nodes.find(node => node.data.selected) - if (currentNode) { + if (currentNode?.data.type === BlockEnum.LLM) { return { id: currentNode.id, type: currentNode.type, 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 e4d784fb96..15627b8642 100644 --- a/web/app/components/sub-graph/components/sub-graph-main.tsx +++ b/web/app/components/sub-graph/components/sub-graph-main.tsx @@ -1,8 +1,7 @@ import type { FC } from 'react' import type { Viewport } from 'reactflow' -import type { SubGraphConfig } from '../types' import type { Edge, Node } from '@/app/components/workflow/types' -import { useCallback, useMemo } from 'react' +import { useMemo } from 'react' import { WorkflowWithInnerContext } from '@/app/components/workflow' import { useAvailableNodesMetaData, useSubGraphPersistence } from '../hooks' import SubGraphChildren from './sub-graph-children' @@ -23,37 +22,14 @@ const SubGraphMain: FC = ({ paramKey, }) => { const availableNodesMetaData = useAvailableNodesMetaData() - const { - saveSubGraphData, - loadSubGraphData, - updateSubGraphConfig, - } = useSubGraphPersistence({ toolNodeId, paramKey }) - - const handleNodesChange = useCallback((updatedNodes: Node[]) => { - const existingData = loadSubGraphData() - const defaultConfig: SubGraphConfig = { - enabled: true, - startNodeId: updatedNodes[0]?.id || '', - selectedOutputVar: [], - whenOutputNone: 'default', - } - - saveSubGraphData({ - nodes: updatedNodes, - edges, - config: existingData?.config || defaultConfig, - }) - }, [edges, loadSubGraphData, saveSubGraphData]) + const { updateSubGraphConfig } = useSubGraphPersistence({ toolNodeId, paramKey }) const hooksStore = useMemo(() => { return { + interactionMode: 'subgraph', availableNodesMetaData, - doSyncWorkflowDraft: async () => { - handleNodesChange(nodes) - }, - syncWorkflowDraftWhenPageClose: () => { - handleNodesChange(nodes) - }, + doSyncWorkflowDraft: async () => {}, + syncWorkflowDraftWhenPageClose: () => {}, handleRefreshWorkflowDraft: () => {}, handleBackupDraft: () => {}, handleLoadBackupDraft: () => {}, @@ -86,7 +62,7 @@ const SubGraphMain: FC = ({ resetConversationVar: async () => {}, invalidateConversationVarValues: () => {}, } - }, [availableNodesMetaData, handleNodesChange, nodes]) + }, [availableNodesMetaData]) return ( = ({ edges={edges} viewport={viewport} hooksStore={hooksStore as any} + allowSelectionWhenReadOnly + canvasReadOnly + interactionMode="subgraph" > = (props) => { - const { toolNodeId, paramKey } = props + const { + toolNodeId, + paramKey, + agentName, + agentNodeId, + extractorNode, + toolParamValue, + } = props - const { loadSubGraphData } = useSubGraphPersistence({ toolNodeId, paramKey }) - const savedData = useMemo(() => loadSubGraphData(), [loadSubGraphData]) + const promptText = useMemo(() => { + if (!toolParamValue) + return '' + // Reason: escape agent id before building a regex pattern. + const escapedAgentId = agentNodeId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const leadingPattern = new RegExp(`^\\{\\{[@#]${escapedAgentId}\\.context[@#]\\}\\}`) + return toolParamValue.replace(leadingPattern, '') + }, [agentNodeId, toolParamValue]) - const { initialNodes, initialEdges } = useSubGraphInit(props) + const startNode = useMemo(() => { + return { + id: 'subgraph-source', + type: 'custom', + position: { x: 100, y: 150 }, + data: { + type: BlockEnum.Start, + title: agentName, + desc: '', + _connectedSourceHandleIds: ['source'], + _connectedTargetHandleIds: [], + variables: [], + }, + selectable: false, + draggable: false, + connectable: false, + focusable: false, + deletable: false, + } + }, [agentName]) - const nodesSource = savedData?.nodes || initialNodes - const edgesSource = savedData?.edges || initialEdges + const extractorDisplayNode = useMemo(() => { + if (!extractorNode) + return null + + const nextPromptTemplate = Array.isArray(extractorNode.data.prompt_template) + ? extractorNode.data.prompt_template.map((item: PromptItem) => { + if (item.role === PromptRole.system) + return { ...item, text: promptText } + return item + }) + : { + ...extractorNode.data.prompt_template, + text: promptText, + } + + const hasSystemPrompt = Array.isArray(nextPromptTemplate) + && nextPromptTemplate.some((item: PromptItem) => item.role === PromptRole.system) + const normalizedPromptTemplate = Array.isArray(nextPromptTemplate) + ? (hasSystemPrompt ? nextPromptTemplate : [{ role: PromptRole.system, text: promptText }, ...nextPromptTemplate]) + : nextPromptTemplate + + return { + ...extractorNode, + hidden: false, + position: { x: 450, y: 150 }, + data: { + ...extractorNode.data, + prompt_template: normalizedPromptTemplate, + }, + } + }, [extractorNode, promptText]) + + const nodesSource = useMemo(() => { + if (!extractorDisplayNode) + return [startNode] + + return [startNode, extractorDisplayNode] + }, [extractorDisplayNode, startNode]) + + const edgesSource = useMemo(() => { + if (!extractorDisplayNode) + return [] + + return [ + { + id: `${startNode.id}-${extractorDisplayNode.id}`, + source: startNode.id, + sourceHandle: 'source', + target: extractorDisplayNode.id, + targetHandle: 'target', + type: 'custom', + selectable: false, + data: { + sourceType: BlockEnum.Start, + targetType: BlockEnum.LLM, + _isTemp: true, + _isSubGraphTemp: true, + }, + }, + ] + }, [extractorDisplayNode, startNode]) const { nodes, edges } = useSubGraphNodes(nodesSource, edgesSource) diff --git a/web/app/components/sub-graph/store/index.ts b/web/app/components/sub-graph/store/index.ts index 798fc93e6a..17f6bde319 100644 --- a/web/app/components/sub-graph/store/index.ts +++ b/web/app/components/sub-graph/store/index.ts @@ -5,6 +5,7 @@ const initialState: Omit + toolParamValue?: string + onSave?: (nodes: Node[], edges: Edge[]) => void } export type SubGraphSliceShape = { @@ -32,6 +36,7 @@ export type SubGraphSliceShape = { parameterKey: string sourceAgentNodeId: string sourceVariable: ValueSelector + subGraphReadOnly: boolean subGraphNodes: Node[] subGraphEdges: Edge[] diff --git a/web/app/components/workflow/custom-edge.tsx b/web/app/components/workflow/custom-edge.tsx index d88c37e35d..f727d1fa0d 100644 --- a/web/app/components/workflow/custom-edge.tsx +++ b/web/app/components/workflow/custom-edge.tsx @@ -25,6 +25,7 @@ import { useAvailableBlocks, useNodesInteractions, } from './hooks' +import { useHooksStore } from './hooks-store' import { BlockEnum, NodeRunningStatus } from './types' import { getEdgeColor } from './utils' @@ -56,6 +57,8 @@ const CustomEdge = ({ }) const [open, setOpen] = useState(false) const { handleNodeAdd } = useNodesInteractions() + const interactionMode = useHooksStore(s => s.interactionMode) + const allowGraphActions = interactionMode !== 'subgraph' const { availablePrevBlocks } = useAvailableBlocks((data as Edge['data'])!.targetType, (data as Edge['data'])?.isInIteration || (data as Edge['data'])?.isInLoop) const { availableNextBlocks } = useAvailableBlocks((data as Edge['data'])!.sourceType, (data as Edge['data'])?.isInIteration || (data as Edge['data'])?.isInLoop) const { @@ -136,35 +139,37 @@ const CustomEdge = ({ stroke, strokeWidth: 2, opacity: data._dimmed ? 0.3 : (data._waitingRun ? 0.7 : 1), - strokeDasharray: (data._isTemp && data.sourceType !== BlockEnum.Group && data.targetType !== BlockEnum.Group) ? '8 8' : undefined, + strokeDasharray: (data._isTemp && !data._isSubGraphTemp && data.sourceType !== BlockEnum.Group && data.targetType !== BlockEnum.Group) ? '8 8' : undefined, }} /> - -
- 'hover:scale-150 transition-all'} - /> -
-
+ {allowGraphActions && ( + +
+ 'hover:scale-150 transition-all'} + /> +
+
+ )} ) } diff --git a/web/app/components/workflow/hooks-store/store.ts b/web/app/components/workflow/hooks-store/store.ts index 44014fc0d7..989c5e5063 100644 --- a/web/app/components/workflow/hooks-store/store.ts +++ b/web/app/components/workflow/hooks-store/store.ts @@ -23,6 +23,7 @@ export type AvailableNodesMetaData = { nodesMap?: Record> } export type CommonHooksFnMap = { + interactionMode?: 'default' | 'subgraph' doSyncWorkflowDraft: ( notRefreshWhenSyncError?: boolean, callback?: { @@ -76,6 +77,7 @@ export type Shape = { } & CommonHooksFnMap export const createHooksStore = ({ + interactionMode = 'default', doSyncWorkflowDraft = async () => noop(), syncWorkflowDraftWhenPageClose = noop, handleRefreshWorkflowDraft = noop, @@ -118,6 +120,7 @@ export const createHooksStore = ({ }: Partial) => { return createStore(set => ({ refreshAll: props => set(state => ({ ...state, ...props })), + interactionMode, doSyncWorkflowDraft, syncWorkflowDraftWhenPageClose, handleRefreshWorkflowDraft, diff --git a/web/app/components/workflow/hooks/use-shortcuts.ts b/web/app/components/workflow/hooks/use-shortcuts.ts index 0845dbd2d9..64e9e9f794 100644 --- a/web/app/components/workflow/hooks/use-shortcuts.ts +++ b/web/app/components/workflow/hooks/use-shortcuts.ts @@ -17,7 +17,7 @@ import { } from '../utils' import { useWorkflowHistoryStore } from '../workflow-history-store' -export const useShortcuts = (): void => { +export const useShortcuts = (enabled = true): void => { const { handleNodesCopy, handleNodesPaste, @@ -66,13 +66,17 @@ export const useShortcuts = (): void => { } const shouldHandleShortcut = useCallback((e: KeyboardEvent) => { + if (!enabled) + return false return !isEventTargetInputArea(e.target as HTMLElement) - }, []) + }, [enabled]) const shouldHandleCopy = useCallback(() => { + if (!enabled) + return false const selection = document.getSelection() return !selection || selection.isCollapsed - }, []) + }, [enabled]) useKeyPress(['delete', 'backspace'], (e) => { if (shouldHandleShortcut(e)) { @@ -282,6 +286,8 @@ export const useShortcuts = (): void => { // Listen for zen toggle event from /zen command useEffect(() => { + if (!enabled) + return const handleZenToggle = () => { handleToggleMaximizeCanvas() } @@ -290,5 +296,5 @@ export const useShortcuts = (): void => { return () => { window.removeEventListener(ZEN_TOGGLE_EVENT, handleZenToggle) } - }, [handleToggleMaximizeCanvas]) + }, [enabled, handleToggleMaximizeCanvas]) } diff --git a/web/app/components/workflow/hooks/use-workflow.ts b/web/app/components/workflow/hooks/use-workflow.ts index f834b06260..1daf06dd02 100644 --- a/web/app/components/workflow/hooks/use-workflow.ts +++ b/web/app/components/workflow/hooks/use-workflow.ts @@ -498,13 +498,9 @@ export const useNodesReadOnly = () => { const isRestoring = useStore(s => s.isRestoring) const getNodesReadOnly = useCallback((): boolean => { - const { - workflowRunningData, - historyWorkflowData, - isRestoring, - } = workflowStore.getState() + const state = workflowStore.getState() - return !!(workflowRunningData?.result.status === WorkflowRunningStatus.Running || historyWorkflowData || isRestoring) + return !!(state.workflowRunningData?.result.status === WorkflowRunningStatus.Running || state.historyWorkflowData || state.isRestoring) }, [workflowStore]) return { diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx index 46967b9688..1a2a42bcde 100644 --- a/web/app/components/workflow/index.tsx +++ b/web/app/components/workflow/index.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react' import type { + NodeMouseHandler, Viewport, } from 'reactflow' import type { Shape as HooksStoreShape } from './hooks-store' @@ -102,6 +103,7 @@ import { } from './store' import SyncingDataModal from './syncing-data-modal' import { + BlockEnum, ControlMode, } from './types' import { setupScrollToNodeListener } from './utils/node-navigation' @@ -134,6 +136,9 @@ export type WorkflowProps = { viewport?: Viewport children?: React.ReactNode onWorkflowDataUpdate?: (v: any) => void + allowSelectionWhenReadOnly?: boolean + canvasReadOnly?: boolean + interactionMode?: 'default' | 'subgraph' } export const Workflow: FC = memo(({ nodes: originalNodes, @@ -141,6 +146,9 @@ export const Workflow: FC = memo(({ viewport, children, onWorkflowDataUpdate, + allowSelectionWhenReadOnly = false, + canvasReadOnly = false, + interactionMode = 'default', }) => { const workflowContainerRef = useRef(null) const workflowStore = useWorkflowStore() @@ -196,7 +204,7 @@ export const Workflow: FC = memo(({ if (!isEqual(oldData, nodesData)) { setNodesInStore(nodes) } - }, [setNodesInStore, workflowStore]) + }, [setNodesInStore]) useEffect(() => { setNodesOnlyChangeWithData(currentNodes as Node[]) }, [currentNodes, setNodesOnlyChangeWithData]) @@ -328,7 +336,8 @@ export const Workflow: FC = memo(({ }, }) - useShortcuts() + const isSubGraph = interactionMode === 'subgraph' + useShortcuts(!isSubGraph) // Initialize workflow node search functionality useWorkflowSearch() @@ -382,6 +391,16 @@ export const Workflow: FC = memo(({ } } + const handleNodeClickInMode = useCallback( + (event, node) => { + if (isSubGraph && node.data.type !== BlockEnum.LLM) + return + + handleNodeClick(event, node) + }, + [handleNodeClick, isSubGraph], + ) + return (
= memo(({ ref={workflowContainerRef} > - + {!isSubGraph && }
- + {!isSubGraph && }
- - - - - + {!isSubGraph && } + {!isSubGraph && } + {!isSubGraph && } + {!isSubGraph && } + {!isSubGraph && } { !!showConfirm && ( = memo(({ onNodeDragStop={handleNodeDragStop} onNodeMouseEnter={handleNodeEnter} onNodeMouseLeave={handleNodeLeave} - onNodeClick={handleNodeClick} - onNodeContextMenu={handleNodeContextMenu} - onConnect={handleNodeConnect} - onConnectStart={handleNodeConnectStart} - onConnectEnd={handleNodeConnectEnd} + onNodeClick={handleNodeClickInMode} + onNodeContextMenu={isSubGraph ? undefined : handleNodeContextMenu} + onConnect={isSubGraph ? undefined : handleNodeConnect} + onConnectStart={isSubGraph ? undefined : handleNodeConnectStart} + onConnectEnd={isSubGraph ? undefined : handleNodeConnectEnd} onEdgeMouseEnter={handleEdgeEnter} onEdgeMouseLeave={handleEdgeLeave} onEdgesChange={handleEdgesChange} - onSelectionStart={handleSelectionStart} - onSelectionChange={handleSelectionChange} - onSelectionDrag={handleSelectionDrag} - onPaneContextMenu={handlePaneContextMenu} - onSelectionContextMenu={handleSelectionContextMenu} + onSelectionStart={isSubGraph ? undefined : handleSelectionStart} + onSelectionChange={isSubGraph ? undefined : handleSelectionChange} + onSelectionDrag={isSubGraph ? undefined : handleSelectionDrag} + onPaneContextMenu={isSubGraph ? undefined : handlePaneContextMenu} + onSelectionContextMenu={isSubGraph ? undefined : handleSelectionContextMenu} connectionLineComponent={CustomConnectionLine} // NOTE: For LOOP node, how to distinguish between ITERATION and LOOP here? Maybe both are the same? connectionLineContainerStyle={{ zIndex: ITERATION_CHILDREN_Z_INDEX }} defaultViewport={viewport} multiSelectionKeyCode={null} deleteKeyCode={null} - nodesDraggable={!nodesReadOnly} - nodesConnectable={!nodesReadOnly} - nodesFocusable={!nodesReadOnly} - edgesFocusable={!nodesReadOnly} - panOnScroll={controlMode === ControlMode.Pointer && !workflowReadOnly} - panOnDrag={controlMode === ControlMode.Hand || [1]} - zoomOnPinch={true} - zoomOnScroll={true} - zoomOnDoubleClick={true} + nodesDraggable={!(nodesReadOnly || canvasReadOnly || isSubGraph)} + nodesConnectable={!(nodesReadOnly || canvasReadOnly || isSubGraph)} + nodesFocusable={allowSelectionWhenReadOnly ? true : !nodesReadOnly} + edgesFocusable={isSubGraph ? false : (allowSelectionWhenReadOnly ? true : !nodesReadOnly)} + panOnScroll={!isSubGraph && controlMode === ControlMode.Pointer && !workflowReadOnly} + panOnDrag={!isSubGraph && (controlMode === ControlMode.Hand || [1])} + selectionOnDrag={!isSubGraph && controlMode === ControlMode.Pointer && !workflowReadOnly && !canvasReadOnly} + zoomOnPinch={!isSubGraph} + zoomOnScroll={!isSubGraph} + zoomOnDoubleClick={!isSubGraph} isValidConnection={isValidConnection} selectionKeyCode={null} selectionMode={SelectionMode.Partial} - selectionOnDrag={controlMode === ControlMode.Pointer && !workflowReadOnly} minZoom={0.25} > = ({ const [open, setOpen] = useState(false) const { handleNodeSelect } = useNodesInteractions() const workflowStore = useWorkflowStore() + const interactionMode = useHooksStore(s => s.interactionMode) const isSingleRunning = data._singleRunningStatus === NodeRunningStatus.Running const handleOpenChange = useCallback((newOpen: boolean) => { setOpen(newOpen) }, []) const isChildNode = !!(data.isInIteration || data.isInLoop) + const allowNodeMenu = interactionMode !== 'subgraph' + const canSingleRun = canRunBySingle(data.type, isChildNode) + + if (!allowNodeMenu && !canSingleRun) + return null return (
= ({ onClick={e => e.stopPropagation()} > { - canRunBySingle(data.type, isChildNode) && ( + canSingleRun && (
{ @@ -80,13 +87,15 @@ const NodeControl: FC = ({
) } - + {allowNodeMenu && ( + + )}
) diff --git a/web/app/components/workflow/nodes/_base/components/node-handle.tsx b/web/app/components/workflow/nodes/_base/components/node-handle.tsx index 1bd8ea84e8..30e23e4f6f 100644 --- a/web/app/components/workflow/nodes/_base/components/node-handle.tsx +++ b/web/app/components/workflow/nodes/_base/components/node-handle.tsx @@ -12,6 +12,7 @@ import { Handle, Position, } from 'reactflow' +import { useHooksStore } from '@/app/components/workflow/hooks-store' import { cn } from '@/utils/classnames' import BlockSelector from '../../../block-selector' import { @@ -46,6 +47,8 @@ export const NodeTargetHandle = memo(({ const [open, setOpen] = useState(false) const { handleNodeAdd } = useNodesInteractions() const { getNodesReadOnly } = useNodesReadOnly() + const interactionMode = useHooksStore(s => s.interactionMode) + const allowGraphActions = interactionMode !== 'subgraph' const connected = data._connectedTargetHandleIds?.includes(handleId) const { availablePrevBlocks } = useAvailableBlocks(data.type, data.isInIteration || data.isInLoop) const isConnectable = !!availablePrevBlocks.length @@ -55,9 +58,9 @@ export const NodeTargetHandle = memo(({ }, []) const handleHandleClick = useCallback((e: MouseEvent) => { e.stopPropagation() - if (!connected) + if (!connected && allowGraphActions) setOpen(v => !v) - }, [connected]) + }, [allowGraphActions, connected]) const handleSelect = useCallback((type: BlockEnum, pluginDefaultValue?: PluginDefaultValue) => { handleNodeAdd( { @@ -91,11 +94,11 @@ export const NodeTargetHandle = memo(({ || data.type === BlockEnum.TriggerPlugin) && 'opacity-0', handleClassName, )} - isConnectable={isConnectable} - onClick={handleHandleClick} + isConnectable={allowGraphActions && isConnectable} + onClick={allowGraphActions ? handleHandleClick : undefined} > { - !connected && isConnectable && !getNodesReadOnly() && ( + allowGraphActions && !connected && isConnectable && !getNodesReadOnly() && ( s.interactionMode) + const allowGraphActions = interactionMode !== 'subgraph' const { availableNextBlocks } = useAvailableBlocks(data.type, data.isInIteration || data.isInLoop) const isConnectable = !!availableNextBlocks.length const isChatMode = useIsChatMode() @@ -145,8 +150,9 @@ export const NodeSourceHandle = memo(({ }, []) const handleHandleClick = useCallback((e: MouseEvent) => { e.stopPropagation() - setOpen(v => !v) - }, []) + if (allowGraphActions) + setOpen(v => !v) + }, [allowGraphActions]) const handleSelect = useCallback((type: BlockEnum, pluginDefaultValue?: PluginDefaultValue) => { handleNodeAdd( { @@ -161,7 +167,7 @@ export const NodeSourceHandle = memo(({ }, [handleNodeAdd, id, handleId]) useEffect(() => { - if (!shouldAutoOpenStartNodeSelector) + if (!shouldAutoOpenStartNodeSelector || !allowGraphActions) return if (isChatMode) { @@ -198,8 +204,8 @@ export const NodeSourceHandle = memo(({ !connected && 'after:opacity-0', handleClassName, )} - isConnectable={isConnectable} - onClick={handleHandleClick} + isConnectable={allowGraphActions && isConnectable} + onClick={allowGraphActions ? handleHandleClick : undefined} >
@@ -214,7 +220,7 @@ export const NodeSourceHandle = memo(({
{ - isConnectable && !getNodesReadOnly() && ( + allowGraphActions && isConnectable && !getNodesReadOnly() && ( = ({ } = useNodesMetaData() const configsMap = useHooksStore(s => s.configsMap) + const interactionMode = useHooksStore(s => s.interactionMode) + const allowGraphActions = interactionMode !== 'subgraph' const { isShowSingleRun, hideSingleRun, @@ -514,9 +516,9 @@ const BasePanel: FC = ({ ) } - - -
+ {allowGraphActions && } + {allowGraphActions && } + {allowGraphActions &&
}
handleNodeSelect(id, true)} @@ -623,7 +625,7 @@ const BasePanel: FC = ({
{ - hasRetryNode(data.type) && ( + allowGraphActions && hasRetryNode(data.type) && ( = ({ ) } { - hasErrorHandleNode(data.type) && ( + allowGraphActions && hasErrorHandleNode(data.type) && ( = ({ ) } { - !!availableNextBlocks.length && ( + allowGraphActions && !!availableNextBlocks.length && (
{t('panel.nextStep', { ns: 'workflow' }).toLocaleUpperCase()} @@ -651,7 +653,7 @@ const BasePanel: FC = ({
) } - {readmeEntranceComponent} + {allowGraphActions ? readmeEntranceComponent : null}
)} diff --git a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx index ee550b9c86..68a985dccc 100644 --- a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx +++ b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx @@ -1,12 +1,19 @@ 'use client' import type { FC } from 'react' import type { SubGraphModalProps } from './types' +import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types' +import type { ToolNodeType } from '@/app/components/workflow/nodes/tool/types' +import type { Node, PromptItem } from '@/app/components/workflow/types' import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react' import { RiCloseLine } from '@remixicon/react' import { noop } from 'es-toolkit/function' -import { Fragment, memo } from 'react' +import { Fragment, memo, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' +import { useStoreApi } from 'reactflow' import { Agent } from '@/app/components/base/icons/src/vender/workflow' +import { useNodesSyncDraft } from '@/app/components/workflow/hooks' +import { useStore } from '@/app/components/workflow/store' +import { PromptRole } from '@/app/components/workflow/types' import SubGraphCanvas from './sub-graph-canvas' const SubGraphModal: FC = ({ @@ -19,6 +26,76 @@ const SubGraphModal: FC = ({ agentNodeId, }) => { const { t } = useTranslation() + const reactflowStore = useStoreApi() + const workflowNodes = useStore(state => state.nodes) + const { handleSyncWorkflowDraft } = useNodesSyncDraft() + + const extractorNodeId = `${toolNodeId}_ext_${paramKey}` + const extractorNode = useMemo(() => { + return workflowNodes.find(node => node.id === extractorNodeId) as Node | undefined + }, [extractorNodeId, workflowNodes]) + const toolNode = useMemo(() => { + return workflowNodes.find(node => node.id === toolNodeId) + }, [toolNodeId, workflowNodes]) + const toolParamValue = (toolNode?.data as ToolNodeType | undefined)?.tool_parameters?.[paramKey]?.value as string | undefined + + const getSystemPromptText = useCallback((promptTemplate?: PromptItem[] | PromptItem) => { + if (!promptTemplate) + return '' + if (Array.isArray(promptTemplate)) { + const systemPrompt = promptTemplate.find(item => item.role === PromptRole.system) + return systemPrompt?.text || '' + } + return promptTemplate.text || '' + }, []) + + const handleSave = useCallback((subGraphNodes: any[], _edges: any[]) => { + const extractorNodeData = subGraphNodes.find(node => node.id === extractorNodeId) + if (!extractorNodeData) + return + + const systemPromptText = getSystemPromptText(extractorNodeData.data?.prompt_template) + const placeholder = `{{@${agentNodeId}.context@}}` + const nextValue = `${placeholder}${systemPromptText}` + + const { getNodes, setNodes } = reactflowStore.getState() + const nextNodes = getNodes().map((node) => { + if (node.id === extractorNodeId) { + return { + ...node, + hidden: true, + data: { + ...node.data, + ...extractorNodeData.data, + parent_node_id: toolNodeId, + }, + } + } + if (node.id === toolNodeId) { + const toolData = node.data as ToolNodeType + if (!toolData.tool_parameters?.[paramKey]) + return node + + return { + ...node, + data: { + ...toolData, + tool_parameters: { + ...toolData.tool_parameters, + [paramKey]: { + ...toolData.tool_parameters[paramKey], + value: nextValue, + }, + }, + }, + } + } + return node + }) + setNodes(nextNodes) + // Trigger main graph draft sync to persist changes to backend + handleSyncWorkflowDraft() + }, [agentNodeId, extractorNodeId, getSystemPromptText, handleSyncWorkflowDraft, paramKey, reactflowStore, toolNodeId]) return ( @@ -58,6 +135,9 @@ const SubGraphModal: FC = ({ sourceVariable={sourceVariable} agentNodeId={agentNodeId} agentName={agentName} + extractorNode={extractorNode} + toolParamValue={toolParamValue} + onSave={handleSave} />
diff --git a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/sub-graph-canvas.tsx b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/sub-graph-canvas.tsx index f13a48d87c..120015e189 100644 --- a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/sub-graph-canvas.tsx +++ b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/sub-graph-canvas.tsx @@ -10,6 +10,9 @@ const SubGraphCanvas: FC = ({ sourceVariable, agentNodeId, agentName, + extractorNode, + toolParamValue, + onSave, }) => { return (
@@ -19,6 +22,9 @@ const SubGraphCanvas: FC = ({ sourceVariable={sourceVariable} agentNodeId={agentNodeId} agentName={agentName} + extractorNode={extractorNode} + toolParamValue={toolParamValue} + onSave={onSave} />
) diff --git a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/types.ts b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/types.ts index 4b33b0cfde..4023b258c7 100644 --- a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/types.ts +++ b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/types.ts @@ -1,11 +1,14 @@ -import type { ValueSelector } from '@/app/components/workflow/types' +import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types' +import type { Edge as WorkflowEdge, Node as WorkflowNode } from '@/app/components/workflow/types' + +type WorkflowValueSelector = string[] export type SubGraphModalProps = { isOpen: boolean onClose: () => void toolNodeId: string paramKey: string - sourceVariable: ValueSelector + sourceVariable: WorkflowValueSelector agentName: string agentNodeId: string } @@ -13,7 +16,10 @@ export type SubGraphModalProps = { export type SubGraphCanvasProps = { toolNodeId: string paramKey: string - sourceVariable: ValueSelector + sourceVariable: WorkflowValueSelector agentNodeId: string agentName: string + extractorNode?: WorkflowNode + toolParamValue?: string + onSave?: (nodes: WorkflowNode[], edges: WorkflowEdge[]) => void } From bc771d9c506e2037f52c58e1da8805e4c9605b35 Mon Sep 17 00:00:00 2001 From: zhsama Date: Tue, 13 Jan 2026 22:51:29 +0800 Subject: [PATCH 42/82] feat: Add onSave prop to SubGraph components for draft sync --- .../sub-graph/components/sub-graph-main.tsx | 58 ++++++------------- web/app/components/sub-graph/index.tsx | 2 + .../tool/components/sub-graph-modal/index.tsx | 6 +- 3 files changed, 24 insertions(+), 42 deletions(-) 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 15627b8642..727693162c 100644 --- a/web/app/components/sub-graph/components/sub-graph-main.tsx +++ b/web/app/components/sub-graph/components/sub-graph-main.tsx @@ -1,7 +1,8 @@ import type { FC } from 'react' import type { Viewport } from 'reactflow' import type { Edge, Node } from '@/app/components/workflow/types' -import { useMemo } from 'react' +import { useCallback, useMemo } from 'react' +import { useStoreApi } from 'reactflow' import { WorkflowWithInnerContext } from '@/app/components/workflow' import { useAvailableNodesMetaData, useSubGraphPersistence } from '../hooks' import SubGraphChildren from './sub-graph-children' @@ -12,6 +13,7 @@ type SubGraphMainProps = { viewport: Viewport toolNodeId: string paramKey: string + onSave?: (nodes: Node[], edges: Edge[]) => void } const SubGraphMain: FC = ({ @@ -20,49 +22,25 @@ const SubGraphMain: FC = ({ viewport, toolNodeId, paramKey, + onSave, }) => { + const reactFlowStore = useStoreApi() const availableNodesMetaData = useAvailableNodesMetaData() const { updateSubGraphConfig } = useSubGraphPersistence({ toolNodeId, paramKey }) - const hooksStore = useMemo(() => { - return { - interactionMode: 'subgraph', - availableNodesMetaData, - doSyncWorkflowDraft: async () => {}, - syncWorkflowDraftWhenPageClose: () => {}, - handleRefreshWorkflowDraft: () => {}, - handleBackupDraft: () => {}, - handleLoadBackupDraft: () => {}, - handleRestoreFromPublishedWorkflow: () => {}, - handleRun: () => {}, - handleStopRun: () => {}, - handleStartWorkflowRun: () => {}, - handleWorkflowStartRunInWorkflow: () => {}, - handleWorkflowStartRunInChatflow: () => {}, - handleWorkflowTriggerScheduleRunInWorkflow: () => {}, - handleWorkflowTriggerWebhookRunInWorkflow: () => {}, - handleWorkflowTriggerPluginRunInWorkflow: () => {}, - handleWorkflowRunAllTriggersInWorkflow: () => {}, - getWorkflowRunAndTraceUrl: () => ({ runUrl: '', traceUrl: '' }), - exportCheck: async () => {}, - handleExportDSL: async () => {}, - fetchInspectVars: async () => {}, - hasNodeInspectVars: () => false, - hasSetInspectVar: () => false, - fetchInspectVarValue: async () => {}, - editInspectVarValue: async () => {}, - renameInspectVarName: async () => {}, - appendNodeInspectVars: () => {}, - deleteInspectVar: async () => {}, - deleteNodeInspectorVars: async () => {}, - deleteAllInspectorVars: async () => {}, - isInspectVarEdited: () => false, - resetToLastRunVar: async () => {}, - invalidateSysVarValues: () => {}, - resetConversationVar: async () => {}, - invalidateConversationVarValues: () => {}, - } - }, [availableNodesMetaData]) + const handleSyncSubGraphDraft = useCallback(() => { + const { getNodes, edges } = reactFlowStore.getState() + onSave?.(getNodes() as Node[], edges as Edge[]) + }, [onSave, reactFlowStore]) + + const hooksStore = useMemo(() => ({ + interactionMode: 'subgraph', + availableNodesMetaData, + doSyncWorkflowDraft: async () => { + handleSyncSubGraphDraft() + }, + syncWorkflowDraftWhenPageClose: handleSyncSubGraphDraft, + }), [availableNodesMetaData, handleSyncSubGraphDraft]) return ( = (props) => { agentNodeId, extractorNode, toolParamValue, + onSave, } = props const promptText = useMemo(() => { @@ -132,6 +133,7 @@ const SubGraph: FC = (props) => { viewport={defaultViewport} toolNodeId={toolNodeId} paramKey={paramKey} + onSave={onSave} /> ) diff --git a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx index 68a985dccc..3ee27c3b54 100644 --- a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx +++ b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx @@ -28,6 +28,7 @@ const SubGraphModal: FC = ({ const { t } = useTranslation() const reactflowStore = useStoreApi() const workflowNodes = useStore(state => state.nodes) + const setControlPromptEditorRerenderKey = useStore(state => state.setControlPromptEditorRerenderKey) const { handleSyncWorkflowDraft } = useNodesSyncDraft() const extractorNodeId = `${toolNodeId}_ext_${paramKey}` @@ -94,8 +95,9 @@ const SubGraphModal: FC = ({ }) setNodes(nextNodes) // Trigger main graph draft sync to persist changes to backend - handleSyncWorkflowDraft() - }, [agentNodeId, extractorNodeId, getSystemPromptText, handleSyncWorkflowDraft, paramKey, reactflowStore, toolNodeId]) + handleSyncWorkflowDraft(true) + setControlPromptEditorRerenderKey(Date.now()) + }, [agentNodeId, extractorNodeId, getSystemPromptText, handleSyncWorkflowDraft, paramKey, reactflowStore, setControlPromptEditorRerenderKey, toolNodeId]) return ( From d394adfaf7862f3d9e63bd7871a48580c78c2a7c Mon Sep 17 00:00:00 2001 From: zhsama Date: Tue, 13 Jan 2026 22:57:05 +0800 Subject: [PATCH 43/82] feat: Fix prompt template handling for Jinja2 edition type --- web/app/components/sub-graph/index.tsx | 39 ++++++++++++++----- .../tool/components/sub-graph-modal/index.tsx | 13 +++++-- 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/web/app/components/sub-graph/index.tsx b/web/app/components/sub-graph/index.tsx index 1ae9ad935d..91d70fab7d 100644 --- a/web/app/components/sub-graph/index.tsx +++ b/web/app/components/sub-graph/index.tsx @@ -6,7 +6,7 @@ import type { PromptItem } from '@/app/components/workflow/types' import { memo, useMemo } from 'react' import WorkflowWithDefaultContext from '@/app/components/workflow' import { WorkflowContextProvider } from '@/app/components/workflow/context' -import { BlockEnum, PromptRole } from '@/app/components/workflow/types' +import { BlockEnum, EditionType, PromptRole } from '@/app/components/workflow/types' import SubGraphMain from './components/sub-graph-main' import { useSubGraphNodes } from './hooks' import { createSubGraphSlice } from './store' @@ -62,21 +62,40 @@ const SubGraph: FC = (props) => { if (!extractorNode) return null - const nextPromptTemplate = Array.isArray(extractorNode.data.prompt_template) - ? extractorNode.data.prompt_template.map((item: PromptItem) => { - if (item.role === PromptRole.system) - return { ...item, text: promptText } - return item - }) - : { - ...extractorNode.data.prompt_template, + const updateSystemPrompt = (item: PromptItem) => { + if (item.role !== PromptRole.system) + return item + if (item.edition_type === EditionType.jinja2) { + return { + ...item, text: promptText, + jinja2_text: promptText, } + } + return { ...item, text: promptText } + } + + const nextPromptTemplate = Array.isArray(extractorNode.data.prompt_template) + ? extractorNode.data.prompt_template.map(updateSystemPrompt) + : updateSystemPrompt(extractorNode.data.prompt_template as PromptItem) const hasSystemPrompt = Array.isArray(nextPromptTemplate) && nextPromptTemplate.some((item: PromptItem) => item.role === PromptRole.system) + const defaultSystemPrompt: PromptItem = (() => { + const useJinja = Array.isArray(nextPromptTemplate) + && nextPromptTemplate.some((item: PromptItem) => item.edition_type === EditionType.jinja2) + if (useJinja) { + return { + role: PromptRole.system, + text: promptText, + jinja2_text: promptText, + edition_type: EditionType.jinja2, + } + } + return { role: PromptRole.system, text: promptText } + })() const normalizedPromptTemplate = Array.isArray(nextPromptTemplate) - ? (hasSystemPrompt ? nextPromptTemplate : [{ role: PromptRole.system, text: promptText }, ...nextPromptTemplate]) + ? (hasSystemPrompt ? nextPromptTemplate : [defaultSystemPrompt, ...nextPromptTemplate]) : nextPromptTemplate return { diff --git a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx index 3ee27c3b54..1886c4c2e2 100644 --- a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx +++ b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx @@ -13,7 +13,7 @@ import { useStoreApi } from 'reactflow' import { Agent } from '@/app/components/base/icons/src/vender/workflow' import { useNodesSyncDraft } from '@/app/components/workflow/hooks' import { useStore } from '@/app/components/workflow/store' -import { PromptRole } from '@/app/components/workflow/types' +import { EditionType, PromptRole } from '@/app/components/workflow/types' import SubGraphCanvas from './sub-graph-canvas' const SubGraphModal: FC = ({ @@ -43,11 +43,18 @@ const SubGraphModal: FC = ({ const getSystemPromptText = useCallback((promptTemplate?: PromptItem[] | PromptItem) => { if (!promptTemplate) return '' + const resolveText = (item?: PromptItem) => { + if (!item) + return '' + if (item.edition_type === EditionType.jinja2) + return item.jinja2_text || item.text || '' + return item.text || '' + } if (Array.isArray(promptTemplate)) { const systemPrompt = promptTemplate.find(item => item.role === PromptRole.system) - return systemPrompt?.text || '' + return resolveText(systemPrompt) } - return promptTemplate.text || '' + return resolveText(promptTemplate) }, []) const handleSave = useCallback((subGraphNodes: any[], _edges: any[]) => { From b7025ad9d6c104266b784dc79066c84a09923575 Mon Sep 17 00:00:00 2001 From: zhsama Date: Tue, 13 Jan 2026 23:23:18 +0800 Subject: [PATCH 44/82] feat: change sub-graph prompt handling to use user role --- web/app/components/sub-graph/index.tsx | 57 +++++++++++-------- .../components/workflow/nodes/_base/node.tsx | 52 ++++++++++++++++- .../tool/components/sub-graph-modal/index.tsx | 11 ++-- 3 files changed, 90 insertions(+), 30 deletions(-) diff --git a/web/app/components/sub-graph/index.tsx b/web/app/components/sub-graph/index.tsx index 91d70fab7d..c7ec066108 100644 --- a/web/app/components/sub-graph/index.tsx +++ b/web/app/components/sub-graph/index.tsx @@ -48,6 +48,8 @@ const SubGraph: FC = (props) => { desc: '', _connectedSourceHandleIds: ['source'], _connectedTargetHandleIds: [], + _subGraphEntry: true, + _iconTypeOverride: BlockEnum.Agent, variables: [], }, selectable: false, @@ -62,9 +64,7 @@ const SubGraph: FC = (props) => { if (!extractorNode) return null - const updateSystemPrompt = (item: PromptItem) => { - if (item.role !== PromptRole.system) - return item + const applyPromptText = (item: PromptItem) => { if (item.edition_type === EditionType.jinja2) { return { ...item, @@ -75,36 +75,45 @@ const SubGraph: FC = (props) => { return { ...item, text: promptText } } - const nextPromptTemplate = Array.isArray(extractorNode.data.prompt_template) - ? extractorNode.data.prompt_template.map(updateSystemPrompt) - : updateSystemPrompt(extractorNode.data.prompt_template as PromptItem) + const nextPromptTemplate = (() => { + const template = extractorNode.data.prompt_template + if (!Array.isArray(template)) + return applyPromptText(template as PromptItem) - const hasSystemPrompt = Array.isArray(nextPromptTemplate) - && nextPromptTemplate.some((item: PromptItem) => item.role === PromptRole.system) - const defaultSystemPrompt: PromptItem = (() => { - const useJinja = Array.isArray(nextPromptTemplate) - && nextPromptTemplate.some((item: PromptItem) => item.edition_type === EditionType.jinja2) - if (useJinja) { - return { - role: PromptRole.system, - text: promptText, - jinja2_text: promptText, - edition_type: EditionType.jinja2, - } + const userIndex = template.findIndex(item => item.role === PromptRole.user) + if (userIndex >= 0) { + return template.map((item, index) => { + if (index !== userIndex) + return item + return applyPromptText(item) + }) } - return { role: PromptRole.system, text: promptText } + + const useJinja = template.some((item: PromptItem) => item.edition_type === EditionType.jinja2) + const defaultUserPrompt: PromptItem = useJinja + ? { + role: PromptRole.user, + text: promptText, + jinja2_text: promptText, + edition_type: EditionType.jinja2, + } + : { role: PromptRole.user, text: promptText } + const systemIndex = template.findIndex(item => item.role === PromptRole.system) + const nextTemplate = [...template] + if (systemIndex >= 0) + nextTemplate.splice(systemIndex + 1, 0, defaultUserPrompt) + else + nextTemplate.unshift(defaultUserPrompt) + return nextTemplate })() - const normalizedPromptTemplate = Array.isArray(nextPromptTemplate) - ? (hasSystemPrompt ? nextPromptTemplate : [defaultSystemPrompt, ...nextPromptTemplate]) - : nextPromptTemplate return { ...extractorNode, hidden: false, - position: { x: 450, y: 150 }, + position: { x: 320, y: 150 }, data: { ...extractorNode.data, - prompt_template: normalizedPromptTemplate, + prompt_template: nextPromptTemplate, }, } }, [extractorNode, promptText]) diff --git a/web/app/components/workflow/nodes/_base/node.tsx b/web/app/components/workflow/nodes/_base/node.tsx index 607a29f098..c484ff48f2 100644 --- a/web/app/components/workflow/nodes/_base/node.tsx +++ b/web/app/components/workflow/nodes/_base/node.tsx @@ -63,6 +63,11 @@ const BaseNode: FC = ({ const { t } = useTranslation() const nodeRef = useRef(null) const { nodesReadOnly } = useNodesReadOnly() + const { _subGraphEntry, _iconTypeOverride } = data as { + _subGraphEntry?: boolean + _iconTypeOverride?: BlockEnum + } + const iconType = _iconTypeOverride ?? data.type const { handleNodeIterationChildSizeChange } = useNodeIterationInteractions() const { handleNodeLoopChildSizeChange } = useNodeLoopInteractions() @@ -138,6 +143,48 @@ const BaseNode: FC = ({ return null }, [data._loopIndex, data._runningStatus, t]) + if (_subGraphEntry) { + return ( +
+ +
+
+ +
+ {data.title} +
+
+
+
+ ) + } + const nodeContent = (
= ({ > @@ -344,8 +391,9 @@ const BaseNode: FC = ({ const isStartNode = data.type === BlockEnum.Start const isEntryNode = isTriggerNode(data.type as any) || isStartNode + const shouldWrapEntryNode = isEntryNode && !(isStartNode && _subGraphEntry) - return isEntryNode + return shouldWrapEntryNode ? ( = ({ }, [toolNodeId, workflowNodes]) const toolParamValue = (toolNode?.data as ToolNodeType | undefined)?.tool_parameters?.[paramKey]?.value as string | undefined - const getSystemPromptText = useCallback((promptTemplate?: PromptItem[] | PromptItem) => { + const getUserPromptText = useCallback((promptTemplate?: PromptItem[] | PromptItem) => { if (!promptTemplate) return '' const resolveText = (item?: PromptItem) => { @@ -51,6 +51,9 @@ const SubGraphModal: FC = ({ return item.text || '' } if (Array.isArray(promptTemplate)) { + const userPrompt = promptTemplate.find(item => item.role === PromptRole.user) + if (userPrompt) + return resolveText(userPrompt) const systemPrompt = promptTemplate.find(item => item.role === PromptRole.system) return resolveText(systemPrompt) } @@ -62,9 +65,9 @@ const SubGraphModal: FC = ({ if (!extractorNodeData) return - const systemPromptText = getSystemPromptText(extractorNodeData.data?.prompt_template) + const userPromptText = getUserPromptText(extractorNodeData.data?.prompt_template) const placeholder = `{{@${agentNodeId}.context@}}` - const nextValue = `${placeholder}${systemPromptText}` + const nextValue = `${placeholder}${userPromptText}` const { getNodes, setNodes } = reactflowStore.getState() const nextNodes = getNodes().map((node) => { @@ -104,7 +107,7 @@ const SubGraphModal: FC = ({ // Trigger main graph draft sync to persist changes to backend handleSyncWorkflowDraft(true) setControlPromptEditorRerenderKey(Date.now()) - }, [agentNodeId, extractorNodeId, getSystemPromptText, handleSyncWorkflowDraft, paramKey, reactflowStore, setControlPromptEditorRerenderKey, toolNodeId]) + }, [agentNodeId, extractorNodeId, getUserPromptText, handleSyncWorkflowDraft, paramKey, reactflowStore, setControlPromptEditorRerenderKey, toolNodeId]) return ( From b9052bc244952b6c89ab066dfa79b64d292992ac Mon Sep 17 00:00:00 2001 From: zhsama Date: Wed, 14 Jan 2026 03:22:42 +0800 Subject: [PATCH 45/82] feat: add sub-graph config panel with variable selection and null handling --- .../sub-graph/components/config-panel.tsx | 219 +++++++++++++----- .../components/sub-graph-children.tsx | 84 +++++-- .../sub-graph/components/sub-graph-main.tsx | 23 +- web/app/components/sub-graph/index.tsx | 31 ++- web/app/components/sub-graph/types.ts | 3 + .../tool/components/sub-graph-modal/index.tsx | 65 +++++- .../sub-graph-modal/sub-graph-canvas.tsx | 4 + .../tool/components/sub-graph-modal/types.ts | 3 + web/i18n/en-US/workflow.json | 4 + web/i18n/ja-JP/workflow.json | 4 + web/i18n/zh-Hans/workflow.json | 4 + web/i18n/zh-Hant/workflow.json | 4 + 12 files changed, 351 insertions(+), 97 deletions(-) diff --git a/web/app/components/sub-graph/components/config-panel.tsx b/web/app/components/sub-graph/components/config-panel.tsx index 9f0245c23d..0b8674ffe4 100644 --- a/web/app/components/sub-graph/components/config-panel.tsx +++ b/web/app/components/sub-graph/components/config-panel.tsx @@ -1,85 +1,178 @@ 'use client' import type { FC } from 'react' -import type { WhenOutputNoneOption } from '../types' -import { memo, useCallback, useState } from 'react' +import type { Item } from '@/app/components/base/select' +import type { MentionConfig } from '@/app/components/workflow/nodes/_base/types' +import type { Node, NodeOutPutVar, ValueSelector } from '@/app/components/workflow/types' +import { RiCheckLine } from '@remixicon/react' +import { memo, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' +import { SimpleSelect } from '@/app/components/base/select' +import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' import Field from '@/app/components/workflow/nodes/_base/components/field' +import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker' +import Tab, { TabType } from '@/app/components/workflow/nodes/_base/components/workflow-panel/tab' +import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' import { cn } from '@/utils/classnames' type ConfigPanelProps = { - toolNodeId: string - paramKey: string - activeTab: 'settings' | 'lastRun' - onTabChange: (tab: 'settings' | 'lastRun') => void + agentName: string + extractorNodeId: string + mentionConfig: MentionConfig + availableNodes: Node[] + availableVars: NodeOutPutVar[] + onMentionConfigChange: (config: MentionConfig) => void } -const outputVariables = [ - { name: 'text', type: 'string' }, - { name: 'structured_output', type: 'object' }, -] - const ConfigPanel: FC = ({ - toolNodeId: _toolNodeId, - paramKey: _paramKey, - activeTab, + agentName, + extractorNodeId, + mentionConfig, + availableNodes, + availableVars, + onMentionConfigChange, }) => { const { t } = useTranslation() - const [whenOutputNone, setWhenOutputNone] = useState('default') + const [tabType, setTabType] = useState(TabType.settings) - const handleWhenOutputNoneChange = useCallback((e: React.ChangeEvent) => { - setWhenOutputNone(e.target.value as WhenOutputNoneOption) - }, []) + const resolvedExtractorId = mentionConfig.extractor_node_id || extractorNodeId - if (activeTab === 'lastRun') { - return ( -
-
+ const selectedOutput = useMemo(() => { + if (!resolvedExtractorId || !mentionConfig.output_selector?.length) + return [] + + return [resolvedExtractorId, ...(mentionConfig.output_selector || [])] + }, [mentionConfig.output_selector, resolvedExtractorId]) + + const handleOutputVarChange = useCallback((value: ValueSelector | string) => { + const selector = Array.isArray(value) ? value : [] + const nextExtractorId = selector[0] || resolvedExtractorId + const nextOutputSelector = selector.length > 1 ? selector.slice(1) : [] + + onMentionConfigChange({ + ...mentionConfig, + extractor_node_id: nextExtractorId, + output_selector: nextOutputSelector, + }) + }, [mentionConfig, onMentionConfigChange, resolvedExtractorId]) + + const whenOutputNoneOptions = useMemo(() => ([ + { + value: 'raise_error', + name: t('subGraphModal.whenOutputNone.error', { ns: 'workflow' }), + description: t('subGraphModal.whenOutputNone.errorDesc', { ns: 'workflow' }), + }, + { + value: 'use_default', + name: t('subGraphModal.whenOutputNone.default', { ns: 'workflow' }), + description: t('subGraphModal.whenOutputNone.defaultDesc', { ns: 'workflow' }), + }, + ]), [t]) + + const handleNullStrategyChange = useCallback((item: Item) => { + if (typeof item.value !== 'string') + return + onMentionConfigChange({ + ...mentionConfig, + null_strategy: item.value as MentionConfig['null_strategy'], + }) + }, [mentionConfig, onMentionConfigChange]) + + const handleDefaultValueChange = useCallback((value: string) => { + const trimmed = value.trim() + let nextValue: unknown = value + if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) { + try { + nextValue = JSON.parse(trimmed) + } + catch { + nextValue = value + } + } + + onMentionConfigChange({ + ...mentionConfig, + default_value: nextValue, + }) + }, [mentionConfig, onMentionConfigChange]) + + return ( +
+
+
+ {t('subGraphModal.internalStructure', { ns: 'workflow' })} +
+
+ {t('subGraphModal.internalStructureDesc', { ns: 'workflow', name: agentName })} +
+
+
+ +
+ {tabType === TabType.lastRun && ( +

{t('subGraphModal.noRunHistory', { ns: 'workflow' })}

-
- ) - } - - return ( -
- -
- {outputVariables.map(variable => ( -
- {variable.name} - {variable.type} -
- ))} + )} + {tabType === TabType.settings && ( +
+
+ + + +
+
+ + ( +
+
+ {selected && ( + + )} +
+
+
{item.name}
+
{item.description}
+
+
+ )} + /> +
+ {mentionConfig.null_strategy === 'use_default' && ( +
+
+ {t('subGraphModal.defaultValueHint', { ns: 'workflow' })} +
+
+ +
+
+ )} +
- - - - - + )}
) } diff --git a/web/app/components/sub-graph/components/sub-graph-children.tsx b/web/app/components/sub-graph/components/sub-graph-children.tsx index c9cb989f53..618d0c9e20 100644 --- a/web/app/components/sub-graph/components/sub-graph-children.tsx +++ b/web/app/components/sub-graph/components/sub-graph-children.tsx @@ -1,36 +1,72 @@ import type { FC } from 'react' -import type { SubGraphConfig } from '../types' +import type { MentionConfig } from '@/app/components/workflow/nodes/_base/types' +import type { NodeOutPutVar } from '@/app/components/workflow/types' import { memo, useMemo } from 'react' import { useStore as useReactFlowStore } from 'reactflow' import { useShallow } from 'zustand/react/shallow' +import { useIsChatMode, useWorkflowVariables } from '@/app/components/workflow/hooks' import { Panel as NodePanel } from '@/app/components/workflow/nodes' +import { useStore } from '@/app/components/workflow/store' import { BlockEnum } from '@/app/components/workflow/types' +import ConfigPanel from './config-panel' type SubGraphChildrenProps = { - toolNodeId: string - paramKey: string - onConfigChange: (config: Partial) => void + agentName: string + extractorNodeId: string + mentionConfig: MentionConfig + onMentionConfigChange: (config: MentionConfig) => void } const SubGraphChildren: FC = ({ - toolNodeId: _toolNodeId, - paramKey: _paramKey, - onConfigChange: _onConfigChange, + agentName, + extractorNodeId, + mentionConfig, + onMentionConfigChange, }) => { - const selectedNode = useReactFlowStore(useShallow((s) => { + const { getNodeAvailableVars } = useWorkflowVariables() + const isChatMode = useIsChatMode() + const nodePanelWidth = useStore(s => s.nodePanelWidth) + + const { selectedNode, nodes } = useReactFlowStore(useShallow((s) => { const nodes = s.getNodes() const currentNode = nodes.find(node => node.data.selected) if (currentNode?.data.type === BlockEnum.LLM) { return { - id: currentNode.id, - type: currentNode.type, - data: currentNode.data, + selectedNode: { + id: currentNode.id, + type: currentNode.type, + data: currentNode.data, + }, + nodes, } } - return null + return { + selectedNode: null, + nodes, + } })) + const extractorNode = useMemo(() => { + return nodes.find(node => node.data.type === BlockEnum.LLM) + }, [nodes]) + + const availableNodes = useMemo(() => { + return extractorNode ? [extractorNode] : [] + }, [extractorNode]) + + const availableVars = useMemo(() => { + if (!extractorNode) + return [] + + const vars = getNodeAvailableVars({ + beforeNodes: [extractorNode], + isChatMode, + filterVar: () => true, + }) + return vars.filter(item => item.nodeId === extractorNode.id) + }, [extractorNode, getNodeAvailableVars, isChatMode]) + const nodePanel = useMemo(() => { if (!selectedNode) return null @@ -46,11 +82,25 @@ const SubGraphChildren: FC = ({ return (
- {nodePanel && ( -
- {nodePanel} -
- )} +
+ {nodePanel || ( +
+
+ +
+
+ )} +
) } 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 727693162c..c9e1c93428 100644 --- a/web/app/components/sub-graph/components/sub-graph-main.tsx +++ b/web/app/components/sub-graph/components/sub-graph-main.tsx @@ -1,18 +1,21 @@ import type { FC } from 'react' import type { Viewport } from 'reactflow' +import type { MentionConfig } from '@/app/components/workflow/nodes/_base/types' import type { Edge, Node } from '@/app/components/workflow/types' import { useCallback, useMemo } from 'react' import { useStoreApi } from 'reactflow' import { WorkflowWithInnerContext } from '@/app/components/workflow' -import { useAvailableNodesMetaData, useSubGraphPersistence } from '../hooks' +import { useAvailableNodesMetaData } from '../hooks' import SubGraphChildren from './sub-graph-children' type SubGraphMainProps = { nodes: Node[] edges: Edge[] viewport: Viewport - toolNodeId: string - paramKey: string + agentName: string + extractorNodeId: string + mentionConfig: MentionConfig + onMentionConfigChange: (config: MentionConfig) => void onSave?: (nodes: Node[], edges: Edge[]) => void } @@ -20,13 +23,14 @@ const SubGraphMain: FC = ({ nodes, edges, viewport, - toolNodeId, - paramKey, + agentName, + extractorNodeId, + mentionConfig, + onMentionConfigChange, onSave, }) => { const reactFlowStore = useStoreApi() const availableNodesMetaData = useAvailableNodesMetaData() - const { updateSubGraphConfig } = useSubGraphPersistence({ toolNodeId, paramKey }) const handleSyncSubGraphDraft = useCallback(() => { const { getNodes, edges } = reactFlowStore.getState() @@ -53,9 +57,10 @@ const SubGraphMain: FC = ({ interactionMode="subgraph" > ) diff --git a/web/app/components/sub-graph/index.tsx b/web/app/components/sub-graph/index.tsx index c7ec066108..79f13f0161 100644 --- a/web/app/components/sub-graph/index.tsx +++ b/web/app/components/sub-graph/index.tsx @@ -5,16 +5,27 @@ import type { InjectWorkflowStoreSliceFn } from '@/app/components/workflow/store import type { PromptItem } from '@/app/components/workflow/types' import { memo, useMemo } from 'react' import WorkflowWithDefaultContext from '@/app/components/workflow' +import { NODE_WIDTH_X_OFFSET, START_INITIAL_POSITION } from '@/app/components/workflow/constants' import { WorkflowContextProvider } from '@/app/components/workflow/context' import { BlockEnum, EditionType, PromptRole } from '@/app/components/workflow/types' import SubGraphMain from './components/sub-graph-main' import { useSubGraphNodes } from './hooks' import { createSubGraphSlice } from './store' +const SUB_GRAPH_EDGE_GAP = 180 +const SUB_GRAPH_ENTRY_POSITION = { + x: START_INITIAL_POSITION.x, + y: 150, +} +const SUB_GRAPH_LLM_POSITION = { + x: SUB_GRAPH_ENTRY_POSITION.x + NODE_WIDTH_X_OFFSET - SUB_GRAPH_EDGE_GAP, + y: SUB_GRAPH_ENTRY_POSITION.y, +} + const defaultViewport: Viewport = { - x: 50, + x: SUB_GRAPH_EDGE_GAP, y: 50, - zoom: 1, + zoom: 1.3, } const SubGraph: FC = (props) => { @@ -23,6 +34,8 @@ const SubGraph: FC = (props) => { paramKey, agentName, agentNodeId, + mentionConfig, + onMentionConfigChange, extractorNode, toolParamValue, onSave, @@ -41,7 +54,7 @@ const SubGraph: FC = (props) => { return { id: 'subgraph-source', type: 'custom', - position: { x: 100, y: 150 }, + position: SUB_GRAPH_ENTRY_POSITION, data: { type: BlockEnum.Start, title: agentName, @@ -50,8 +63,10 @@ const SubGraph: FC = (props) => { _connectedTargetHandleIds: [], _subGraphEntry: true, _iconTypeOverride: BlockEnum.Agent, + selected: false, variables: [], }, + selected: false, selectable: false, draggable: false, connectable: false, @@ -110,9 +125,11 @@ const SubGraph: FC = (props) => { return { ...extractorNode, hidden: false, - position: { x: 320, y: 150 }, + selected: false, + position: SUB_GRAPH_LLM_POSITION, data: { ...extractorNode.data, + selected: false, prompt_template: nextPromptTemplate, }, } @@ -159,8 +176,10 @@ const SubGraph: FC = (props) => { nodes={nodes} edges={edges} viewport={defaultViewport} - toolNodeId={toolNodeId} - paramKey={paramKey} + agentName={agentName} + extractorNodeId={`${toolNodeId}_ext_${paramKey}`} + mentionConfig={mentionConfig} + onMentionConfigChange={onMentionConfigChange} onSave={onSave} /> diff --git a/web/app/components/sub-graph/types.ts b/web/app/components/sub-graph/types.ts index 957acba417..587ed9e451 100644 --- a/web/app/components/sub-graph/types.ts +++ b/web/app/components/sub-graph/types.ts @@ -1,4 +1,5 @@ import type { StateCreator } from 'zustand' +import type { MentionConfig } from '@/app/components/workflow/nodes/_base/types' import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types' import type { Edge, Node, NodeOutPutVar, ValueSelector, VarType } from '@/app/components/workflow/types' @@ -26,6 +27,8 @@ export type SubGraphProps = { sourceVariable: ValueSelector agentNodeId: string agentName: string + mentionConfig: MentionConfig + onMentionConfigChange: (config: MentionConfig) => void extractorNode?: Node toolParamValue?: string onSave?: (nodes: Node[], edges: Edge[]) => void diff --git a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx index ea539b5d5c..373b3153a1 100644 --- a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx +++ b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx @@ -1,17 +1,19 @@ 'use client' import type { FC } from 'react' import type { SubGraphModalProps } from './types' +import type { MentionConfig } from '@/app/components/workflow/nodes/_base/types' import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types' import type { ToolNodeType } from '@/app/components/workflow/nodes/tool/types' import type { Node, PromptItem } from '@/app/components/workflow/types' import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react' import { RiCloseLine } from '@remixicon/react' import { noop } from 'es-toolkit/function' -import { Fragment, memo, useCallback, useMemo } from 'react' +import { Fragment, memo, useCallback, useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useStoreApi } from 'reactflow' import { Agent } from '@/app/components/base/icons/src/vender/workflow' import { useNodesSyncDraft } from '@/app/components/workflow/hooks' +import { VarKindType } from '@/app/components/workflow/nodes/_base/types' import { useStore } from '@/app/components/workflow/store' import { EditionType, PromptRole } from '@/app/components/workflow/types' import SubGraphCanvas from './sub-graph-canvas' @@ -38,7 +40,64 @@ const SubGraphModal: FC = ({ const toolNode = useMemo(() => { return workflowNodes.find(node => node.id === toolNodeId) }, [toolNodeId, workflowNodes]) - const toolParamValue = (toolNode?.data as ToolNodeType | undefined)?.tool_parameters?.[paramKey]?.value as string | undefined + const toolParam = (toolNode?.data as ToolNodeType | undefined)?.tool_parameters?.[paramKey] + const toolParamValue = toolParam?.value as string | undefined + + const mentionConfig = useMemo(() => { + const current = toolParam?.mention_config + const rawSelector = Array.isArray(current?.output_selector) ? current!.output_selector : [] + const outputSelector = rawSelector[0] === extractorNodeId ? rawSelector.slice(1) : rawSelector + return { + extractor_node_id: current?.extractor_node_id || extractorNodeId, + output_selector: outputSelector, + null_strategy: current?.null_strategy || 'use_default', + default_value: current?.default_value ?? '', + } + }, [extractorNodeId, toolParam?.mention_config]) + + const handleMentionConfigChange = useCallback((config: MentionConfig) => { + const { getNodes, setNodes } = reactflowStore.getState() + const nextNodes = getNodes().map((node) => { + if (node.id !== toolNodeId) + return node + + const toolData = node.data as ToolNodeType + const currentParam = toolData.tool_parameters?.[paramKey] + if (!currentParam) + return node + + return { + ...node, + data: { + ...toolData, + tool_parameters: { + ...toolData.tool_parameters, + [paramKey]: { + ...currentParam, + type: currentParam.type || VarKindType.mention, + mention_config: config, + }, + }, + }, + } + }) + setNodes(nextNodes) + handleSyncWorkflowDraft(true) + }, [handleSyncWorkflowDraft, paramKey, reactflowStore, toolNodeId]) + + useEffect(() => { + if (!toolParam || (toolParam.type && toolParam.type !== VarKindType.mention)) + return + + const current = toolParam.mention_config + const needsExtractor = !current?.extractor_node_id + const needsNullStrategy = !current?.null_strategy + const needsOutputSelector = !Array.isArray(current?.output_selector) + const needsDefaultValue = current?.default_value === undefined + + if (needsExtractor || needsNullStrategy || needsOutputSelector || needsDefaultValue) + handleMentionConfigChange(mentionConfig) + }, [handleMentionConfigChange, mentionConfig, toolParam]) const getUserPromptText = useCallback((promptTemplate?: PromptItem[] | PromptItem) => { if (!promptTemplate) @@ -147,6 +206,8 @@ const SubGraphModal: FC = ({ sourceVariable={sourceVariable} agentNodeId={agentNodeId} agentName={agentName} + mentionConfig={mentionConfig} + onMentionConfigChange={handleMentionConfigChange} extractorNode={extractorNode} toolParamValue={toolParamValue} onSave={handleSave} diff --git a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/sub-graph-canvas.tsx b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/sub-graph-canvas.tsx index 120015e189..e838ddbceb 100644 --- a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/sub-graph-canvas.tsx +++ b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/sub-graph-canvas.tsx @@ -10,6 +10,8 @@ const SubGraphCanvas: FC = ({ sourceVariable, agentNodeId, agentName, + mentionConfig, + onMentionConfigChange, extractorNode, toolParamValue, onSave, @@ -22,6 +24,8 @@ const SubGraphCanvas: FC = ({ sourceVariable={sourceVariable} agentNodeId={agentNodeId} agentName={agentName} + mentionConfig={mentionConfig} + onMentionConfigChange={onMentionConfigChange} extractorNode={extractorNode} toolParamValue={toolParamValue} onSave={onSave} diff --git a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/types.ts b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/types.ts index 4023b258c7..b4e3dada85 100644 --- a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/types.ts +++ b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/types.ts @@ -1,3 +1,4 @@ +import type { MentionConfig } from '@/app/components/workflow/nodes/_base/types' import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types' import type { Edge as WorkflowEdge, Node as WorkflowNode } from '@/app/components/workflow/types' @@ -19,6 +20,8 @@ export type SubGraphCanvasProps = { sourceVariable: WorkflowValueSelector agentNodeId: string agentName: string + mentionConfig: MentionConfig + onMentionConfigChange: (config: MentionConfig) => void extractorNode?: WorkflowNode toolParamValue?: string onSave?: (nodes: WorkflowNode[], edges: WorkflowEdge[]) => void diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index 686fe3f7cc..ec2e838feb 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -989,7 +989,9 @@ "singleRun.testRunIteration": "Test Run Iteration", "singleRun.testRunLoop": "Test Run Loop", "subGraphModal.canvasPlaceholder": "Click to configure the internal structure", + "subGraphModal.defaultValueHint": "Returns the value below", "subGraphModal.internalStructure": "Internal structure", + "subGraphModal.internalStructureDesc": "Internal structure of @{{name}}", "subGraphModal.lastRun": "LAST RUN", "subGraphModal.noRunHistory": "No run history yet", "subGraphModal.outputVariables": "OUTPUT VARIABLES", @@ -998,7 +1000,9 @@ "subGraphModal.title": "INTERNAL STRUCTURE", "subGraphModal.whenOutputIsNone": "WHEN OUTPUT IS NONE", "subGraphModal.whenOutputNone.default": "Use default value", + "subGraphModal.whenOutputNone.defaultDesc": "Continue with a default value", "subGraphModal.whenOutputNone.error": "Raise an error", + "subGraphModal.whenOutputNone.errorDesc": "Pass the error to the outer workflow", "subGraphModal.whenOutputNone.skip": "Skip this step", "tabs.-": "Default", "tabs.addAll": "Add all", diff --git a/web/i18n/ja-JP/workflow.json b/web/i18n/ja-JP/workflow.json index 1435f672e8..555867256d 100644 --- a/web/i18n/ja-JP/workflow.json +++ b/web/i18n/ja-JP/workflow.json @@ -986,7 +986,9 @@ "singleRun.testRunIteration": "テスト実行(イテレーション)", "singleRun.testRunLoop": "テスト実行ループ", "subGraphModal.canvasPlaceholder": "クリックして内部構造を設定", + "subGraphModal.defaultValueHint": "以下の値を返す", "subGraphModal.internalStructure": "内部構造", + "subGraphModal.internalStructureDesc": "@{{name}} の内部構造", "subGraphModal.lastRun": "前回の実行", "subGraphModal.noRunHistory": "実行履歴がありません", "subGraphModal.outputVariables": "出力変数", @@ -995,7 +997,9 @@ "subGraphModal.title": "内部構造", "subGraphModal.whenOutputIsNone": "出力が空の場合", "subGraphModal.whenOutputNone.default": "デフォルト値を使用", + "subGraphModal.whenOutputNone.defaultDesc": "デフォルト値で続行", "subGraphModal.whenOutputNone.error": "エラーを発生させる", + "subGraphModal.whenOutputNone.errorDesc": "エラーを外部ワークフローに渡す", "subGraphModal.whenOutputNone.skip": "このステップをスキップ", "tabs.-": "デフォルト", "tabs.addAll": "すべてを追加する", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index 2b863487b0..cebe1cede5 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -987,7 +987,9 @@ "singleRun.testRunIteration": "测试运行迭代", "singleRun.testRunLoop": "测试运行循环", "subGraphModal.canvasPlaceholder": "点击配置内部结构", + "subGraphModal.defaultValueHint": "返回以下值", "subGraphModal.internalStructure": "内部结构", + "subGraphModal.internalStructureDesc": "@{{name}} 的内部结构", "subGraphModal.lastRun": "上次运行", "subGraphModal.noRunHistory": "暂无运行记录", "subGraphModal.outputVariables": "输出变量", @@ -996,7 +998,9 @@ "subGraphModal.title": "内部结构", "subGraphModal.whenOutputIsNone": "当输出为空时", "subGraphModal.whenOutputNone.default": "使用默认值", + "subGraphModal.whenOutputNone.defaultDesc": "使用默认值继续执行", "subGraphModal.whenOutputNone.error": "抛出错误", + "subGraphModal.whenOutputNone.errorDesc": "将错误传递给外部工作流", "subGraphModal.whenOutputNone.skip": "跳过此步骤", "tabs.-": "默认", "tabs.addAll": "添加全部", diff --git a/web/i18n/zh-Hant/workflow.json b/web/i18n/zh-Hant/workflow.json index 11e91a7a9d..0d181bb69b 100644 --- a/web/i18n/zh-Hant/workflow.json +++ b/web/i18n/zh-Hant/workflow.json @@ -986,7 +986,9 @@ "singleRun.testRunIteration": "測試運行迭代", "singleRun.testRunLoop": "測試運行循環", "subGraphModal.canvasPlaceholder": "點擊配置內部結構", + "subGraphModal.defaultValueHint": "返回以下值", "subGraphModal.internalStructure": "內部結構", + "subGraphModal.internalStructureDesc": "@{{name}} 的內部結構", "subGraphModal.lastRun": "上次執行", "subGraphModal.noRunHistory": "暫無執行記錄", "subGraphModal.outputVariables": "輸出變數", @@ -995,7 +997,9 @@ "subGraphModal.title": "內部結構", "subGraphModal.whenOutputIsNone": "當輸出為空時", "subGraphModal.whenOutputNone.default": "使用預設值", + "subGraphModal.whenOutputNone.defaultDesc": "使用預設值繼續執行", "subGraphModal.whenOutputNone.error": "拋出錯誤", + "subGraphModal.whenOutputNone.errorDesc": "將錯誤傳遞給外部工作流程", "subGraphModal.whenOutputNone.skip": "跳過此步驟", "tabs.-": "預設", "tabs.addAll": "全部新增", From 495d575ebc5018a110fa17fdd6d8e81c13800173 Mon Sep 17 00:00:00 2001 From: Novice Date: Wed, 14 Jan 2026 14:10:21 +0800 Subject: [PATCH 46/82] feat: add assemble variable builder api --- api/controllers/console/app/generator.py | 102 +++++ api/core/llm_generator/llm_generator.py | 486 ++++++++++++++++++++++- api/core/llm_generator/utils.py | 45 +++ 3 files changed, 631 insertions(+), 2 deletions(-) create mode 100644 api/core/llm_generator/utils.py diff --git a/api/controllers/console/app/generator.py b/api/controllers/console/app/generator.py index b4fc44767a..b13b94f67d 100644 --- a/api/controllers/console/app/generator.py +++ b/api/controllers/console/app/generator.py @@ -55,6 +55,35 @@ class InstructionTemplatePayload(BaseModel): type: str = Field(..., description="Instruction template type") +class ContextGeneratePayload(BaseModel): + """Payload for generating extractor code node.""" + + workflow_id: str = Field(..., description="Workflow ID") + node_id: str = Field(..., description="Current tool/llm node ID") + parameter_name: str = Field(..., description="Parameter name to generate code for") + language: str = Field(default="python3", description="Code language (python3/javascript)") + prompt_messages: list[dict[str, Any]] = Field( + ..., description="Multi-turn conversation history, last message is the current instruction" + ) + model_config_data: dict[str, Any] = Field(..., alias="model_config", description="Model configuration") + + +class SuggestedQuestionsPayload(BaseModel): + """Payload for generating suggested questions.""" + + workflow_id: str = Field(..., description="Workflow ID") + node_id: str = Field(..., description="Current tool/llm node ID") + parameter_name: str = Field(..., description="Parameter name") + language: str = Field( + default="English", description="Language for generated questions (e.g. English, Chinese, Japanese)" + ) + model_config_data: dict[str, Any] | None = Field( + default=None, + alias="model_config", + description="Model configuration (optional, uses system default if not provided)", + ) + + def reg(cls: type[BaseModel]): console_ns.schema_model(cls.__name__, cls.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) @@ -64,6 +93,8 @@ reg(RuleCodeGeneratePayload) reg(RuleStructuredOutputPayload) reg(InstructionGeneratePayload) reg(InstructionTemplatePayload) +reg(ContextGeneratePayload) +reg(SuggestedQuestionsPayload) @console_ns.route("/rule-generate") @@ -278,3 +309,74 @@ class InstructionGenerationTemplateApi(Resource): return {"data": INSTRUCTION_GENERATE_TEMPLATE_CODE} case _: raise ValueError(f"Invalid type: {args.type}") + + +@console_ns.route("/context-generate") +class ContextGenerateApi(Resource): + @console_ns.doc("generate_with_context") + @console_ns.doc(description="Generate with multi-turn conversation context") + @console_ns.expect(console_ns.models[ContextGeneratePayload.__name__]) + @console_ns.response(200, "Content generated successfully") + @console_ns.response(400, "Invalid request parameters or workflow not found") + @console_ns.response(402, "Provider quota exceeded") + @setup_required + @login_required + @account_initialization_required + def post(self): + from core.llm_generator.utils import deserialize_prompt_messages + + args = ContextGeneratePayload.model_validate(console_ns.payload) + _, current_tenant_id = current_account_with_tenant() + + prompt_messages = deserialize_prompt_messages(args.prompt_messages) + + try: + return LLMGenerator.generate_with_context( + tenant_id=current_tenant_id, + workflow_id=args.workflow_id, + node_id=args.node_id, + parameter_name=args.parameter_name, + language=args.language, + prompt_messages=prompt_messages, + model_config=args.model_config_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) + + +@console_ns.route("/context-generate/suggested-questions") +class SuggestedQuestionsApi(Resource): + @console_ns.doc("generate_suggested_questions") + @console_ns.doc(description="Generate suggested questions for context generation") + @console_ns.expect(console_ns.models[SuggestedQuestionsPayload.__name__]) + @console_ns.response(200, "Questions generated successfully") + @setup_required + @login_required + @account_initialization_required + def post(self): + args = SuggestedQuestionsPayload.model_validate(console_ns.payload) + _, current_tenant_id = current_account_with_tenant() + + try: + return LLMGenerator.generate_suggested_questions( + tenant_id=current_tenant_id, + workflow_id=args.workflow_id, + node_id=args.node_id, + parameter_name=args.parameter_name, + language=args.language, + model_config=args.model_config_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) diff --git a/api/core/llm_generator/llm_generator.py b/api/core/llm_generator/llm_generator.py index b4c3ec1caf..346a4c247e 100644 --- a/api/core/llm_generator/llm_generator.py +++ b/api/core/llm_generator/llm_generator.py @@ -1,8 +1,8 @@ import json import logging import re -from collections.abc import Sequence -from typing import Protocol, cast +from collections.abc import Mapping, Sequence +from typing import Any, Protocol, cast import json_repair @@ -398,6 +398,488 @@ class LLMGenerator: logger.exception("Failed to invoke LLM model, model: %s", model_config.get("name")) return {"output": "", "error": f"An unexpected error occurred: {str(e)}"} + @classmethod + def generate_with_context( + cls, + tenant_id: str, + workflow_id: str, + node_id: str, + parameter_name: str, + language: str, + prompt_messages: list[PromptMessage], + model_config: dict, + ) -> dict: + """ + Generate extractor code node based on conversation context. + + Args: + tenant_id: Tenant/workspace ID + workflow_id: Workflow ID + node_id: Current tool/llm node ID + parameter_name: Parameter name to generate code for + language: Code language (python3/javascript) + prompt_messages: Multi-turn conversation history (last message is instruction) + model_config: Model configuration (provider, name, completion_params) + + Returns: + dict with CodeNodeData format: + - variables: Input variable selectors + - code_language: Code language + - code: Generated code + - outputs: Output definitions + - message: Explanation + - error: Error message if any + """ + from sqlalchemy import select + from sqlalchemy.orm import Session + + from services.workflow_service import WorkflowService + + # Get workflow + with Session(db.engine) as session: + stmt = select(App).where(App.id == workflow_id) + app = session.scalar(stmt) + if not app: + return cls._error_response(f"App {workflow_id} not found") + + workflow = WorkflowService().get_draft_workflow(app_model=app) + if not workflow: + return cls._error_response(f"Workflow for app {workflow_id} not found") + + # Get upstream nodes via edge backtracking + upstream_nodes = cls._get_upstream_nodes(workflow.graph_dict, node_id) + + # Get current node info + current_node = cls._get_node_by_id(workflow.graph_dict, node_id) + if not current_node: + return cls._error_response(f"Node {node_id} not found") + + # Get parameter info + parameter_info = cls._get_parameter_info( + tenant_id=tenant_id, + node_data=current_node.get("data", {}), + parameter_name=parameter_name, + ) + + # Build system prompt + system_prompt = cls._build_extractor_system_prompt( + upstream_nodes=upstream_nodes, + current_node=current_node, + parameter_info=parameter_info, + language=language, + ) + + # Construct complete prompt_messages with system prompt + complete_messages: list[PromptMessage] = [ + SystemPromptMessage(content=system_prompt), + *prompt_messages, + ] + + from core.llm_generator.output_parser.structured_output import invoke_llm_with_structured_output + + # Get model instance and schema + provider = model_config.get("provider", "") + model_name = model_config.get("name", "") + model_instance = ModelManager().get_model_instance( + tenant_id=tenant_id, + model_type=ModelType.LLM, + provider=provider, + model=model_name, + ) + + model_schema = model_instance.model_type_instance.get_model_schema(model_name, model_instance.credentials) + if not model_schema: + return cls._error_response(f"Model schema not found for {model_name}") + + model_parameters = model_config.get("completion_params", {}) + json_schema = cls._get_code_node_json_schema() + + try: + response = invoke_llm_with_structured_output( + provider=provider, + model_schema=model_schema, + model_instance=model_instance, + prompt_messages=complete_messages, + json_schema=json_schema, + model_parameters=model_parameters, + stream=False, + tenant_id=tenant_id, + ) + + return cls._parse_code_node_output( + response.structured_output, language, parameter_info.get("type", "string") + ) + + except InvokeError as e: + return cls._error_response(str(e)) + except Exception as e: + logger.exception("Failed to generate with context, model: %s", model_config.get("name")) + return cls._error_response(f"An unexpected error occurred: {str(e)}") + + @classmethod + def _error_response(cls, error: str) -> dict: + """Return error response in CodeNodeData format.""" + return { + "variables": [], + "code_language": "python3", + "code": "", + "outputs": {}, + "message": "", + "error": error, + } + + @classmethod + def generate_suggested_questions( + cls, + tenant_id: str, + workflow_id: str, + node_id: str, + parameter_name: str, + language: str, + model_config: dict | None = None, + ) -> dict: + """ + Generate suggested questions for context generation. + + Returns dict with questions array and error field. + """ + from sqlalchemy import select + from sqlalchemy.orm import Session + + from core.llm_generator.output_parser.structured_output import invoke_llm_with_structured_output + from services.workflow_service import WorkflowService + + # Get workflow context (reuse existing logic) + with Session(db.engine) as session: + stmt = select(App).where(App.id == workflow_id) + app = session.scalar(stmt) + if not app: + return {"questions": [], "error": f"App {workflow_id} not found"} + + workflow = WorkflowService().get_draft_workflow(app_model=app) + if not workflow: + return {"questions": [], "error": f"Workflow for app {workflow_id} not found"} + + upstream_nodes = cls._get_upstream_nodes(workflow.graph_dict, node_id) + current_node = cls._get_node_by_id(workflow.graph_dict, node_id) + if not current_node: + return {"questions": [], "error": f"Node {node_id} not found"} + + parameter_info = cls._get_parameter_info( + tenant_id=tenant_id, + node_data=current_node.get("data", {}), + parameter_name=parameter_name, + ) + + # Build prompt + system_prompt = cls._build_suggested_questions_prompt( + upstream_nodes=upstream_nodes, + current_node=current_node, + parameter_info=parameter_info, + language=language, + ) + + prompt_messages: list[PromptMessage] = [ + SystemPromptMessage(content=system_prompt), + ] + + # Get model instance - use default if model_config not provided + model_manager = ModelManager() + if model_config: + provider = model_config.get("provider", "") + model_name = model_config.get("name", "") + model_instance = model_manager.get_model_instance( + tenant_id=tenant_id, + model_type=ModelType.LLM, + provider=provider, + model=model_name, + ) + else: + model_instance = model_manager.get_default_model_instance( + tenant_id=tenant_id, + model_type=ModelType.LLM, + ) + model_name = model_instance.model + + model_schema = model_instance.model_type_instance.get_model_schema(model_name, model_instance.credentials) + if not model_schema: + return {"questions": [], "error": f"Model schema not found for {model_name}"} + + completion_params = model_config.get("completion_params", {}) if model_config else {} + model_parameters = {**completion_params, "max_tokens": 256} + json_schema = cls._get_suggested_questions_json_schema() + + try: + response = invoke_llm_with_structured_output( + provider=model_instance.provider, + model_schema=model_schema, + model_instance=model_instance, + prompt_messages=prompt_messages, + json_schema=json_schema, + model_parameters=model_parameters, + stream=False, + tenant_id=tenant_id, + ) + + questions = response.structured_output.get("questions", []) if response.structured_output else [] + return {"questions": questions, "error": ""} + + except InvokeError as e: + return {"questions": [], "error": str(e)} + except Exception as e: + logger.exception("Failed to generate suggested questions, model: %s", model_name) + return {"questions": [], "error": f"An unexpected error occurred: {str(e)}"} + + @classmethod + def _build_suggested_questions_prompt( + cls, + upstream_nodes: list[dict], + current_node: dict, + parameter_info: dict, + language: str = "English", + ) -> str: + """Build minimal prompt for suggested questions generation.""" + # Simplify upstream nodes to reduce tokens + sources = [f"{n['title']}({','.join(n.get('outputs', {}).keys())})" for n in upstream_nodes[:5]] + param_type = parameter_info.get("type", "string") + param_desc = parameter_info.get("description", "")[:100] + + return f"""Suggest 3 code generation questions for extracting data. +Sources: {", ".join(sources)} +Target: {parameter_info.get("name")}({param_type}) - {param_desc} +Output 3 short, practical questions in {language}.""" + + @classmethod + def _get_suggested_questions_json_schema(cls) -> dict: + """Return JSON Schema for suggested questions.""" + return { + "type": "object", + "properties": { + "questions": { + "type": "array", + "items": {"type": "string"}, + "minItems": 3, + "maxItems": 3, + "description": "3 suggested questions", + }, + }, + "required": ["questions"], + } + + @classmethod + def _get_code_node_json_schema(cls) -> dict: + """Return JSON Schema for structured output.""" + return { + "type": "object", + "properties": { + "variables": { + "type": "array", + "items": { + "type": "object", + "properties": { + "variable": {"type": "string", "description": "Variable name in code"}, + "value_selector": { + "type": "array", + "items": {"type": "string"}, + "description": "Path like [node_id, output_name]", + }, + }, + "required": ["variable", "value_selector"], + }, + }, + "code": {"type": "string", "description": "Generated code with main function"}, + "outputs": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": {"type": {"type": "string"}}, + }, + "description": "Output definitions, key is output name", + }, + "explanation": {"type": "string", "description": "Brief explanation of the code"}, + }, + "required": ["variables", "code", "outputs", "explanation"], + } + + @classmethod + def _get_upstream_nodes(cls, graph_dict: Mapping[str, Any], node_id: str) -> list[dict]: + """ + Get all upstream nodes via edge backtracking. + + Traverses the graph backwards from node_id to collect all reachable nodes. + """ + from collections import defaultdict + + nodes = {n["id"]: n for n in graph_dict.get("nodes", [])} + edges = graph_dict.get("edges", []) + + # Build reverse adjacency list + reverse_adj: dict[str, list[str]] = defaultdict(list) + for edge in edges: + reverse_adj[edge["target"]].append(edge["source"]) + + # BFS to find all upstream nodes + visited: set[str] = set() + queue = [node_id] + upstream: list[dict] = [] + + while queue: + current = queue.pop(0) + for source in reverse_adj.get(current, []): + if source not in visited: + visited.add(source) + queue.append(source) + if source in nodes: + upstream.append(cls._extract_node_info(nodes[source])) + + return upstream + + @classmethod + def _get_node_by_id(cls, graph_dict: Mapping[str, Any], node_id: str) -> dict | None: + """Get node by ID from graph.""" + for node in graph_dict.get("nodes", []): + if node["id"] == node_id: + return node + return None + + @classmethod + def _extract_node_info(cls, node: dict) -> dict: + """Extract minimal node info with outputs based on node type.""" + node_type = node["data"]["type"] + node_data = node.get("data", {}) + + # Build outputs based on node type (only type, no description to reduce tokens) + outputs: dict[str, str] = {} + match node_type: + case "start": + for var in node_data.get("variables", []): + name = var.get("variable", var.get("name", "")) + outputs[name] = var.get("type", "string") + case "llm": + outputs["text"] = "string" + case "code": + for name, output in node_data.get("outputs", {}).items(): + outputs[name] = output.get("type", "string") + case "http-request": + outputs = {"body": "string", "status_code": "number", "headers": "object"} + case "knowledge-retrieval": + outputs["result"] = "array[object]" + case "tool": + outputs = {"text": "string", "json": "object"} + case _: + outputs["output"] = "string" + + info: dict = { + "id": node["id"], + "title": node_data.get("title", node["id"]), + "outputs": outputs, + } + # Only include description if not empty + desc = node_data.get("desc", "") + if desc: + info["desc"] = desc + + return info + + @classmethod + def _get_parameter_info(cls, tenant_id: str, node_data: dict, parameter_name: str) -> dict: + """Get parameter info from tool schema using ToolManager.""" + default_info = {"name": parameter_name, "type": "string", "description": ""} + + if node_data.get("type") != "tool": + return default_info + + try: + from core.app.entities.app_invoke_entities import InvokeFrom + from core.tools.entities.tool_entities import ToolProviderType + from core.tools.tool_manager import ToolManager + + provider_type_str = node_data.get("provider_type", "") + provider_type = ToolProviderType(provider_type_str) if provider_type_str else ToolProviderType.BUILT_IN + + tool_runtime = ToolManager.get_tool_runtime( + provider_type=provider_type, + provider_id=node_data.get("provider_id", ""), + tool_name=node_data.get("tool_name", ""), + tenant_id=tenant_id, + invoke_from=InvokeFrom.DEBUGGER, + ) + + parameters = tool_runtime.get_merged_runtime_parameters() + for param in parameters: + if param.name == parameter_name: + return { + "name": param.name, + "type": param.type.value if hasattr(param.type, "value") else str(param.type), + "description": param.llm_description + or (param.human_description.en_US if param.human_description else ""), + "required": param.required, + } + except Exception as e: + logger.debug("Failed to get parameter info from ToolManager: %s", e) + + return default_info + + @classmethod + def _build_extractor_system_prompt( + cls, + upstream_nodes: list[dict], + current_node: dict, + parameter_info: dict, + language: str, + ) -> str: + """Build system prompt for extractor code generation.""" + upstream_json = json.dumps(upstream_nodes, indent=2, ensure_ascii=False) + param_type = parameter_info.get("type", "string") + return f"""You are a code generator for workflow automation. + +Generate {language} code to extract/transform upstream node outputs for the target parameter. + +## Upstream Nodes +{upstream_json} + +## Target +Node: {current_node["data"].get("title", current_node["id"])} +Parameter: {parameter_info.get("name")} ({param_type}) - {parameter_info.get("description", "")} + +## Requirements +- Write a main function that returns type: {param_type} +- Use value_selector format: ["node_id", "output_name"] +""" + + @classmethod + def _parse_code_node_output(cls, content: Mapping[str, Any] | None, language: str, parameter_type: str) -> dict: + """ + Parse structured output to CodeNodeData format. + + Args: + content: Structured output dict from invoke_llm_with_structured_output + language: Code language + parameter_type: Expected parameter type + + Returns dict with variables, code_language, code, outputs, message, error. + """ + if content is None: + return cls._error_response("Empty or invalid response from LLM") + + # Validate and normalize variables + variables = [ + {"variable": v.get("variable", ""), "value_selector": v.get("value_selector", [])} + for v in content.get("variables", []) + if isinstance(v, dict) + ] + + outputs = content.get("outputs", {"result": {"type": parameter_type}}) + + return { + "variables": variables, + "code_language": language, + "code": content.get("code", ""), + "outputs": outputs, + "message": content.get("explanation", ""), + "error": "", + } + @staticmethod def instruction_modify_legacy( tenant_id: str, flow_id: str, current: str, instruction: str, model_config: dict, ideal_output: str | None diff --git a/api/core/llm_generator/utils.py b/api/core/llm_generator/utils.py new file mode 100644 index 0000000000..86c9091dd4 --- /dev/null +++ b/api/core/llm_generator/utils.py @@ -0,0 +1,45 @@ +"""Utility functions for LLM generator.""" + +from core.model_runtime.entities.message_entities import ( + AssistantPromptMessage, + PromptMessage, + PromptMessageRole, + SystemPromptMessage, + ToolPromptMessage, + UserPromptMessage, +) + + +def deserialize_prompt_messages(messages: list[dict]) -> list[PromptMessage]: + """ + Deserialize list of dicts to list[PromptMessage]. + + Expected format: + [ + {"role": "user", "content": "..."}, + {"role": "assistant", "content": "..."}, + ] + """ + result: list[PromptMessage] = [] + for msg in messages: + role = PromptMessageRole.value_of(msg["role"]) + content = msg.get("content", "") + + match role: + case PromptMessageRole.USER: + result.append(UserPromptMessage(content=content)) + case PromptMessageRole.ASSISTANT: + result.append(AssistantPromptMessage(content=content)) + case PromptMessageRole.SYSTEM: + result.append(SystemPromptMessage(content=content)) + case PromptMessageRole.TOOL: + result.append(ToolPromptMessage(content=content, tool_call_id=msg.get("tool_call_id", ""))) + + return result + + +def serialize_prompt_messages(messages: list[PromptMessage]) -> list[dict]: + """ + Serialize list[PromptMessage] to list of dicts. + """ + return [{"role": msg.role.value, "content": msg.content} for msg in messages] From c8c048c3a39729bbe75d65ba89bee4d6da099521 Mon Sep 17 00:00:00 2001 From: zhsama Date: Wed, 14 Jan 2026 15:37:29 +0800 Subject: [PATCH 47/82] perf: Optimize sub-graph store selectors and layout --- .../components/sub-graph-children.tsx | 26 ++++--------------- web/app/components/sub-graph/index.tsx | 12 ++++----- .../workflow/store/workflow/index.ts | 3 ++- 3 files changed, 13 insertions(+), 28 deletions(-) diff --git a/web/app/components/sub-graph/components/sub-graph-children.tsx b/web/app/components/sub-graph/components/sub-graph-children.tsx index 618d0c9e20..b17472ff7d 100644 --- a/web/app/components/sub-graph/components/sub-graph-children.tsx +++ b/web/app/components/sub-graph/components/sub-graph-children.tsx @@ -27,29 +27,13 @@ const SubGraphChildren: FC = ({ const isChatMode = useIsChatMode() const nodePanelWidth = useStore(s => s.nodePanelWidth) - const { selectedNode, nodes } = useReactFlowStore(useShallow((s) => { - const nodes = s.getNodes() - const currentNode = nodes.find(node => node.data.selected) - - if (currentNode?.data.type === BlockEnum.LLM) { - return { - selectedNode: { - id: currentNode.id, - type: currentNode.type, - data: currentNode.data, - }, - nodes, - } - } - return { - selectedNode: null, - nodes, - } + const selectedNode = useReactFlowStore(useShallow((s) => { + return s.getNodes().find(node => node.data.selected) })) - const extractorNode = useMemo(() => { - return nodes.find(node => node.data.type === BlockEnum.LLM) - }, [nodes]) + const extractorNode = useReactFlowStore(useShallow((s) => { + return s.getNodes().find(node => node.data.type === BlockEnum.LLM) + })) const availableNodes = useMemo(() => { return extractorNode ? [extractorNode] : [] diff --git a/web/app/components/sub-graph/index.tsx b/web/app/components/sub-graph/index.tsx index 79f13f0161..6a4aef5537 100644 --- a/web/app/components/sub-graph/index.tsx +++ b/web/app/components/sub-graph/index.tsx @@ -12,7 +12,7 @@ import SubGraphMain from './components/sub-graph-main' import { useSubGraphNodes } from './hooks' import { createSubGraphSlice } from './store' -const SUB_GRAPH_EDGE_GAP = 180 +const SUB_GRAPH_EDGE_GAP = 160 const SUB_GRAPH_ENTRY_POSITION = { x: START_INITIAL_POSITION.x, y: 150, @@ -28,7 +28,7 @@ const defaultViewport: Viewport = { zoom: 1.3, } -const SubGraph: FC = (props) => { +const SubGraphContent: FC = (props) => { const { toolNodeId, paramKey, @@ -186,14 +186,14 @@ const SubGraph: FC = (props) => { ) } -const SubGraphWrapper: FC = (props) => { +const SubGraph: FC = (props) => { return ( - + ) } -export default memo(SubGraphWrapper) +export default memo(SubGraph) diff --git a/web/app/components/workflow/store/workflow/index.ts b/web/app/components/workflow/store/workflow/index.ts index c2c0c00201..ff67ff29a5 100644 --- a/web/app/components/workflow/store/workflow/index.ts +++ b/web/app/components/workflow/store/workflow/index.ts @@ -15,6 +15,7 @@ import type { VersionSliceShape } from './version-slice' import type { WorkflowDraftSliceShape } from './workflow-draft-slice' import type { WorkflowSliceShape } from './workflow-slice' import type { RagPipelineSliceShape } from '@/app/components/rag-pipeline/store' +import type { SubGraphSliceShape } from '@/app/components/sub-graph/types' import type { WorkflowSliceShape as WorkflowAppSliceShape } from '@/app/components/workflow-app/store/workflow/workflow-slice' import { useContext } from 'react' import { @@ -30,7 +31,6 @@ import { createHelpLineSlice } from './help-line-slice' import { createHistorySlice } from './history-slice' import { createLayoutSlice } from './layout-slice' import { createNodeSlice } from './node-slice' - import { createPanelSlice } from './panel-slice' import { createToolSlice } from './tool-slice' import { createVersionSlice } from './version-slice' @@ -40,6 +40,7 @@ import { createWorkflowSlice } from './workflow-slice' export type SliceFromInjection = Partial & Partial + & Partial export type Shape = ChatVariableSliceShape From 48283485321ab1ebf9fea9b70386f5ba415778e6 Mon Sep 17 00:00:00 2001 From: zhsama Date: Wed, 14 Jan 2026 17:25:06 +0800 Subject: [PATCH 48/82] feat: Add structured output to sub-graph LLM nodes --- .../sub-graph/hooks/use-sub-graph-init.ts | 21 ++++++++++++++++--- .../mixed-variable-text-input/index.tsx | 21 ++++++++++++++++++- .../tool/components/sub-graph-modal/index.tsx | 6 ++++-- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/web/app/components/sub-graph/hooks/use-sub-graph-init.ts b/web/app/components/sub-graph/hooks/use-sub-graph-init.ts index 68d1e1be20..84ee9abd9a 100644 --- a/web/app/components/sub-graph/hooks/use-sub-graph-init.ts +++ b/web/app/components/sub-graph/hooks/use-sub-graph-init.ts @@ -3,6 +3,7 @@ import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types' import type { StartNodeType } from '@/app/components/workflow/nodes/start/types' import type { Edge, Node, ValueSelector } from '@/app/components/workflow/types' import { useMemo } from 'react' +import { Type } from '@/app/components/workflow/nodes/llm/types' import { BlockEnum, PromptRole } from '@/app/components/workflow/types' import { AppModeEnum } from '@/types/app' @@ -12,6 +13,7 @@ export const SUBGRAPH_LLM_NODE_ID = 'subgraph-llm' export const getSubGraphInitialNodes = ( sourceVariable: ValueSelector, agentName: string, + paramKey: string, ): Node[] => { const sourceVarName = sourceVariable.length > 1 ? sourceVariable.slice(1).join('.') @@ -60,6 +62,19 @@ export const getSubGraphInitialNodes = ( vision: { enabled: false, }, + structured_output_enabled: true, + structured_output: { + schema: { + type: Type.object, + properties: { + [paramKey]: { + type: Type.string, + }, + }, + required: [paramKey], + additionalProperties: false, + }, + }, }, } @@ -84,11 +99,11 @@ export const getSubGraphInitialEdges = (): Edge[] => { } export const useSubGraphInit = (props: SubGraphProps) => { - const { sourceVariable, agentName } = props + const { sourceVariable, agentName, paramKey } = props const initialNodes = useMemo((): Node[] => { - return getSubGraphInitialNodes(sourceVariable, agentName) - }, [sourceVariable, agentName]) + return getSubGraphInitialNodes(sourceVariable, agentName, paramKey) + }, [sourceVariable, agentName, paramKey]) const initialEdges = useMemo((): Edge[] => { return getSubGraphInitialEdges() diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx index 1cc1480f6d..a340739c9c 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx @@ -16,6 +16,7 @@ import { useStoreApi } from 'reactflow' import PromptEditor from '@/app/components/base/prompt-editor' import { useNodesMetaData, useNodesSyncDraft } from '@/app/components/workflow/hooks' import { VarKindType as VarKindTypeEnum } from '@/app/components/workflow/nodes/_base/types' +import { Type } from '@/app/components/workflow/nodes/llm/types' import { useStore } from '@/app/components/workflow/store' import { BlockEnum } from '@/app/components/workflow/types' import { generateNewNode, getNodeCustomTypeByNodeDataType } from '@/app/components/workflow/utils' @@ -163,6 +164,19 @@ const MixedVariableTextInput = ({ title: defaultValue.title, desc: defaultValue.desc || '', parent_node_id: toolNodeId, + structured_output_enabled: true, + structured_output: { + schema: { + type: Type.object, + properties: { + [paramKey]: { + type: Type.string, + }, + }, + required: [paramKey], + additionalProperties: false, + }, + }, }, position: { x: 0, @@ -175,7 +189,12 @@ const MixedVariableTextInput = ({ } } - onChange(newValue, VarKindTypeEnum.mention, DEFAULT_MENTION_CONFIG) + const mentionConfigWithOutputSelector: MentionConfig = { + ...DEFAULT_MENTION_CONFIG, + extractor_node_id: toolNodeId && paramKey ? `${toolNodeId}_ext_${paramKey}` : '', + output_selector: paramKey ? ['structured_output', paramKey] : [], + } + onChange(newValue, VarKindTypeEnum.mention, mentionConfigWithOutputSelector) setControlPromptEditorRerenderKey(Date.now()) }, [handleSyncWorkflowDraft, nodesMetaDataMap, onChange, paramKey, reactFlowStore, setControlPromptEditorRerenderKey, toolNodeId, value]) diff --git a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx index 373b3153a1..d052fba728 100644 --- a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx +++ b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx @@ -47,13 +47,15 @@ const SubGraphModal: FC = ({ const current = toolParam?.mention_config const rawSelector = Array.isArray(current?.output_selector) ? current!.output_selector : [] const outputSelector = rawSelector[0] === extractorNodeId ? rawSelector.slice(1) : rawSelector + const defaultOutputSelector = ['structured_output', paramKey] + return { extractor_node_id: current?.extractor_node_id || extractorNodeId, - output_selector: outputSelector, + output_selector: outputSelector.length > 0 ? outputSelector : defaultOutputSelector, null_strategy: current?.null_strategy || 'use_default', default_value: current?.default_value ?? '', } - }, [extractorNodeId, toolParam?.mention_config]) + }, [extractorNodeId, paramKey, toolParam?.mention_config]) const handleMentionConfigChange = useCallback((config: MentionConfig) => { const { getNodes, setNodes } = reactflowStore.getState() From 810f9eaaad28e44018d836f9e1b3c17b22f5b30b Mon Sep 17 00:00:00 2001 From: zhsama Date: Wed, 14 Jan 2026 23:23:09 +0800 Subject: [PATCH 49/82] feat: Enhance sub-graph components with context handling and variable management --- .../sub-graph/components/config-panel.tsx | 86 +++--- web/app/components/sub-graph/index.tsx | 33 ++- web/app/components/sub-graph/store/index.ts | 5 +- web/app/components/sub-graph/types.ts | 4 + .../variable/var-reference-picker.tsx | 3 + .../variable/var-reference-popup.tsx | 5 +- .../llm/components/config-context-item.tsx | 129 +++++++++ .../nodes/llm/components/config-prompt.tsx | 259 +++++++++++++++--- .../components/workflow/nodes/llm/panel.tsx | 9 +- .../components/workflow/nodes/llm/types.ts | 4 +- .../workflow/nodes/llm/use-config.ts | 4 +- .../tool/components/sub-graph-modal/index.tsx | 43 ++- .../sub-graph-modal/sub-graph-canvas.tsx | 4 + .../tool/components/sub-graph-modal/types.ts | 4 +- web/app/components/workflow/types.ts | 11 + web/i18n/en-US/workflow.json | 7 +- web/i18n/ja-JP/workflow.json | 7 +- web/i18n/zh-Hans/workflow.json | 7 +- web/i18n/zh-Hant/workflow.json | 7 +- 19 files changed, 528 insertions(+), 103 deletions(-) create mode 100644 web/app/components/workflow/nodes/llm/components/config-context-item.tsx diff --git a/web/app/components/sub-graph/components/config-panel.tsx b/web/app/components/sub-graph/components/config-panel.tsx index 0b8674ffe4..edbd746550 100644 --- a/web/app/components/sub-graph/components/config-panel.tsx +++ b/web/app/components/sub-graph/components/config-panel.tsx @@ -67,6 +67,9 @@ const ConfigPanel: FC = ({ description: t('subGraphModal.whenOutputNone.defaultDesc', { ns: 'workflow' }), }, ]), [t]) + const selectedWhenOutputNoneOption = useMemo(() => ( + whenOutputNoneOptions.find(item => item.value === mentionConfig.null_strategy) ?? whenOutputNoneOptions[0] + ), [mentionConfig.null_strategy, whenOutputNoneOptions]) const handleNullStrategyChange = useCallback((item: Item) => { if (typeof item.value !== 'string') @@ -94,6 +97,8 @@ const ConfigPanel: FC = ({ default_value: nextValue, }) }, [mentionConfig, onMentionConfigChange]) + const defaultValue = mentionConfig.default_value ?? '' + const shouldFormatDefaultValue = typeof defaultValue !== 'string' return (
@@ -131,45 +136,54 @@ const ConfigPanel: FC = ({
- - ( -
-
- {selected && ( - - )} -
-
-
{item.name}
-
{item.description}
-
-
- )} - /> -
- {mentionConfig.null_strategy === 'use_default' && ( -
-
- {t('subGraphModal.defaultValueHint', { ns: 'workflow' })} -
-
- + ( +
+
+ {selected && ( + + )} +
+
+
{item.name}
+
{item.description}
+
+
+ )} />
+ )} + > +
+ {selectedWhenOutputNoneOption?.description && ( +
+ {selectedWhenOutputNoneOption.description} +
+ )} + {mentionConfig.null_strategy === 'use_default' && ( +
+ +
+ )}
- )} +
)} diff --git a/web/app/components/sub-graph/index.tsx b/web/app/components/sub-graph/index.tsx index 6a4aef5537..a7d37a880e 100644 --- a/web/app/components/sub-graph/index.tsx +++ b/web/app/components/sub-graph/index.tsx @@ -2,12 +2,13 @@ import type { FC } from 'react' import type { Viewport } from 'reactflow' import type { SubGraphProps } from './types' import type { InjectWorkflowStoreSliceFn } from '@/app/components/workflow/store' -import type { PromptItem } from '@/app/components/workflow/types' -import { memo, useMemo } from 'react' +import type { PromptItem, PromptTemplateItem } from '@/app/components/workflow/types' +import { memo, useEffect, useMemo } from 'react' import WorkflowWithDefaultContext from '@/app/components/workflow' import { NODE_WIDTH_X_OFFSET, START_INITIAL_POSITION } from '@/app/components/workflow/constants' import { WorkflowContextProvider } from '@/app/components/workflow/context' -import { BlockEnum, EditionType, PromptRole } from '@/app/components/workflow/types' +import { useStore } from '@/app/components/workflow/store' +import { BlockEnum, EditionType, isPromptMessageContext, PromptRole } from '@/app/components/workflow/types' import SubGraphMain from './components/sub-graph-main' import { useSubGraphNodes } from './hooks' import { createSubGraphSlice } from './store' @@ -38,9 +39,19 @@ const SubGraphContent: FC = (props) => { onMentionConfigChange, extractorNode, toolParamValue, + parentAvailableNodes, + parentAvailableVars, onSave, } = props + const setParentAvailableVars = useStore(state => state.setParentAvailableVars) + const setParentAvailableNodes = useStore(state => state.setParentAvailableNodes) + + useEffect(() => { + setParentAvailableVars?.(parentAvailableVars || []) + setParentAvailableNodes?.(parentAvailableNodes || []) + }, [parentAvailableNodes, parentAvailableVars, setParentAvailableNodes, setParentAvailableVars]) + const promptText = useMemo(() => { if (!toolParamValue) return '' @@ -95,16 +106,18 @@ const SubGraphContent: FC = (props) => { if (!Array.isArray(template)) return applyPromptText(template as PromptItem) - const userIndex = template.findIndex(item => item.role === PromptRole.user) + const promptItems = template.filter((item): item is PromptItem => !isPromptMessageContext(item)) + + const userIndex = promptItems.findIndex(item => item.role === PromptRole.user) if (userIndex >= 0) { - return template.map((item, index) => { + return promptItems.map((item, index) => { if (index !== userIndex) return item return applyPromptText(item) - }) + }) as PromptTemplateItem[] } - const useJinja = template.some((item: PromptItem) => item.edition_type === EditionType.jinja2) + const useJinja = promptItems.some((item: PromptItem) => item.edition_type === EditionType.jinja2) const defaultUserPrompt: PromptItem = useJinja ? { role: PromptRole.user, @@ -113,13 +126,13 @@ const SubGraphContent: FC = (props) => { edition_type: EditionType.jinja2, } : { role: PromptRole.user, text: promptText } - const systemIndex = template.findIndex(item => item.role === PromptRole.system) - const nextTemplate = [...template] + const systemIndex = promptItems.findIndex(item => item.role === PromptRole.system) + const nextTemplate = [...promptItems] if (systemIndex >= 0) nextTemplate.splice(systemIndex + 1, 0, defaultUserPrompt) else nextTemplate.unshift(defaultUserPrompt) - return nextTemplate + return nextTemplate as PromptTemplateItem[] })() return { diff --git a/web/app/components/sub-graph/store/index.ts b/web/app/components/sub-graph/store/index.ts index 17f6bde319..3b314be72a 100644 --- a/web/app/components/sub-graph/store/index.ts +++ b/web/app/components/sub-graph/store/index.ts @@ -1,6 +1,6 @@ import type { CreateSubGraphSlice, SubGraphSliceShape } from '../types' -const initialState: Omit = { +const initialState: Omit = { parentToolNodeId: '', parameterKey: '', sourceAgentNodeId: '', @@ -18,6 +18,7 @@ const initialState: Omit ({ @@ -46,5 +47,7 @@ export const createSubGraphSlice: CreateSubGraphSlice = set => ({ setParentAvailableVars: vars => set(() => ({ parentAvailableVars: vars })), + setParentAvailableNodes: nodes => set(() => ({ parentAvailableNodes: nodes })), + resetSubGraph: () => set(() => ({ ...initialState })), }) diff --git a/web/app/components/sub-graph/types.ts b/web/app/components/sub-graph/types.ts index 587ed9e451..936ac87cde 100644 --- a/web/app/components/sub-graph/types.ts +++ b/web/app/components/sub-graph/types.ts @@ -31,6 +31,8 @@ export type SubGraphProps = { onMentionConfigChange: (config: MentionConfig) => void extractorNode?: Node toolParamValue?: string + parentAvailableNodes?: Node[] + parentAvailableVars?: NodeOutPutVar[] onSave?: (nodes: Node[], edges: Edge[]) => void } @@ -52,6 +54,7 @@ export type SubGraphSliceShape = { isRunning: boolean parentAvailableVars: NodeOutPutVar[] + parentAvailableNodes: Node[] setSubGraphContext: (context: { parentToolNodeId: string @@ -67,6 +70,7 @@ export type SubGraphSliceShape = { setShowDebugPanel: (show: boolean) => void setIsRunning: (running: boolean) => void setParentAvailableVars: (vars: NodeOutPutVar[]) => void + setParentAvailableNodes: (nodes: Node[]) => void resetSubGraph: () => void } diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx index 6dfcbaf4d8..bca8b79c14 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx @@ -84,6 +84,7 @@ type Props = { currentTool?: Tool currentProvider?: ToolWithProvider | TriggerWithProvider preferSchemaType?: boolean + hideSearch?: boolean } const DEFAULT_VALUE_SELECTOR: Props['value'] = [] @@ -117,6 +118,7 @@ const VarReferencePicker: FC = ({ currentTool, currentProvider, preferSchemaType, + hideSearch, }) => { const { t } = useTranslation() const store = useStoreApi() @@ -636,6 +638,7 @@ const VarReferencePicker: FC = ({ isSupportFileVar={isSupportFileVar} zIndex={zIndex} preferSchemaType={preferSchemaType} + hideSearch={hideSearch} /> )} diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-popup.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-popup.tsx index 6184bcad9f..561016132b 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-popup.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-popup.tsx @@ -15,6 +15,7 @@ type Props = { onChange: (value: ValueSelector, varDetail: Var) => void itemWidth?: number isSupportFileVar?: boolean + hideSearch?: boolean zIndex?: number preferSchemaType?: boolean } @@ -24,6 +25,7 @@ const VarReferencePopup: FC = ({ onChange, itemWidth, isSupportFileVar = true, + hideSearch, zIndex, preferSchemaType, }) => { @@ -35,7 +37,7 @@ const VarReferencePopup: FC = ({ // max-h-[300px] overflow-y-auto todo: use portal to handle long list return (
= ({ showManageInputField={showManageRagInputFields} onManageInputField={() => setShowInputFieldPanel?.(true)} preferSchemaType={preferSchemaType} + hideSearch={hideSearch} /> )}
diff --git a/web/app/components/workflow/nodes/llm/components/config-context-item.tsx b/web/app/components/workflow/nodes/llm/components/config-context-item.tsx new file mode 100644 index 0000000000..77f9b06981 --- /dev/null +++ b/web/app/components/workflow/nodes/llm/components/config-context-item.tsx @@ -0,0 +1,129 @@ +'use client' +import type { FC } from 'react' +import type { PromptMessageContext, ValueSelector } from '../../../types' +import type { Node, NodeOutPutVar, Var } from '@/app/components/workflow/types' +import { RiArrowDownSLine, RiDeleteBinLine } from '@remixicon/react' +import { memo, useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + PortalToFollowElem, + PortalToFollowElemContent, + PortalToFollowElemTrigger, +} from '@/app/components/base/portal-to-follow-elem' +import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars' +import VariableLabelInSelect from '@/app/components/workflow/nodes/_base/components/variable/variable-label/variable-label-in-select' +import { BlockEnum } from '@/app/components/workflow/types' +import { cn } from '@/utils/classnames' + +type Props = { + readOnly: boolean + payload: PromptMessageContext + contextVars: NodeOutPutVar[] + availableNodes: Node[] + onChange: (value: ValueSelector) => void + onRemove: () => void +} + +const ConfigContextItem: FC = ({ + readOnly, + payload, + contextVars, + availableNodes, + onChange, + onRemove, +}) => { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + + const selectedNodeId = Array.isArray(payload.$context) ? payload.$context[0] : '' + const selectedNode = useMemo(() => { + return availableNodes.find(node => node.id === selectedNodeId) + }, [availableNodes, selectedNodeId]) + const hasOptions = contextVars.length > 0 + + const handleChange = useCallback((value: ValueSelector, _item?: Var) => { + onChange(value) + setOpen(false) + }, [onChange]) + + const handleToggle = useCallback(() => { + if (readOnly) + return + setOpen(prev => !prev) + }, [readOnly]) + + const handleRemove = useCallback(() => { + onRemove() + setOpen(false) + }, [onRemove]) + + return ( + + + + + +
+ {hasOptions + ? ( + setOpen(false)} + onBlur={() => setOpen(false)} + autoFocus={false} + preferSchemaType + /> + ) + : ( +
+ {t('common.noAgentNodes', { ns: 'workflow' })} +
+ )} + {!readOnly && ( +
+ +
+ )} +
+
+
+ ) +} + +export default memo(ConfigContextItem) diff --git a/web/app/components/workflow/nodes/llm/components/config-prompt.tsx b/web/app/components/workflow/nodes/llm/components/config-prompt.tsx index 5b28c9b48f..147e69b6e1 100644 --- a/web/app/components/workflow/nodes/llm/components/config-prompt.tsx +++ b/web/app/components/workflow/nodes/llm/components/config-prompt.tsx @@ -1,19 +1,28 @@ 'use client' import type { FC } from 'react' -import type { ModelConfig, PromptItem, ValueSelector, Var, Variable } from '../../../types' +import type { ModelConfig, Node, NodeOutPutVar, PromptItem, PromptMessageContext, PromptTemplateItem, ValueSelector, Var, Variable } from '../../../types' import { produce } from 'immer' import * as React from 'react' -import { useCallback } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { ReactSortable } from 'react-sortablejs' +import { useStoreApi } from 'reactflow' import { v4 as uuid4 } from 'uuid' import { DragHandle } from '@/app/components/base/icons/src/vender/line/others' +import { + PortalToFollowElem, + PortalToFollowElemContent, + PortalToFollowElemTrigger, +} from '@/app/components/base/portal-to-follow-elem' import AddButton from '@/app/components/workflow/nodes/_base/components/add-button' import Editor from '@/app/components/workflow/nodes/_base/components/prompt/editor' +import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars' import { cn } from '@/utils/classnames' -import { useWorkflowStore } from '../../../store' -import { EditionType, PromptRole } from '../../../types' +import { useWorkflow } from '../../../hooks' +import { useStore, useWorkflowStore } from '../../../store' +import { BlockEnum, EditionType, isPromptMessageContext, PromptRole, VarType } from '../../../types' import useAvailableVarList from '../../_base/hooks/use-available-var-list' +import ConfigContextItem from './config-context-item' import ConfigPromptItem from './config-prompt-item' const i18nPrefix = 'nodes.llm' @@ -24,8 +33,8 @@ type Props = { filterVar: (payload: Var, selector: ValueSelector) => boolean isChatModel: boolean isChatApp: boolean - payload: PromptItem | PromptItem[] - onChange: (payload: PromptItem | PromptItem[]) => void + payload: PromptItem | PromptTemplateItem[] + onChange: (payload: PromptItem | PromptTemplateItem[]) => void isShowContext: boolean hasSetBlockStatus: { context: boolean @@ -56,6 +65,13 @@ const ConfigPrompt: FC = ({ const { setControlPromptEditorRerenderKey, } = workflowStore.getState() + + const store = useStoreApi() + const { getBeforeNodesInSameBranch } = useWorkflow() + + const [isContextMenuOpen, setIsContextMenuOpen] = useState(false) + const contextMenuTriggerRef = useRef(null) + const payloadWithIds = (isChatModel && Array.isArray(payload)) ? payload.map((item) => { const id = uuid4() @@ -75,11 +91,78 @@ const ConfigPrompt: FC = ({ onlyLeafNodeVar: false, filterVar, }) + const parentAvailableVars = useStore(state => state.parentAvailableVars) || [] + const parentAvailableNodes = useStore(state => state.parentAvailableNodes) || [] + + const mergedAvailableVars = useMemo(() => { + if (!parentAvailableVars.length) + return availableVars + const merged = new Map() + availableVars.forEach((item) => { + merged.set(item.nodeId, item) + }) + parentAvailableVars.forEach((item) => { + if (!merged.has(item.nodeId)) + merged.set(item.nodeId, item) + }) + return Array.from(merged.values()) + }, [availableVars, parentAvailableVars]) + + const mergedAvailableNodesWithParent = useMemo(() => { + if (!parentAvailableNodes.length) + return availableNodesWithParent + const merged = new Map() + availableNodesWithParent.forEach((node) => { + merged.set(node.id, node) + }) + parentAvailableNodes.forEach((node) => { + if (!merged.has(node.id)) + merged.set(node.id, node) + }) + return Array.from(merged.values()) + }, [availableNodesWithParent, parentAvailableNodes]) + + const contextAgentNodes = useMemo(() => { + const agentNodes = mergedAvailableNodesWithParent + .filter(node => node.data.type === BlockEnum.Agent) + + const { getNodes } = store.getState() + const allNodes = getNodes() + const currentNode = allNodes.find(n => n.id === nodeId) + const parentNodeId = currentNode?.parentId + + if (parentNodeId) { + const beforeNodes = getBeforeNodesInSameBranch(parentNodeId) + const parentAgentNodes = beforeNodes + .filter(node => node.data.type === BlockEnum.Agent) + .filter(node => !agentNodes.some(n => n.id === node.id)) + + agentNodes.unshift(...parentAgentNodes) + } + + return agentNodes + }, [mergedAvailableNodesWithParent, nodeId, store, getBeforeNodesInSameBranch]) + + const contextVarOptions = useMemo(() => { + return contextAgentNodes.map(node => ({ + nodeId: node.id, + title: node.data.title, + vars: [ + { + variable: 'context', + type: VarType.arrayObject, + schemaType: 'List[promptMessage]', + }, + ], + })) + }, [contextAgentNodes]) const handleChatModePromptChange = useCallback((index: number) => { return (prompt: string) => { - const newPrompt = produce(payload as PromptItem[], (draft) => { - draft[index][draft[index].edition_type === EditionType.jinja2 ? 'jinja2_text' : 'text'] = prompt + const newPrompt = produce(payload as PromptTemplateItem[], (draft) => { + const item = draft[index] + if (!isPromptMessageContext(item)) + item[item.edition_type === EditionType.jinja2 ? 'jinja2_text' : 'text'] = prompt }) onChange(newPrompt) } @@ -87,8 +170,10 @@ const ConfigPrompt: FC = ({ const handleChatModeEditionTypeChange = useCallback((index: number) => { return (editionType: EditionType) => { - const newPrompt = produce(payload as PromptItem[], (draft) => { - draft[index].edition_type = editionType + const newPrompt = produce(payload as PromptTemplateItem[], (draft) => { + const item = draft[index] + if (!isPromptMessageContext(item)) + item.edition_type = editionType }) onChange(newPrompt) } @@ -96,29 +181,80 @@ const ConfigPrompt: FC = ({ const handleChatModeMessageRoleChange = useCallback((index: number) => { return (role: PromptRole) => { - const newPrompt = produce(payload as PromptItem[], (draft) => { - draft[index].role = role + const newPrompt = produce(payload as PromptTemplateItem[], (draft) => { + const item = draft[index] + if (!isPromptMessageContext(item)) + item.role = role }) onChange(newPrompt) } }, [onChange, payload]) const handleAddPrompt = useCallback(() => { - const newPrompt = produce(payload as PromptItem[], (draft) => { + const newPrompt = produce(payload as PromptTemplateItem[], (draft) => { if (draft.length === 0) { draft.push({ role: PromptRole.system, text: '', id: uuid4() }) - return } - const isLastItemUser = draft[draft.length - 1].role === PromptRole.user + const lastPromptItem = [...draft].reverse().find(item => !isPromptMessageContext(item)) as PromptItem | undefined + const isLastItemUser = lastPromptItem?.role === PromptRole.user draft.push({ role: isLastItemUser ? PromptRole.assistant : PromptRole.user, text: '', id: uuid4() }) }) onChange(newPrompt) }, [onChange, payload]) + const handleAddContext = useCallback((agentNodeId: string) => { + const newPrompt = produce(payload as PromptTemplateItem[], (draft) => { + const contextItem: PromptMessageContext = { + id: uuid4(), + $context: [agentNodeId, 'context'], + } + + const lastUserIndex = draft + .map((item, idx) => ({ item, idx })) + .reverse() + .find(({ item }) => !isPromptMessageContext(item) && (item as PromptItem).role === PromptRole.user) + ?.idx + + if (lastUserIndex !== undefined) { + draft.splice(lastUserIndex, 0, contextItem) + return + } + + const promptItems = draft.filter(item => !isPromptMessageContext(item)) as PromptItem[] + const hasOnlySystem = promptItems.length === 1 && promptItems[0].role === PromptRole.system + if (hasOnlySystem) { + draft.push({ role: PromptRole.user, text: '', id: uuid4() }) + draft.splice(draft.length - 1, 0, contextItem) + return + } + + draft.push(contextItem) + }) + onChange(newPrompt) + setIsContextMenuOpen(false) + }, [onChange, payload]) + + const handleAddContextVar = useCallback((value: ValueSelector, _item?: Var) => { + if (!Array.isArray(value) || value.length < 2) + return + handleAddContext(value[0]) + }, [handleAddContext]) + + const handleContextChange = useCallback((index: number) => { + return (value: ValueSelector) => { + const newPrompt = produce(payload as PromptTemplateItem[], (draft) => { + const item = draft[index] + if (isPromptMessageContext(item)) + item.$context = value + }) + onChange(newPrompt) + } + }, [onChange, payload]) + const handleRemove = useCallback((index: number) => { return () => { - const newPrompt = produce(payload as PromptItem[], (draft) => { + const newPrompt = produce(payload as PromptTemplateItem[], (draft) => { draft.splice(index, 1) }) onChange(newPrompt) @@ -145,11 +281,12 @@ const ConfigPrompt: FC = ({ }, [onChange, payload]) const canChooseSystemRole = (() => { - if (isChatModel && Array.isArray(payload)) - return !payload.find(item => item.role === PromptRole.system) - + if (isChatModel && Array.isArray(payload)) { + return !payload.find(item => !isPromptMessageContext(item) && (item as PromptItem).role === PromptRole.system) + } return false })() + return (
{(isChatModel && Array.isArray(payload)) @@ -160,9 +297,12 @@ const ConfigPrompt: FC = ({ className="space-y-1" list={payloadWithIds} setList={(list) => { - if ((payload as PromptItem[])?.[0]?.role === PromptRole.system && list[0].p?.role !== PromptRole.system) - return - + const firstItem = (payload as PromptTemplateItem[])?.[0] + if (firstItem && !isPromptMessageContext(firstItem) && firstItem.role === PromptRole.system) { + const newFirstItem = list[0]?.p + if (newFirstItem && !isPromptMessageContext(newFirstItem) && newFirstItem.role !== PromptRole.system) + return + } onChange(list.map(item => item.p)) }} handle=".handle" @@ -170,7 +310,23 @@ const ConfigPrompt: FC = ({ animation={150} > { - (payload as PromptItem[]).map((item, index) => { + (payload as PromptTemplateItem[]).map((item, index) => { + if (isPromptMessageContext(item)) { + return ( +
+ {!readOnly && } + +
+ ) + } + const canDrag = (() => { if (readOnly) return false @@ -182,7 +338,7 @@ const ConfigPrompt: FC = ({ })() return (
- {canDrag && } + {canDrag && } = ({ onRemove={handleRemove(index)} isShowContext={isShowContext} hasSetBlockStatus={hasSetBlockStatus} - availableVars={availableVars} - availableNodes={availableNodesWithParent} + availableVars={mergedAvailableVars} + availableNodes={mergedAvailableNodesWithParent} varList={varList} handleAddVariable={handleAddVariable} modelConfig={modelConfig} @@ -213,11 +369,48 @@ const ConfigPrompt: FC = ({ }
- +
+ + + setIsContextMenuOpen(!isContextMenuOpen)}> +
+ {}} + /> +
+
+ +
+ {contextVarOptions.length > 0 + ? ( + setIsContextMenuOpen(false)} + onBlur={() => setIsContextMenuOpen(false)} + autoFocus={false} + preferSchemaType + /> + ) + : ( +
+ {t('common.noAgentNodes', { ns: 'workflow' })} +
+ )} +
+
+
+
) : ( @@ -232,8 +425,8 @@ const ConfigPrompt: FC = ({ isChatApp={isChatApp} isShowContext={isShowContext} hasSetBlockStatus={hasSetBlockStatus} - nodesOutputVars={availableVars} - availableNodes={availableNodesWithParent} + nodesOutputVars={mergedAvailableVars} + availableNodes={mergedAvailableNodesWithParent} isSupportPromptGenerator isSupportJinja editionType={(payload as PromptItem).edition_type} diff --git a/web/app/components/workflow/nodes/llm/panel.tsx b/web/app/components/workflow/nodes/llm/panel.tsx index 670d3149be..553f7ca599 100644 --- a/web/app/components/workflow/nodes/llm/panel.tsx +++ b/web/app/components/workflow/nodes/llm/panel.tsx @@ -5,6 +5,7 @@ import { RiAlertFill, RiQuestionLine } from '@remixicon/react' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' +import Badge from '@/app/components/base/badge' import AddButton2 from '@/app/components/base/button/add-button' import Switch from '@/app/components/base/switch' import Toast from '@/app/components/base/toast' @@ -119,7 +120,12 @@ const Panel: FC> = ({ {/* knowledge */} +
{t(`${i18nPrefix}.context`, { ns: 'workflow' })}
+ LEGACY +
+ )} tooltip={t(`${i18nPrefix}.contextTooltip`, { ns: 'workflow' })!} > <> @@ -130,6 +136,7 @@ const Panel: FC> = ({ value={inputs.context?.variable_selector || []} onChange={handleContextVarChange} filterVar={filterVar} + hideSearch /> {shouldShowContextTip && (
{t(`${i18nPrefix}.notSetContextInPromptTip`, { ns: 'workflow' })}
diff --git a/web/app/components/workflow/nodes/llm/types.ts b/web/app/components/workflow/nodes/llm/types.ts index 70dc4d9cc7..5b15f83ac6 100644 --- a/web/app/components/workflow/nodes/llm/types.ts +++ b/web/app/components/workflow/nodes/llm/types.ts @@ -1,8 +1,8 @@ -import type { CommonNodeType, Memory, ModelConfig, PromptItem, ValueSelector, Variable, VisionSetting } from '@/app/components/workflow/types' +import type { CommonNodeType, Memory, ModelConfig, PromptItem, PromptTemplateItem, ValueSelector, Variable, VisionSetting } from '@/app/components/workflow/types' export type LLMNodeType = CommonNodeType & { model: ModelConfig - prompt_template: PromptItem[] | PromptItem + prompt_template: PromptTemplateItem[] | PromptItem prompt_config?: { jinja2_variables?: Variable[] } diff --git a/web/app/components/workflow/nodes/llm/use-config.ts b/web/app/components/workflow/nodes/llm/use-config.ts index e885f108bb..6922a8989f 100644 --- a/web/app/components/workflow/nodes/llm/use-config.ts +++ b/web/app/components/workflow/nodes/llm/use-config.ts @@ -1,4 +1,4 @@ -import type { Memory, PromptItem, ValueSelector, Var, Variable } from '../../types' +import type { Memory, PromptItem, PromptTemplateItem, ValueSelector, Var, Variable } from '../../types' import type { LLMNodeType, StructuredOutput } from './types' import { produce } from 'immer' import { useCallback, useEffect, useRef, useState } from 'react' @@ -249,7 +249,7 @@ const useConfig = (id: string, payload: LLMNodeType) => { setInputs(newInputs) }, [setInputs]) - const handlePromptChange = useCallback((newPrompt: PromptItem[] | PromptItem) => { + const handlePromptChange = useCallback((newPrompt: PromptTemplateItem[] | PromptItem) => { const newInputs = produce(inputRef.current, (draft) => { draft.prompt_template = newPrompt }) diff --git a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx index d052fba728..fc793db9e4 100644 --- a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx +++ b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx @@ -10,12 +10,12 @@ import { RiCloseLine } from '@remixicon/react' import { noop } from 'es-toolkit/function' import { Fragment, memo, useCallback, useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { useStoreApi } from 'reactflow' +import { useStore as useReactFlowStore, useStoreApi } from 'reactflow' import { Agent } from '@/app/components/base/icons/src/vender/workflow' -import { useNodesSyncDraft } from '@/app/components/workflow/hooks' +import { useIsChatMode, useNodesSyncDraft, useWorkflow, useWorkflowVariables } from '@/app/components/workflow/hooks' import { VarKindType } from '@/app/components/workflow/nodes/_base/types' -import { useStore } from '@/app/components/workflow/store' -import { EditionType, PromptRole } from '@/app/components/workflow/types' +import { useStore as useWorkflowStore } from '@/app/components/workflow/store' +import { BlockEnum, EditionType, PromptRole } from '@/app/components/workflow/types' import SubGraphCanvas from './sub-graph-canvas' const SubGraphModal: FC = ({ @@ -29,9 +29,13 @@ const SubGraphModal: FC = ({ }) => { const { t } = useTranslation() const reactflowStore = useStoreApi() - const workflowNodes = useStore(state => state.nodes) - const setControlPromptEditorRerenderKey = useStore(state => state.setControlPromptEditorRerenderKey) + const workflowNodes = useWorkflowStore(state => state.nodes) + const workflowEdges = useReactFlowStore(state => state.edges) + const setControlPromptEditorRerenderKey = useWorkflowStore(state => state.setControlPromptEditorRerenderKey) const { handleSyncWorkflowDraft } = useNodesSyncDraft() + const { getBeforeNodesInSameBranch } = useWorkflow() + const { getNodeAvailableVars } = useWorkflowVariables() + const isChatMode = useIsChatMode() const extractorNodeId = `${toolNodeId}_ext_${paramKey}` const extractorNode = useMemo(() => { @@ -43,6 +47,28 @@ const SubGraphModal: FC = ({ const toolParam = (toolNode?.data as ToolNodeType | undefined)?.tool_parameters?.[paramKey] const toolParamValue = toolParam?.value as string | undefined + const parentAgentNodes = useMemo(() => { + if (!isOpen) + return [] + const beforeNodes = getBeforeNodesInSameBranch(toolNodeId, workflowNodes, workflowEdges) + return beforeNodes.filter(node => node.data.type === BlockEnum.Agent) + }, [getBeforeNodesInSameBranch, isOpen, toolNodeId, workflowEdges, workflowNodes]) + + const parentAgentNodeIds = useMemo(() => { + return parentAgentNodes.map(node => node.id) + }, [parentAgentNodes]) + + const parentAvailableVars = useMemo(() => { + if (!parentAgentNodeIds.length) + return [] + const vars = getNodeAvailableVars({ + beforeNodes: parentAgentNodes, + isChatMode, + filterVar: () => true, + }) + return vars.filter(nodeVar => parentAgentNodeIds.includes(nodeVar.nodeId)) + }, [getNodeAvailableVars, isChatMode, parentAgentNodeIds, parentAgentNodes]) + const mentionConfig = useMemo(() => { const current = toolParam?.mention_config const rawSelector = Array.isArray(current?.output_selector) ? current!.output_selector : [] @@ -115,8 +141,7 @@ const SubGraphModal: FC = ({ const userPrompt = promptTemplate.find(item => item.role === PromptRole.user) if (userPrompt) return resolveText(userPrompt) - const systemPrompt = promptTemplate.find(item => item.role === PromptRole.system) - return resolveText(systemPrompt) + return '' } return resolveText(promptTemplate) }, []) @@ -212,6 +237,8 @@ const SubGraphModal: FC = ({ onMentionConfigChange={handleMentionConfigChange} extractorNode={extractorNode} toolParamValue={toolParamValue} + parentAvailableNodes={parentAgentNodes} + parentAvailableVars={parentAvailableVars} onSave={handleSave} />
diff --git a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/sub-graph-canvas.tsx b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/sub-graph-canvas.tsx index e838ddbceb..04a19c88fc 100644 --- a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/sub-graph-canvas.tsx +++ b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/sub-graph-canvas.tsx @@ -14,6 +14,8 @@ const SubGraphCanvas: FC = ({ onMentionConfigChange, extractorNode, toolParamValue, + parentAvailableNodes, + parentAvailableVars, onSave, }) => { return ( @@ -28,6 +30,8 @@ const SubGraphCanvas: FC = ({ onMentionConfigChange={onMentionConfigChange} extractorNode={extractorNode} toolParamValue={toolParamValue} + parentAvailableNodes={parentAvailableNodes} + parentAvailableVars={parentAvailableVars} onSave={onSave} />
diff --git a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/types.ts b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/types.ts index b4e3dada85..ae9e227458 100644 --- a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/types.ts +++ b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/types.ts @@ -1,6 +1,6 @@ import type { MentionConfig } from '@/app/components/workflow/nodes/_base/types' import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types' -import type { Edge as WorkflowEdge, Node as WorkflowNode } from '@/app/components/workflow/types' +import type { NodeOutPutVar, Edge as WorkflowEdge, Node as WorkflowNode } from '@/app/components/workflow/types' type WorkflowValueSelector = string[] @@ -24,5 +24,7 @@ export type SubGraphCanvasProps = { onMentionConfigChange: (config: MentionConfig) => void extractorNode?: WorkflowNode toolParamValue?: string + parentAvailableNodes?: WorkflowNode[] + parentAvailableVars?: NodeOutPutVar[] onSave?: (nodes: WorkflowNode[], edges: WorkflowEdge[]) => void } diff --git a/web/app/components/workflow/types.ts b/web/app/components/workflow/types.ts index 02ee45aa7a..b4bd68b1ee 100644 --- a/web/app/components/workflow/types.ts +++ b/web/app/components/workflow/types.ts @@ -255,6 +255,17 @@ export type PromptItem = { jinja2_text?: string } +export type PromptMessageContext = { + id?: string + $context: ValueSelector +} + +export type PromptTemplateItem = PromptItem | PromptMessageContext + +export const isPromptMessageContext = (item: PromptTemplateItem): item is PromptMessageContext => { + return '$context' in item +} + export enum MemoryRole { user = 'user', assistant = 'assistant', diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index ec2e838feb..315179cd9c 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -625,8 +625,10 @@ "nodes.listFilter.outputVars.last_record": "Last record", "nodes.listFilter.outputVars.result": "Filter result", "nodes.listFilter.selectVariableKeyPlaceholder": "Select sub variable key", + "nodes.llm.addContext": "Add Context", "nodes.llm.addMessage": "Add Message", "nodes.llm.context": "context", + "nodes.llm.contextBlock": "Context Block", "nodes.llm.contextTooltip": "You can import Knowledge as context", "nodes.llm.files": "Files", "nodes.llm.jsonSchema.addChildField": "Add Child Field", @@ -663,6 +665,7 @@ "nodes.llm.reasoningFormat.tagged": "Keep think tags", "nodes.llm.reasoningFormat.title": "Enable reasoning tag separation", "nodes.llm.reasoningFormat.tooltip": "Extract content from think tags and store it in the reasoning_content field.", + "nodes.llm.removeContext": "Remove context", "nodes.llm.resolution.high": "High", "nodes.llm.resolution.low": "Low", "nodes.llm.resolution.name": "Resolution", @@ -999,8 +1002,8 @@ "subGraphModal.sourceNode": "SOURCE", "subGraphModal.title": "INTERNAL STRUCTURE", "subGraphModal.whenOutputIsNone": "WHEN OUTPUT IS NONE", - "subGraphModal.whenOutputNone.default": "Use default value", - "subGraphModal.whenOutputNone.defaultDesc": "Continue with a default value", + "subGraphModal.whenOutputNone.default": "Default value", + "subGraphModal.whenOutputNone.defaultDesc": "Returns the value below", "subGraphModal.whenOutputNone.error": "Raise an error", "subGraphModal.whenOutputNone.errorDesc": "Pass the error to the outer workflow", "subGraphModal.whenOutputNone.skip": "Skip this step", diff --git a/web/i18n/ja-JP/workflow.json b/web/i18n/ja-JP/workflow.json index 555867256d..f74a865eaf 100644 --- a/web/i18n/ja-JP/workflow.json +++ b/web/i18n/ja-JP/workflow.json @@ -623,8 +623,10 @@ "nodes.listFilter.outputVars.last_record": "最後のレコード", "nodes.listFilter.outputVars.result": "フィルター結果", "nodes.listFilter.selectVariableKeyPlaceholder": "サブ変数キーを選択する", + "nodes.llm.addContext": "コンテキスト追加", "nodes.llm.addMessage": "メッセージ追加", "nodes.llm.context": "コンテキスト", + "nodes.llm.contextBlock": "コンテキストブロック", "nodes.llm.contextTooltip": "ナレッジベースをコンテキストとして利用", "nodes.llm.files": "ファイル", "nodes.llm.jsonSchema.addChildField": "サブフィールドを追加", @@ -661,6 +663,7 @@ "nodes.llm.reasoningFormat.tagged": "タグを考え続けてください", "nodes.llm.reasoningFormat.title": "推論タグの分離を有効にする", "nodes.llm.reasoningFormat.tooltip": "thinkタグから内容を抽出し、それをreasoning_contentフィールドに保存します。", + "nodes.llm.removeContext": "コンテキストを削除", "nodes.llm.resolution.high": "高", "nodes.llm.resolution.low": "低", "nodes.llm.resolution.name": "解像度", @@ -996,8 +999,8 @@ "subGraphModal.sourceNode": "ソース", "subGraphModal.title": "内部構造", "subGraphModal.whenOutputIsNone": "出力が空の場合", - "subGraphModal.whenOutputNone.default": "デフォルト値を使用", - "subGraphModal.whenOutputNone.defaultDesc": "デフォルト値で続行", + "subGraphModal.whenOutputNone.default": "デフォルト値", + "subGraphModal.whenOutputNone.defaultDesc": "以下の値を返す", "subGraphModal.whenOutputNone.error": "エラーを発生させる", "subGraphModal.whenOutputNone.errorDesc": "エラーを外部ワークフローに渡す", "subGraphModal.whenOutputNone.skip": "このステップをスキップ", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index cebe1cede5..7eb9f556dc 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -623,8 +623,10 @@ "nodes.listFilter.outputVars.last_record": "最后一条记录", "nodes.listFilter.outputVars.result": "过滤结果", "nodes.listFilter.selectVariableKeyPlaceholder": "选择子变量的 Key", + "nodes.llm.addContext": "添加上下文", "nodes.llm.addMessage": "添加消息", "nodes.llm.context": "上下文", + "nodes.llm.contextBlock": "上下文块", "nodes.llm.contextTooltip": "您可以导入知识库作为上下文", "nodes.llm.files": "文件", "nodes.llm.jsonSchema.addChildField": "添加子字段", @@ -661,6 +663,7 @@ "nodes.llm.reasoningFormat.tagged": "保持思考标签", "nodes.llm.reasoningFormat.title": "启用推理标签分离", "nodes.llm.reasoningFormat.tooltip": "从think标签中提取内容,并将其存储在reasoning_content字段中。", + "nodes.llm.removeContext": "删除上下文", "nodes.llm.resolution.high": "高", "nodes.llm.resolution.low": "低", "nodes.llm.resolution.name": "分辨率", @@ -997,8 +1000,8 @@ "subGraphModal.sourceNode": "来源", "subGraphModal.title": "内部结构", "subGraphModal.whenOutputIsNone": "当输出为空时", - "subGraphModal.whenOutputNone.default": "使用默认值", - "subGraphModal.whenOutputNone.defaultDesc": "使用默认值继续执行", + "subGraphModal.whenOutputNone.default": "默认值", + "subGraphModal.whenOutputNone.defaultDesc": "返回以下值", "subGraphModal.whenOutputNone.error": "抛出错误", "subGraphModal.whenOutputNone.errorDesc": "将错误传递给外部工作流", "subGraphModal.whenOutputNone.skip": "跳过此步骤", diff --git a/web/i18n/zh-Hant/workflow.json b/web/i18n/zh-Hant/workflow.json index 0d181bb69b..cebfdd2feb 100644 --- a/web/i18n/zh-Hant/workflow.json +++ b/web/i18n/zh-Hant/workflow.json @@ -623,8 +623,10 @@ "nodes.listFilter.outputVars.last_record": "最後一條記錄", "nodes.listFilter.outputVars.result": "篩選結果", "nodes.listFilter.selectVariableKeyPlaceholder": "Select sub variable key(選擇子變數鍵)", + "nodes.llm.addContext": "新增上下文", "nodes.llm.addMessage": "新增消息", "nodes.llm.context": "上下文", + "nodes.llm.contextBlock": "上下文區塊", "nodes.llm.contextTooltip": "您可以導入知識庫作為上下文", "nodes.llm.files": "文件", "nodes.llm.jsonSchema.addChildField": "新增子欄位", @@ -661,6 +663,7 @@ "nodes.llm.reasoningFormat.tagged": "保持思考標籤", "nodes.llm.reasoningFormat.title": "啟用推理標籤分離", "nodes.llm.reasoningFormat.tooltip": "從 think 標籤中提取內容並將其存儲在 reasoning_content 欄位中。", + "nodes.llm.removeContext": "刪除上下文", "nodes.llm.resolution.high": "高", "nodes.llm.resolution.low": "低", "nodes.llm.resolution.name": "分辨率", @@ -996,8 +999,8 @@ "subGraphModal.sourceNode": "來源", "subGraphModal.title": "內部結構", "subGraphModal.whenOutputIsNone": "當輸出為空時", - "subGraphModal.whenOutputNone.default": "使用預設值", - "subGraphModal.whenOutputNone.defaultDesc": "使用預設值繼續執行", + "subGraphModal.whenOutputNone.default": "預設值", + "subGraphModal.whenOutputNone.defaultDesc": "返回以下值", "subGraphModal.whenOutputNone.error": "拋出錯誤", "subGraphModal.whenOutputNone.errorDesc": "將錯誤傳遞給外部工作流程", "subGraphModal.whenOutputNone.skip": "跳過此步驟", From 56e537786f522c37489edb7d4f2d24eea11b9967 Mon Sep 17 00:00:00 2001 From: zhsama Date: Wed, 14 Jan 2026 23:30:12 +0800 Subject: [PATCH 50/82] feat: Update LLM context selector styling --- .../nodes/llm/components/config-context-item.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/web/app/components/workflow/nodes/llm/components/config-context-item.tsx b/web/app/components/workflow/nodes/llm/components/config-context-item.tsx index 77f9b06981..6960b27b54 100644 --- a/web/app/components/workflow/nodes/llm/components/config-context-item.tsx +++ b/web/app/components/workflow/nodes/llm/components/config-context-item.tsx @@ -63,17 +63,18 @@ const ConfigContextItem: FC = ({ onOpenChange={setOpen} placement="bottom-start" offset={6} + triggerPopupSameWidth > -
+
{hasOptions ? ( Date: Thu, 15 Jan 2026 01:14:46 +0800 Subject: [PATCH 51/82] refactor: refactor prompt template processing logic --- web/app/components/sub-graph/index.tsx | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/web/app/components/sub-graph/index.tsx b/web/app/components/sub-graph/index.tsx index a7d37a880e..51314dca04 100644 --- a/web/app/components/sub-graph/index.tsx +++ b/web/app/components/sub-graph/index.tsx @@ -106,18 +106,20 @@ const SubGraphContent: FC = (props) => { if (!Array.isArray(template)) return applyPromptText(template as PromptItem) - const promptItems = template.filter((item): item is PromptItem => !isPromptMessageContext(item)) - - const userIndex = promptItems.findIndex(item => item.role === PromptRole.user) + const userIndex = template.findIndex( + item => !isPromptMessageContext(item) && (item as PromptItem).role === PromptRole.user, + ) if (userIndex >= 0) { - return promptItems.map((item, index) => { + return template.map((item, index) => { if (index !== userIndex) return item - return applyPromptText(item) + return applyPromptText(item as PromptItem) }) as PromptTemplateItem[] } - const useJinja = promptItems.some((item: PromptItem) => item.edition_type === EditionType.jinja2) + const useJinja = template.some( + item => !isPromptMessageContext(item) && (item as PromptItem).edition_type === EditionType.jinja2, + ) const defaultUserPrompt: PromptItem = useJinja ? { role: PromptRole.user, @@ -126,13 +128,7 @@ const SubGraphContent: FC = (props) => { edition_type: EditionType.jinja2, } : { role: PromptRole.user, text: promptText } - const systemIndex = promptItems.findIndex(item => item.role === PromptRole.system) - const nextTemplate = [...promptItems] - if (systemIndex >= 0) - nextTemplate.splice(systemIndex + 1, 0, defaultUserPrompt) - else - nextTemplate.unshift(defaultUserPrompt) - return nextTemplate as PromptTemplateItem[] + return [...template, defaultUserPrompt] as PromptTemplateItem[] })() return { From 1ff677c3000689e59568bf9164e55c1fbdbf33a3 Mon Sep 17 00:00:00 2001 From: zhsama Date: Thu, 15 Jan 2026 04:08:42 +0800 Subject: [PATCH 52/82] refactor: Remove unused sub-graph persistence and initialization hooks. Simplified sub-graph store by removing unused state fields and setters. --- web/app/components/sub-graph/hooks/index.ts | 3 - .../sub-graph/hooks/use-sub-graph-init.ts | 116 ------------------ .../hooks/use-sub-graph-persistence.ts | 116 ------------------ web/app/components/sub-graph/store/index.ts | 43 +------ web/app/components/sub-graph/types.ts | 51 +------- 5 files changed, 2 insertions(+), 327 deletions(-) delete mode 100644 web/app/components/sub-graph/hooks/use-sub-graph-init.ts delete mode 100644 web/app/components/sub-graph/hooks/use-sub-graph-persistence.ts diff --git a/web/app/components/sub-graph/hooks/index.ts b/web/app/components/sub-graph/hooks/index.ts index d67a22f2cc..71ef209f64 100644 --- a/web/app/components/sub-graph/hooks/index.ts +++ b/web/app/components/sub-graph/hooks/index.ts @@ -1,5 +1,2 @@ export { useAvailableNodesMetaData } from './use-available-nodes-meta-data' -export { useSubGraphInit } from './use-sub-graph-init' export { useSubGraphNodes } from './use-sub-graph-nodes' -export { useSubGraphPersistence } from './use-sub-graph-persistence' -export type { SubGraphData } from './use-sub-graph-persistence' diff --git a/web/app/components/sub-graph/hooks/use-sub-graph-init.ts b/web/app/components/sub-graph/hooks/use-sub-graph-init.ts deleted file mode 100644 index 84ee9abd9a..0000000000 --- a/web/app/components/sub-graph/hooks/use-sub-graph-init.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { SubGraphProps } from '../types' -import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types' -import type { StartNodeType } from '@/app/components/workflow/nodes/start/types' -import type { Edge, Node, ValueSelector } from '@/app/components/workflow/types' -import { useMemo } from 'react' -import { Type } from '@/app/components/workflow/nodes/llm/types' -import { BlockEnum, PromptRole } from '@/app/components/workflow/types' -import { AppModeEnum } from '@/types/app' - -export const SUBGRAPH_SOURCE_NODE_ID = 'subgraph-source' -export const SUBGRAPH_LLM_NODE_ID = 'subgraph-llm' - -export const getSubGraphInitialNodes = ( - sourceVariable: ValueSelector, - agentName: string, - paramKey: string, -): Node[] => { - const sourceVarName = sourceVariable.length > 1 - ? sourceVariable.slice(1).join('.') - : 'output' - - const startNode: Node = { - id: SUBGRAPH_SOURCE_NODE_ID, - type: 'custom', - position: { x: 100, y: 150 }, - data: { - type: BlockEnum.Start, - title: `${agentName}: ${sourceVarName}`, - desc: 'Source variable from agent', - _connectedSourceHandleIds: ['source'], - _connectedTargetHandleIds: [], - variables: [], - }, - } - - const llmNode: Node = { - id: SUBGRAPH_LLM_NODE_ID, - type: 'custom', - position: { x: 450, y: 150 }, - data: { - type: BlockEnum.LLM, - title: 'LLM', - desc: 'Transform the output', - _connectedSourceHandleIds: [], - _connectedTargetHandleIds: ['target'], - model: { - provider: '', - name: '', - mode: AppModeEnum.CHAT, - completion_params: { - temperature: 0.7, - }, - }, - prompt_template: [{ - role: PromptRole.system, - text: '', - }], - context: { - enabled: false, - variable_selector: [], - }, - vision: { - enabled: false, - }, - structured_output_enabled: true, - structured_output: { - schema: { - type: Type.object, - properties: { - [paramKey]: { - type: Type.string, - }, - }, - required: [paramKey], - additionalProperties: false, - }, - }, - }, - } - - return [startNode, llmNode] -} - -export const getSubGraphInitialEdges = (): Edge[] => { - return [ - { - id: `${SUBGRAPH_SOURCE_NODE_ID}-${SUBGRAPH_LLM_NODE_ID}`, - source: SUBGRAPH_SOURCE_NODE_ID, - sourceHandle: 'source', - target: SUBGRAPH_LLM_NODE_ID, - targetHandle: 'target', - type: 'custom', - data: { - sourceType: BlockEnum.Start, - targetType: BlockEnum.LLM, - }, - }, - ] -} - -export const useSubGraphInit = (props: SubGraphProps) => { - const { sourceVariable, agentName, paramKey } = props - - const initialNodes = useMemo((): Node[] => { - return getSubGraphInitialNodes(sourceVariable, agentName, paramKey) - }, [sourceVariable, agentName, paramKey]) - - const initialEdges = useMemo((): Edge[] => { - return getSubGraphInitialEdges() - }, []) - - return { - initialNodes, - initialEdges, - } -} diff --git a/web/app/components/sub-graph/hooks/use-sub-graph-persistence.ts b/web/app/components/sub-graph/hooks/use-sub-graph-persistence.ts deleted file mode 100644 index ee54000b9c..0000000000 --- a/web/app/components/sub-graph/hooks/use-sub-graph-persistence.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { SubGraphConfig } from '../types' -import type { ToolNodeType } from '@/app/components/workflow/nodes/tool/types' -import type { Edge, Node } from '@/app/components/workflow/types' -import { useCallback } from 'react' -import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' -import { VarKindType } from '@/app/components/workflow/nodes/_base/types' - -type SubGraphPersistenceProps = { - toolNodeId: string - paramKey: string -} - -export type SubGraphData = { - nodes: Node[] - edges: Edge[] - config: SubGraphConfig -} - -const SUB_GRAPH_DATA_PREFIX = '__subgraph__' - -export const useSubGraphPersistence = ({ - toolNodeId, - paramKey, -}: SubGraphPersistenceProps) => { - const { inputs, setInputs } = useNodeCrud(toolNodeId, {} as ToolNodeType) - - const getSubGraphDataKey = useCallback(() => { - return `${SUB_GRAPH_DATA_PREFIX}${paramKey}` - }, [paramKey]) - - const loadSubGraphData = useCallback((): SubGraphData | null => { - const dataKey = getSubGraphDataKey() - const toolParameters = inputs.tool_parameters || {} - const storedData = toolParameters[dataKey] - - if (!storedData || storedData.type !== VarKindType.constant) { - return null - } - - try { - const parsed = typeof storedData.value === 'string' - ? JSON.parse(storedData.value) - : storedData.value - - return parsed as SubGraphData - } - catch { - return null - } - }, [getSubGraphDataKey, inputs.tool_parameters]) - - const saveSubGraphData = useCallback((data: SubGraphData) => { - const dataKey = getSubGraphDataKey() - const newToolParameters = { - ...inputs.tool_parameters, - [dataKey]: { - type: VarKindType.constant, - value: JSON.stringify(data), - }, - } - - setInputs({ - ...inputs, - tool_parameters: newToolParameters, - }) - }, [getSubGraphDataKey, inputs, setInputs]) - - const hasSubGraphData = useCallback(() => { - const dataKey = getSubGraphDataKey() - const toolParameters = inputs.tool_parameters || {} - return !!toolParameters[dataKey] - }, [getSubGraphDataKey, inputs.tool_parameters]) - - const updateSubGraphConfig = useCallback(( - config: Partial, - ) => { - const existingData = loadSubGraphData() - if (!existingData) - return - - saveSubGraphData({ - ...existingData, - config: { - ...existingData.config, - ...config, - }, - }) - }, [loadSubGraphData, saveSubGraphData]) - - const updateSubGraphNodes = useCallback(( - nodes: Node[], - edges: Edge[], - ) => { - const existingData = loadSubGraphData() - const defaultConfig: SubGraphConfig = { - enabled: true, - startNodeId: nodes[0]?.id || '', - selectedOutputVar: [], - whenOutputNone: 'default', - } - - saveSubGraphData({ - nodes, - edges, - config: existingData?.config || defaultConfig, - }) - }, [loadSubGraphData, saveSubGraphData]) - - return { - loadSubGraphData, - saveSubGraphData, - hasSubGraphData, - updateSubGraphConfig, - updateSubGraphNodes, - } -} diff --git a/web/app/components/sub-graph/store/index.ts b/web/app/components/sub-graph/store/index.ts index 3b314be72a..0701cdc3f2 100644 --- a/web/app/components/sub-graph/store/index.ts +++ b/web/app/components/sub-graph/store/index.ts @@ -1,53 +1,12 @@ import type { CreateSubGraphSlice, SubGraphSliceShape } from '../types' -const initialState: Omit = { - parentToolNodeId: '', - parameterKey: '', - sourceAgentNodeId: '', - sourceVariable: [], - subGraphReadOnly: true, - - subGraphNodes: [], - subGraphEdges: [], - - selectedOutputVar: [], - whenOutputNone: 'default', - defaultValue: '', - - showDebugPanel: false, - isRunning: false, - +const initialState: Omit = { parentAvailableVars: [], parentAvailableNodes: [], } export const createSubGraphSlice: CreateSubGraphSlice = set => ({ ...initialState, - - setSubGraphContext: context => set(() => ({ - parentToolNodeId: context.parentToolNodeId, - parameterKey: context.parameterKey, - sourceAgentNodeId: context.sourceAgentNodeId, - sourceVariable: context.sourceVariable, - })), - - setSubGraphNodes: nodes => set(() => ({ subGraphNodes: nodes })), - - setSubGraphEdges: edges => set(() => ({ subGraphEdges: edges })), - - setSelectedOutputVar: selector => set(() => ({ selectedOutputVar: selector })), - - setWhenOutputNone: option => set(() => ({ whenOutputNone: option })), - - setDefaultValue: value => set(() => ({ defaultValue: value })), - - setShowDebugPanel: show => set(() => ({ showDebugPanel: show })), - - setIsRunning: running => set(() => ({ isRunning: running })), - setParentAvailableVars: vars => set(() => ({ parentAvailableVars: vars })), - setParentAvailableNodes: nodes => set(() => ({ parentAvailableNodes: nodes })), - - resetSubGraph: () => set(() => ({ ...initialState })), }) diff --git a/web/app/components/sub-graph/types.ts b/web/app/components/sub-graph/types.ts index 936ac87cde..fc98101a08 100644 --- a/web/app/components/sub-graph/types.ts +++ b/web/app/components/sub-graph/types.ts @@ -1,25 +1,7 @@ import type { StateCreator } from 'zustand' import type { MentionConfig } from '@/app/components/workflow/nodes/_base/types' import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types' -import type { Edge, Node, NodeOutPutVar, ValueSelector, VarType } from '@/app/components/workflow/types' - -export type WhenOutputNoneOption = 'error' | 'default' - -export type SubGraphConfig = { - enabled: boolean - startNodeId: string - selectedOutputVar: ValueSelector - whenOutputNone: WhenOutputNoneOption - defaultValue?: string -} - -export type SubGraphOutputVariable = { - nodeId: string - nodeName: string - variable: string - type: VarType - description?: string -} +import type { Edge, Node, NodeOutPutVar, ValueSelector } from '@/app/components/workflow/types' export type SubGraphProps = { toolNodeId: string @@ -37,41 +19,10 @@ export type SubGraphProps = { } export type SubGraphSliceShape = { - parentToolNodeId: string - parameterKey: string - sourceAgentNodeId: string - sourceVariable: ValueSelector - subGraphReadOnly: boolean - - subGraphNodes: Node[] - subGraphEdges: Edge[] - - selectedOutputVar: ValueSelector - whenOutputNone: WhenOutputNoneOption - defaultValue: string - - showDebugPanel: boolean - isRunning: boolean - parentAvailableVars: NodeOutPutVar[] parentAvailableNodes: Node[] - - setSubGraphContext: (context: { - parentToolNodeId: string - parameterKey: string - sourceAgentNodeId: string - sourceVariable: ValueSelector - }) => void - setSubGraphNodes: (nodes: Node[]) => void - setSubGraphEdges: (edges: Edge[]) => void - setSelectedOutputVar: (selector: ValueSelector) => void - setWhenOutputNone: (option: WhenOutputNoneOption) => void - setDefaultValue: (value: string) => void - setShowDebugPanel: (show: boolean) => void - setIsRunning: (running: boolean) => void setParentAvailableVars: (vars: NodeOutPutVar[]) => void setParentAvailableNodes: (nodes: Node[]) => void - resetSubGraph: () => void } export type CreateSubGraphSlice = StateCreator From ccb337e8ebeb26fe0388c0bbb0da9c4e64bc70c5 Mon Sep 17 00:00:00 2001 From: zhsama Date: Thu, 15 Jan 2026 04:09:35 +0800 Subject: [PATCH 53/82] fix: Sync extractor prompt template with tool input text --- .../mixed-variable-text-input/index.tsx | 133 +++++++++++++++++- .../tool/components/sub-graph-modal/index.tsx | 5 +- 2 files changed, 130 insertions(+), 8 deletions(-) diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx index a340739c9c..eec880bd77 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx @@ -1,8 +1,11 @@ import type { AgentNode } from '@/app/components/base/prompt-editor/types' import type { MentionConfig, VarKindType } from '@/app/components/workflow/nodes/_base/types' +import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types' import type { Node, NodeOutPutVar, + PromptItem, + PromptTemplateItem, ValueSelector, } from '@/app/components/workflow/types' import { @@ -18,7 +21,7 @@ import { useNodesMetaData, useNodesSyncDraft } from '@/app/components/workflow/h import { VarKindType as VarKindTypeEnum } from '@/app/components/workflow/nodes/_base/types' import { Type } from '@/app/components/workflow/nodes/llm/types' import { useStore } from '@/app/components/workflow/store' -import { BlockEnum } from '@/app/components/workflow/types' +import { BlockEnum, EditionType, isPromptMessageContext, PromptRole } from '@/app/components/workflow/types' import { generateNewNode, getNodeCustomTypeByNodeDataType } from '@/app/components/workflow/utils' import { cn } from '@/utils/classnames' import SubGraphModal from '../sub-graph-modal' @@ -38,6 +41,76 @@ const DEFAULT_MENTION_CONFIG: MentionConfig = { default_value: '', } +const resolvePromptText = (item?: PromptItem) => { + if (!item) + return '' + if (item.edition_type === EditionType.jinja2) + return item.jinja2_text || item.text || '' + return item.text || '' +} + +const getUserPromptText = (promptTemplate?: PromptTemplateItem[] | PromptItem) => { + if (!promptTemplate) + return '' + if (Array.isArray(promptTemplate)) { + const userPrompt = promptTemplate.find( + item => !isPromptMessageContext(item) && item.role === PromptRole.user, + ) as PromptItem | undefined + return resolvePromptText(userPrompt) + } + return resolvePromptText(promptTemplate) +} + +const hasUserPromptTemplate = (promptTemplate: PromptTemplateItem[] | PromptItem) => { + if (!Array.isArray(promptTemplate)) + return true + return promptTemplate.some(item => !isPromptMessageContext(item) && item.role === PromptRole.user) +} + +const applyPromptText = (item: PromptItem, text: string) => { + if (item.edition_type === EditionType.jinja2) { + return { + ...item, + text, + jinja2_text: text, + } + } + return { + ...item, + text, + } +} + +const buildPromptTemplateWithText = (promptTemplate: PromptTemplateItem[] | PromptItem, text: string) => { + if (!Array.isArray(promptTemplate)) + return applyPromptText(promptTemplate as PromptItem, text) + + const userIndex = promptTemplate.findIndex( + item => !isPromptMessageContext(item) && item.role === PromptRole.user, + ) + if (userIndex >= 0) { + return promptTemplate.map((item, index) => { + if (index !== userIndex || isPromptMessageContext(item)) + return item + return applyPromptText(item as PromptItem, text) + }) as PromptTemplateItem[] + } + + const useJinja = promptTemplate.some( + item => !isPromptMessageContext(item) && (item as PromptItem).edition_type === EditionType.jinja2, + ) + const defaultUserPrompt: PromptItem = useJinja + ? { + role: PromptRole.user, + text, + jinja2_text: text, + edition_type: EditionType.jinja2, + } + : { role: PromptRole.user, text } + + return [...promptTemplate, defaultUserPrompt] as PromptTemplateItem[] +} + type MixedVariableTextInputProps = { readOnly?: boolean nodesOutputVars?: NodeOutPutVar[] @@ -83,11 +156,11 @@ const MixedVariableTextInput = ({ name: string } - const detectedAgentFromValue: DetectedAgent | null = useMemo(() => { - if (!value) + const detectAgentFromText = useCallback((text: string): DetectedAgent | null => { + if (!text) return null - const matches = value.matchAll(AGENT_CONTEXT_VAR_PATTERN) + const matches = text.matchAll(AGENT_CONTEXT_VAR_PATTERN) for (const match of matches) { const variablePath = match[1] const nodeId = variablePath.split('.')[0] @@ -100,7 +173,11 @@ const MixedVariableTextInput = ({ } } return null - }, [value, nodesByIdMap]) + }, [nodesByIdMap]) + + const detectedAgentFromValue: DetectedAgent | null = useMemo(() => { + return detectAgentFromText(value) + }, [detectAgentFromText, value]) const agentNodes = useMemo(() => { return availableNodes @@ -111,6 +188,47 @@ const MixedVariableTextInput = ({ })) }, [availableNodes]) + const syncExtractorPromptFromText = useCallback((text: string) => { + if (!toolNodeId || !paramKey) + return + + const detectedAgent = detectAgentFromText(text) + if (!detectedAgent) + return + + const escapedAgentId = detectedAgent.nodeId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const leadingPattern = new RegExp(`^\\{\\{[@#]${escapedAgentId}\\.context[@#]\\}\\}`) + const promptText = text.replace(leadingPattern, '') + + const extractorNodeId = `${toolNodeId}_ext_${paramKey}` + const { getNodes, setNodes } = reactFlowStore.getState() + const nodes = getNodes() + const extractorNode = nodes.find(node => node.id === extractorNodeId) as Node | undefined + if (!extractorNode?.data?.prompt_template) + return + + const currentPromptText = getUserPromptText(extractorNode.data.prompt_template) + const shouldUpdate = !hasUserPromptTemplate(extractorNode.data.prompt_template) + || currentPromptText !== promptText + if (!shouldUpdate) + return + + const nextPromptTemplate = buildPromptTemplateWithText(extractorNode.data.prompt_template, promptText) + const nextNodes = nodes.map((node) => { + if (node.id !== extractorNodeId) + return node + return { + ...node, + data: { + ...node.data, + prompt_template: nextPromptTemplate, + }, + } + }) + setNodes(nextNodes) + handleSyncWorkflowDraft() + }, [detectAgentFromText, handleSyncWorkflowDraft, paramKey, reactFlowStore, toolNodeId]) + const removeExtractorNode = useCallback(() => { if (!toolNodeId || !paramKey) return @@ -195,8 +313,9 @@ const MixedVariableTextInput = ({ output_selector: paramKey ? ['structured_output', paramKey] : [], } onChange(newValue, VarKindTypeEnum.mention, mentionConfigWithOutputSelector) + syncExtractorPromptFromText(newValue) setControlPromptEditorRerenderKey(Date.now()) - }, [handleSyncWorkflowDraft, nodesMetaDataMap, onChange, paramKey, reactFlowStore, setControlPromptEditorRerenderKey, toolNodeId, value]) + }, [handleSyncWorkflowDraft, nodesMetaDataMap, onChange, paramKey, reactFlowStore, setControlPromptEditorRerenderKey, syncExtractorPromptFromText, toolNodeId, value]) const handleOpenSubGraphModal = useCallback(() => { setIsSubGraphModalOpen(true) @@ -257,6 +376,8 @@ const MixedVariableTextInput = ({ placeholder={} onChange={(text) => { const hasPlaceholder = new RegExp(AGENT_CONTEXT_VAR_PATTERN.source).test(text) + if (hasPlaceholder) + syncExtractorPromptFromText(text) if (detectedAgentFromValue && !hasPlaceholder) { removeExtractorNode() onChange?.(text, VarKindTypeEnum.mixed, null) diff --git a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx index fc793db9e4..74dd99a0c1 100644 --- a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx +++ b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx @@ -110,7 +110,7 @@ const SubGraphModal: FC = ({ } }) setNodes(nextNodes) - handleSyncWorkflowDraft(true) + handleSyncWorkflowDraft() }, [handleSyncWorkflowDraft, paramKey, reactflowStore, toolNodeId]) useEffect(() => { @@ -146,6 +146,7 @@ const SubGraphModal: FC = ({ return resolveText(promptTemplate) }, []) + // TODO: handle external workflow updates while sub-graph modal is open. const handleSave = useCallback((subGraphNodes: any[], _edges: any[]) => { const extractorNodeData = subGraphNodes.find(node => node.id === extractorNodeId) if (!extractorNodeData) @@ -191,7 +192,7 @@ const SubGraphModal: FC = ({ }) setNodes(nextNodes) // Trigger main graph draft sync to persist changes to backend - handleSyncWorkflowDraft(true) + handleSyncWorkflowDraft() setControlPromptEditorRerenderKey(Date.now()) }, [agentNodeId, extractorNodeId, getUserPromptText, handleSyncWorkflowDraft, paramKey, reactflowStore, setControlPromptEditorRerenderKey, toolNodeId]) From 8ee643e88deb4b4aeabf66c1fc57706d874624c4 Mon Sep 17 00:00:00 2001 From: zhsama Date: Thu, 15 Jan 2026 15:55:55 +0800 Subject: [PATCH 54/82] fix: fix variable inspect panel width in subgraphs --- .../components/sub-graph-children.tsx | 35 +++++++++++++++++-- .../sub-graph/components/sub-graph-main.tsx | 21 ++++++++++- web/app/components/sub-graph/index.tsx | 3 ++ web/app/components/workflow/index.tsx | 2 +- .../components/workflow/operator/index.tsx | 10 ++++-- 5 files changed, 64 insertions(+), 7 deletions(-) diff --git a/web/app/components/sub-graph/components/sub-graph-children.tsx b/web/app/components/sub-graph/components/sub-graph-children.tsx index b17472ff7d..749a571efb 100644 --- a/web/app/components/sub-graph/components/sub-graph-children.tsx +++ b/web/app/components/sub-graph/components/sub-graph-children.tsx @@ -1,7 +1,7 @@ import type { FC } from 'react' import type { MentionConfig } from '@/app/components/workflow/nodes/_base/types' import type { NodeOutPutVar } from '@/app/components/workflow/types' -import { memo, useMemo } from 'react' +import { memo, useEffect, useMemo, useRef } from 'react' import { useStore as useReactFlowStore } from 'reactflow' import { useShallow } from 'zustand/react/shallow' import { useIsChatMode, useWorkflowVariables } from '@/app/components/workflow/hooks' @@ -26,6 +26,37 @@ const SubGraphChildren: FC = ({ const { getNodeAvailableVars } = useWorkflowVariables() const isChatMode = useIsChatMode() const nodePanelWidth = useStore(s => s.nodePanelWidth) + const setRightPanelWidth = useStore(s => s.setRightPanelWidth) + const panelRef = useRef(null) + + useEffect(() => { + const element = panelRef.current + if (!element) + return + + const updateWidth = (width: number) => { + if (width > 0) + setRightPanelWidth(width) + } + + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + if (entry.borderBoxSize?.length) + updateWidth(entry.borderBoxSize[0].inlineSize) + else if (entry.contentRect.width > 0) + updateWidth(entry.contentRect.width) + else + updateWidth(element.getBoundingClientRect().width) + } + }) + + resizeObserver.observe(element) + updateWidth(element.getBoundingClientRect().width) + + return () => { + resizeObserver.disconnect() + } + }, [setRightPanelWidth]) const selectedNode = useReactFlowStore(useShallow((s) => { return s.getNodes().find(node => node.data.selected) @@ -66,7 +97,7 @@ const SubGraphChildren: FC = ({ return (
-
+
{nodePanel || (
void onSave?: (nodes: Node[], edges: Edge[]) => void @@ -25,12 +30,23 @@ const SubGraphMain: FC = ({ viewport, agentName, extractorNodeId, + configsMap, mentionConfig, onMentionConfigChange, onSave, }) => { const reactFlowStore = useStoreApi() const availableNodesMetaData = useAvailableNodesMetaData() + const flowType = configsMap?.flowType ?? FlowType.appFlow + const flowId = configsMap?.flowId ?? '' + const { fetchInspectVars } = useSetWorkflowVarsWithValue({ + flowType, + flowId, + }) + const inspectVarsCrud = useInspectVarsCrudCommon({ + flowType, + flowId, + }) const handleSyncSubGraphDraft = useCallback(() => { const { getNodes, edges } = reactFlowStore.getState() @@ -40,11 +56,14 @@ const SubGraphMain: FC = ({ const hooksStore = useMemo(() => ({ interactionMode: 'subgraph', availableNodesMetaData, + configsMap, + fetchInspectVars, + ...inspectVarsCrud, doSyncWorkflowDraft: async () => { handleSyncSubGraphDraft() }, syncWorkflowDraftWhenPageClose: handleSyncSubGraphDraft, - }), [availableNodesMetaData, handleSyncSubGraphDraft]) + }), [availableNodesMetaData, configsMap, fetchInspectVars, handleSyncSubGraphDraft, inspectVarsCrud]) return ( = (props) => { const setParentAvailableVars = useStore(state => state.setParentAvailableVars) const setParentAvailableNodes = useStore(state => state.setParentAvailableNodes) + const configsMap = useHooksStore(state => state.configsMap) useEffect(() => { setParentAvailableVars?.(parentAvailableVars || []) @@ -187,6 +189,7 @@ const SubGraphContent: FC = (props) => { viewport={defaultViewport} agentName={agentName} extractorNodeId={`${toolNodeId}_ext_${paramKey}`} + configsMap={configsMap} mentionConfig={mentionConfig} onMentionConfigChange={onMentionConfigChange} onSave={onSave} diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx index 1a2a42bcde..f0846f2996 100644 --- a/web/app/components/workflow/index.tsx +++ b/web/app/components/workflow/index.tsx @@ -419,7 +419,7 @@ export const Workflow: FC = memo(({ > {!isSubGraph && }
- {!isSubGraph && } + {!isSubGraph && } {!isSubGraph && } {!isSubGraph && } diff --git a/web/app/components/workflow/operator/index.tsx b/web/app/components/workflow/operator/index.tsx index 519eb1fdb7..a52d7fc409 100644 --- a/web/app/components/workflow/operator/index.tsx +++ b/web/app/components/workflow/operator/index.tsx @@ -2,6 +2,7 @@ import type { Node } from 'reactflow' import { memo, useCallback, useEffect, useMemo, useRef } from 'react' import { MiniMap } from 'reactflow' import UndoRedo from '../header/undo-redo' +import { useHooksStore } from '../hooks-store' import { useStore } from '../store' import VariableInspectPanel from '../variable-inspect' import VariableTrigger from '../variable-inspect/trigger' @@ -14,16 +15,19 @@ export type OperatorProps = { const Operator = ({ handleUndo, handleRedo }: OperatorProps) => { const bottomPanelRef = useRef(null) + const interactionMode = useHooksStore(s => s.interactionMode) const workflowCanvasWidth = useStore(s => s.workflowCanvasWidth) const rightPanelWidth = useStore(s => s.rightPanelWidth) + const nodePanelWidth = useStore(s => s.nodePanelWidth) const setBottomPanelWidth = useStore(s => s.setBottomPanelWidth) const setBottomPanelHeight = useStore(s => s.setBottomPanelHeight) const bottomPanelWidth = useMemo(() => { - if (!workflowCanvasWidth || !rightPanelWidth) + const panelWidth = rightPanelWidth || nodePanelWidth + if (!workflowCanvasWidth || !panelWidth) return 'auto' - return Math.max((workflowCanvasWidth - rightPanelWidth), 400) - }, [workflowCanvasWidth, rightPanelWidth]) + return Math.max((workflowCanvasWidth - panelWidth), 400) + }, [interactionMode, nodePanelWidth, rightPanelWidth, workflowCanvasWidth]) const getMiniMapNodeClassName = useCallback((node: Node) => { return node.data?.selected From 5525f630323445983866806265d65db83d064966 Mon Sep 17 00:00:00 2001 From: zhsama Date: Thu, 15 Jan 2026 16:12:39 +0800 Subject: [PATCH 55/82] refactor: sub-graph panel use shared Panel component --- .../components/sub-graph-children.tsx | 88 ++++++------------- .../components/workflow/operator/index.tsx | 10 +-- web/app/components/workflow/panel/index.tsx | 3 + 3 files changed, 32 insertions(+), 69 deletions(-) diff --git a/web/app/components/sub-graph/components/sub-graph-children.tsx b/web/app/components/sub-graph/components/sub-graph-children.tsx index 749a571efb..7da4f15664 100644 --- a/web/app/components/sub-graph/components/sub-graph-children.tsx +++ b/web/app/components/sub-graph/components/sub-graph-children.tsx @@ -1,11 +1,11 @@ import type { FC } from 'react' import type { MentionConfig } from '@/app/components/workflow/nodes/_base/types' import type { NodeOutPutVar } from '@/app/components/workflow/types' -import { memo, useEffect, useMemo, useRef } from 'react' +import { memo, useMemo } from 'react' import { useStore as useReactFlowStore } from 'reactflow' import { useShallow } from 'zustand/react/shallow' import { useIsChatMode, useWorkflowVariables } from '@/app/components/workflow/hooks' -import { Panel as NodePanel } from '@/app/components/workflow/nodes' +import Panel from '@/app/components/workflow/panel' import { useStore } from '@/app/components/workflow/store' import { BlockEnum } from '@/app/components/workflow/types' import ConfigPanel from './config-panel' @@ -26,37 +26,6 @@ const SubGraphChildren: FC = ({ const { getNodeAvailableVars } = useWorkflowVariables() const isChatMode = useIsChatMode() const nodePanelWidth = useStore(s => s.nodePanelWidth) - const setRightPanelWidth = useStore(s => s.setRightPanelWidth) - const panelRef = useRef(null) - - useEffect(() => { - const element = panelRef.current - if (!element) - return - - const updateWidth = (width: number) => { - if (width > 0) - setRightPanelWidth(width) - } - - const resizeObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - if (entry.borderBoxSize?.length) - updateWidth(entry.borderBoxSize[0].inlineSize) - else if (entry.contentRect.width > 0) - updateWidth(entry.contentRect.width) - else - updateWidth(element.getBoundingClientRect().width) - } - }) - - resizeObserver.observe(element) - updateWidth(element.getBoundingClientRect().width) - - return () => { - resizeObserver.disconnect() - } - }, [setRightPanelWidth]) const selectedNode = useReactFlowStore(useShallow((s) => { return s.getNodes().find(node => node.data.selected) @@ -82,41 +51,36 @@ const SubGraphChildren: FC = ({ return vars.filter(item => item.nodeId === extractorNode.id) }, [extractorNode, getNodeAvailableVars, isChatMode]) - const nodePanel = useMemo(() => { - if (!selectedNode) + const panelRight = useMemo(() => { + if (selectedNode) return null return ( - +
+
+ +
+
) - }, [selectedNode]) + }, [agentName, availableNodes, availableVars, extractorNodeId, mentionConfig, nodePanelWidth, onMentionConfigChange, selectedNode]) return ( -
-
- {nodePanel || ( -
-
- -
-
- )} -
-
+ ) } diff --git a/web/app/components/workflow/operator/index.tsx b/web/app/components/workflow/operator/index.tsx index a52d7fc409..519eb1fdb7 100644 --- a/web/app/components/workflow/operator/index.tsx +++ b/web/app/components/workflow/operator/index.tsx @@ -2,7 +2,6 @@ import type { Node } from 'reactflow' import { memo, useCallback, useEffect, useMemo, useRef } from 'react' import { MiniMap } from 'reactflow' import UndoRedo from '../header/undo-redo' -import { useHooksStore } from '../hooks-store' import { useStore } from '../store' import VariableInspectPanel from '../variable-inspect' import VariableTrigger from '../variable-inspect/trigger' @@ -15,19 +14,16 @@ export type OperatorProps = { const Operator = ({ handleUndo, handleRedo }: OperatorProps) => { const bottomPanelRef = useRef(null) - const interactionMode = useHooksStore(s => s.interactionMode) const workflowCanvasWidth = useStore(s => s.workflowCanvasWidth) const rightPanelWidth = useStore(s => s.rightPanelWidth) - const nodePanelWidth = useStore(s => s.nodePanelWidth) const setBottomPanelWidth = useStore(s => s.setBottomPanelWidth) const setBottomPanelHeight = useStore(s => s.setBottomPanelHeight) const bottomPanelWidth = useMemo(() => { - const panelWidth = rightPanelWidth || nodePanelWidth - if (!workflowCanvasWidth || !panelWidth) + if (!workflowCanvasWidth || !rightPanelWidth) return 'auto' - return Math.max((workflowCanvasWidth - panelWidth), 400) - }, [interactionMode, nodePanelWidth, rightPanelWidth, workflowCanvasWidth]) + return Math.max((workflowCanvasWidth - rightPanelWidth), 400) + }, [workflowCanvasWidth, rightPanelWidth]) const getMiniMapNodeClassName = useCallback((node: Node) => { return node.data?.selected diff --git a/web/app/components/workflow/panel/index.tsx b/web/app/components/workflow/panel/index.tsx index 88ada8b11e..6fbafb1817 100644 --- a/web/app/components/workflow/panel/index.tsx +++ b/web/app/components/workflow/panel/index.tsx @@ -19,6 +19,7 @@ export type PanelProps = { right?: React.ReactNode } versionHistoryPanelProps?: VersionHistoryPanelProps + topOffset?: number } /** @@ -69,6 +70,7 @@ const useResizeObserver = ( const Panel: FC = ({ components, versionHistoryPanelProps, + topOffset, }) => { const selectedNode = useReactflow(useShallow((s) => { const nodes = s.getNodes() @@ -128,6 +130,7 @@ const Panel: FC = ({ ref={rightPanelRef} tabIndex={-1} className={cn('absolute bottom-1 right-0 top-14 z-10 flex outline-none')} + style={topOffset === undefined ? undefined : { top: `${topOffset}px` }} key={`${isRestoring}`} > {components?.left} From 2e10d676101fafc14a69b78b5ee79dc3f0bd9e43 Mon Sep 17 00:00:00 2001 From: zhsama Date: Thu, 15 Jan 2026 16:44:15 +0800 Subject: [PATCH 56/82] perf: Replace topOffset prop with withHeader in Panel component --- .../sub-graph/components/sub-graph-children.tsx | 2 +- web/app/components/sub-graph/index.tsx | 3 +-- web/app/components/workflow/panel/index.tsx | 10 ++++++---- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/web/app/components/sub-graph/components/sub-graph-children.tsx b/web/app/components/sub-graph/components/sub-graph-children.tsx index 7da4f15664..a8867d1963 100644 --- a/web/app/components/sub-graph/components/sub-graph-children.tsx +++ b/web/app/components/sub-graph/components/sub-graph-children.tsx @@ -76,7 +76,7 @@ const SubGraphChildren: FC = ({ return ( = (props) => { toolParamValue, parentAvailableNodes, parentAvailableVars, + configsMap, onSave, } = props const setParentAvailableVars = useStore(state => state.setParentAvailableVars) const setParentAvailableNodes = useStore(state => state.setParentAvailableNodes) - const configsMap = useHooksStore(state => state.configsMap) useEffect(() => { setParentAvailableVars?.(parentAvailableVars || []) diff --git a/web/app/components/workflow/panel/index.tsx b/web/app/components/workflow/panel/index.tsx index 6fbafb1817..faaf764ee2 100644 --- a/web/app/components/workflow/panel/index.tsx +++ b/web/app/components/workflow/panel/index.tsx @@ -19,7 +19,7 @@ export type PanelProps = { right?: React.ReactNode } versionHistoryPanelProps?: VersionHistoryPanelProps - topOffset?: number + withHeader?: boolean } /** @@ -70,7 +70,7 @@ const useResizeObserver = ( const Panel: FC = ({ components, versionHistoryPanelProps, - topOffset, + withHeader = true, }) => { const selectedNode = useReactflow(useShallow((s) => { const nodes = s.getNodes() @@ -129,8 +129,10 @@ const Panel: FC = ({
{components?.left} From d641c845dd5052dd716637de51f729f9194f7e37 Mon Sep 17 00:00:00 2001 From: zhsama Date: Thu, 15 Jan 2026 17:12:30 +0800 Subject: [PATCH 57/82] feat: Pass workflow draft sync callback to sub-graph --- .../sub-graph/components/sub-graph-main.tsx | 29 ++++++++++++++++--- web/app/components/sub-graph/index.tsx | 2 ++ web/app/components/sub-graph/types.ts | 14 +++++++++ .../tool/components/sub-graph-modal/index.tsx | 6 +++- .../sub-graph-modal/sub-graph-canvas.tsx | 4 +++ .../tool/components/sub-graph-modal/types.ts | 4 +++ 6 files changed, 54 insertions(+), 5 deletions(-) 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 53162dde36..2dc93f881e 100644 --- a/web/app/components/sub-graph/components/sub-graph-main.tsx +++ b/web/app/components/sub-graph/components/sub-graph-main.tsx @@ -1,5 +1,6 @@ import type { FC } from 'react' import type { Viewport } from 'reactflow' +import type { SyncWorkflowDraft, SyncWorkflowDraftCallback } from '../types' import type { Shape as HooksStoreShape } from '@/app/components/workflow/hooks-store' import type { MentionConfig } from '@/app/components/workflow/nodes/_base/types' import type { Edge, Node } from '@/app/components/workflow/types' @@ -22,6 +23,7 @@ type SubGraphMainProps = { mentionConfig: MentionConfig onMentionConfigChange: (config: MentionConfig) => void onSave?: (nodes: Node[], edges: Edge[]) => void + onSyncWorkflowDraft?: SyncWorkflowDraft } const SubGraphMain: FC = ({ @@ -34,6 +36,7 @@ const SubGraphMain: FC = ({ mentionConfig, onMentionConfigChange, onSave, + onSyncWorkflowDraft, }) => { const reactFlowStore = useStoreApi() const availableNodesMetaData = useAvailableNodesMetaData() @@ -53,17 +56,35 @@ const SubGraphMain: FC = ({ onSave?.(getNodes() as Node[], edges as Edge[]) }, [onSave, reactFlowStore]) + const handleSyncWorkflowDraft = useCallback(async ( + notRefreshWhenSyncError?: boolean, + callback?: SyncWorkflowDraftCallback, + ) => { + handleSyncSubGraphDraft() + if (onSyncWorkflowDraft) { + await onSyncWorkflowDraft(notRefreshWhenSyncError, callback) + return + } + try { + callback?.onSuccess?.() + } + catch { + callback?.onError?.() + } + finally { + callback?.onSettled?.() + } + }, [handleSyncSubGraphDraft, onSyncWorkflowDraft]) + const hooksStore = useMemo(() => ({ interactionMode: 'subgraph', availableNodesMetaData, configsMap, fetchInspectVars, ...inspectVarsCrud, - doSyncWorkflowDraft: async () => { - handleSyncSubGraphDraft() - }, + doSyncWorkflowDraft: handleSyncWorkflowDraft, syncWorkflowDraftWhenPageClose: handleSyncSubGraphDraft, - }), [availableNodesMetaData, configsMap, fetchInspectVars, handleSyncSubGraphDraft, inspectVarsCrud]) + }), [availableNodesMetaData, configsMap, fetchInspectVars, handleSyncSubGraphDraft, handleSyncWorkflowDraft, inspectVarsCrud]) return ( = (props) => { parentAvailableVars, configsMap, onSave, + onSyncWorkflowDraft, } = props const setParentAvailableVars = useStore(state => state.setParentAvailableVars) @@ -192,6 +193,7 @@ const SubGraphContent: FC = (props) => { mentionConfig={mentionConfig} onMentionConfigChange={onMentionConfigChange} onSave={onSave} + onSyncWorkflowDraft={onSyncWorkflowDraft} /> ) diff --git a/web/app/components/sub-graph/types.ts b/web/app/components/sub-graph/types.ts index fc98101a08..2ffd7f91eb 100644 --- a/web/app/components/sub-graph/types.ts +++ b/web/app/components/sub-graph/types.ts @@ -1,14 +1,27 @@ import type { StateCreator } from 'zustand' +import type { Shape as HooksStoreShape } from '@/app/components/workflow/hooks-store' import type { MentionConfig } from '@/app/components/workflow/nodes/_base/types' import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types' import type { Edge, Node, NodeOutPutVar, ValueSelector } from '@/app/components/workflow/types' +export type SyncWorkflowDraftCallback = { + onSuccess?: () => void + onError?: () => void + onSettled?: () => void +} + +export type SyncWorkflowDraft = ( + notRefreshWhenSyncError?: boolean, + callback?: SyncWorkflowDraftCallback, +) => Promise + export type SubGraphProps = { toolNodeId: string paramKey: string sourceVariable: ValueSelector agentNodeId: string agentName: string + configsMap?: HooksStoreShape['configsMap'] mentionConfig: MentionConfig onMentionConfigChange: (config: MentionConfig) => void extractorNode?: Node @@ -16,6 +29,7 @@ export type SubGraphProps = { parentAvailableNodes?: Node[] parentAvailableVars?: NodeOutPutVar[] onSave?: (nodes: Node[], edges: Edge[]) => void + onSyncWorkflowDraft?: SyncWorkflowDraft } export type SubGraphSliceShape = { diff --git a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx index 74dd99a0c1..48e000c414 100644 --- a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx +++ b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx @@ -13,6 +13,7 @@ import { useTranslation } from 'react-i18next' import { useStore as useReactFlowStore, useStoreApi } from 'reactflow' import { Agent } from '@/app/components/base/icons/src/vender/workflow' import { useIsChatMode, useNodesSyncDraft, useWorkflow, useWorkflowVariables } from '@/app/components/workflow/hooks' +import { useHooksStore } from '@/app/components/workflow/hooks-store' import { VarKindType } from '@/app/components/workflow/nodes/_base/types' import { useStore as useWorkflowStore } from '@/app/components/workflow/store' import { BlockEnum, EditionType, PromptRole } from '@/app/components/workflow/types' @@ -32,7 +33,8 @@ const SubGraphModal: FC = ({ const workflowNodes = useWorkflowStore(state => state.nodes) const workflowEdges = useReactFlowStore(state => state.edges) const setControlPromptEditorRerenderKey = useWorkflowStore(state => state.setControlPromptEditorRerenderKey) - const { handleSyncWorkflowDraft } = useNodesSyncDraft() + const { handleSyncWorkflowDraft, doSyncWorkflowDraft } = useNodesSyncDraft() + const configsMap = useHooksStore(state => state.configsMap) const { getBeforeNodesInSameBranch } = useWorkflow() const { getNodeAvailableVars } = useWorkflowVariables() const isChatMode = useIsChatMode() @@ -234,6 +236,7 @@ const SubGraphModal: FC = ({ sourceVariable={sourceVariable} agentNodeId={agentNodeId} agentName={agentName} + configsMap={configsMap} mentionConfig={mentionConfig} onMentionConfigChange={handleMentionConfigChange} extractorNode={extractorNode} @@ -241,6 +244,7 @@ const SubGraphModal: FC = ({ parentAvailableNodes={parentAgentNodes} parentAvailableVars={parentAvailableVars} onSave={handleSave} + onSyncWorkflowDraft={doSyncWorkflowDraft} />
diff --git a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/sub-graph-canvas.tsx b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/sub-graph-canvas.tsx index 04a19c88fc..c8f3b59708 100644 --- a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/sub-graph-canvas.tsx +++ b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/sub-graph-canvas.tsx @@ -10,6 +10,7 @@ const SubGraphCanvas: FC = ({ sourceVariable, agentNodeId, agentName, + configsMap, mentionConfig, onMentionConfigChange, extractorNode, @@ -17,6 +18,7 @@ const SubGraphCanvas: FC = ({ parentAvailableNodes, parentAvailableVars, onSave, + onSyncWorkflowDraft, }) => { return (
@@ -26,6 +28,7 @@ const SubGraphCanvas: FC = ({ sourceVariable={sourceVariable} agentNodeId={agentNodeId} agentName={agentName} + configsMap={configsMap} mentionConfig={mentionConfig} onMentionConfigChange={onMentionConfigChange} extractorNode={extractorNode} @@ -33,6 +36,7 @@ const SubGraphCanvas: FC = ({ parentAvailableNodes={parentAvailableNodes} parentAvailableVars={parentAvailableVars} onSave={onSave} + onSyncWorkflowDraft={onSyncWorkflowDraft} />
) diff --git a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/types.ts b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/types.ts index ae9e227458..a6ed7b9a8f 100644 --- a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/types.ts +++ b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/types.ts @@ -1,3 +1,5 @@ +import type { SyncWorkflowDraft } from '@/app/components/sub-graph/types' +import type { Shape as HooksStoreShape } from '@/app/components/workflow/hooks-store' import type { MentionConfig } from '@/app/components/workflow/nodes/_base/types' import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types' import type { NodeOutPutVar, Edge as WorkflowEdge, Node as WorkflowNode } from '@/app/components/workflow/types' @@ -20,6 +22,7 @@ export type SubGraphCanvasProps = { sourceVariable: WorkflowValueSelector agentNodeId: string agentName: string + configsMap?: HooksStoreShape['configsMap'] mentionConfig: MentionConfig onMentionConfigChange: (config: MentionConfig) => void extractorNode?: WorkflowNode @@ -27,4 +30,5 @@ export type SubGraphCanvasProps = { parentAvailableNodes?: WorkflowNode[] parentAvailableVars?: NodeOutPutVar[] onSave?: (nodes: WorkflowNode[], edges: WorkflowEdge[]) => void + onSyncWorkflowDraft?: SyncWorkflowDraft } From f247ebfbe19a9eab6870cb88dc7f7cd67c56308b Mon Sep 17 00:00:00 2001 From: zhsama Date: Thu, 15 Jan 2026 17:53:28 +0800 Subject: [PATCH 58/82] feat: Await sub-graph save before syncing workflow draft --- .../sub-graph/components/sub-graph-main.tsx | 14 ++++++------- .../tool/components/sub-graph-modal/index.tsx | 21 +++++++++---------- 2 files changed, 17 insertions(+), 18 deletions(-) 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 2dc93f881e..44e30b6aee 100644 --- a/web/app/components/sub-graph/components/sub-graph-main.tsx +++ b/web/app/components/sub-graph/components/sub-graph-main.tsx @@ -51,21 +51,21 @@ const SubGraphMain: FC = ({ flowId, }) - const handleSyncSubGraphDraft = useCallback(() => { + const handleSyncSubGraphDraft = useCallback(async () => { const { getNodes, edges } = reactFlowStore.getState() - onSave?.(getNodes() as Node[], edges as Edge[]) + await onSave?.(getNodes() as Node[], edges as Edge[]) }, [onSave, reactFlowStore]) const handleSyncWorkflowDraft = useCallback(async ( notRefreshWhenSyncError?: boolean, callback?: SyncWorkflowDraftCallback, ) => { - handleSyncSubGraphDraft() - if (onSyncWorkflowDraft) { - await onSyncWorkflowDraft(notRefreshWhenSyncError, callback) - return - } try { + await handleSyncSubGraphDraft() + if (onSyncWorkflowDraft) { + await onSyncWorkflowDraft(notRefreshWhenSyncError, callback) + return + } callback?.onSuccess?.() } catch { diff --git a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx index 48e000c414..c1d2bcbea4 100644 --- a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx +++ b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx @@ -4,7 +4,7 @@ import type { SubGraphModalProps } from './types' import type { MentionConfig } from '@/app/components/workflow/nodes/_base/types' import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types' import type { ToolNodeType } from '@/app/components/workflow/nodes/tool/types' -import type { Node, PromptItem } from '@/app/components/workflow/types' +import type { Node, PromptItem, PromptTemplateItem } from '@/app/components/workflow/types' import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react' import { RiCloseLine } from '@remixicon/react' import { noop } from 'es-toolkit/function' @@ -16,7 +16,7 @@ import { useIsChatMode, useNodesSyncDraft, useWorkflow, useWorkflowVariables } f import { useHooksStore } from '@/app/components/workflow/hooks-store' import { VarKindType } from '@/app/components/workflow/nodes/_base/types' import { useStore as useWorkflowStore } from '@/app/components/workflow/store' -import { BlockEnum, EditionType, PromptRole } from '@/app/components/workflow/types' +import { BlockEnum, EditionType, isPromptMessageContext, PromptRole } from '@/app/components/workflow/types' import SubGraphCanvas from './sub-graph-canvas' const SubGraphModal: FC = ({ @@ -129,7 +129,7 @@ const SubGraphModal: FC = ({ handleMentionConfigChange(mentionConfig) }, [handleMentionConfigChange, mentionConfig, toolParam]) - const getUserPromptText = useCallback((promptTemplate?: PromptItem[] | PromptItem) => { + const getUserPromptText = useCallback((promptTemplate?: PromptTemplateItem[] | PromptItem) => { if (!promptTemplate) return '' const resolveText = (item?: PromptItem) => { @@ -140,17 +140,18 @@ const SubGraphModal: FC = ({ return item.text || '' } if (Array.isArray(promptTemplate)) { - const userPrompt = promptTemplate.find(item => item.role === PromptRole.user) - if (userPrompt) - return resolveText(userPrompt) + for (const item of promptTemplate) { + if (!isPromptMessageContext(item) && item.role === PromptRole.user) + return resolveText(item) + } return '' } return resolveText(promptTemplate) }, []) // TODO: handle external workflow updates while sub-graph modal is open. - const handleSave = useCallback((subGraphNodes: any[], _edges: any[]) => { - const extractorNodeData = subGraphNodes.find(node => node.id === extractorNodeId) + const handleSave = useCallback((subGraphNodes: Node[]) => { + const extractorNodeData = subGraphNodes.find(node => node.id === extractorNodeId) as Node | undefined if (!extractorNodeData) return @@ -193,10 +194,8 @@ const SubGraphModal: FC = ({ return node }) setNodes(nextNodes) - // Trigger main graph draft sync to persist changes to backend - handleSyncWorkflowDraft() setControlPromptEditorRerenderKey(Date.now()) - }, [agentNodeId, extractorNodeId, getUserPromptText, handleSyncWorkflowDraft, paramKey, reactflowStore, setControlPromptEditorRerenderKey, toolNodeId]) + }, [agentNodeId, extractorNodeId, getUserPromptText, paramKey, reactflowStore, setControlPromptEditorRerenderKey, toolNodeId]) return ( From f43fde57972fae698029aa5b9f629e8fef694a2f Mon Sep 17 00:00:00 2001 From: zhsama Date: Thu, 15 Jan 2026 23:26:19 +0800 Subject: [PATCH 59/82] feat: Enhance context variable handling for Agent and LLM nodes --- .../plugins/component-picker-block/index.tsx | 2 +- .../workflow-variable-block/component.tsx | 5 +- .../plugins/workflow-variable-block/node.tsx | 5 +- web/app/components/workflow/constants.ts | 10 ++++ .../_base/hooks/use-available-var-list.ts | 31 +++++++++-- .../nodes/llm/components/config-prompt.tsx | 53 +++++-------------- .../mixed-variable-text-input/index.tsx | 20 +++++-- .../tool/components/sub-graph-modal/index.tsx | 27 ++++++---- 8 files changed, 88 insertions(+), 65 deletions(-) diff --git a/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx b/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx index 4731f9abe7..1511754128 100644 --- a/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx +++ b/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx @@ -220,7 +220,7 @@ const ComponentPicker = ({ ({ ...node, - type: BlockEnum.Agent, + type: BlockEnum.Agent || BlockEnum.LLM, }))} onSelect={handleSelectAgent} onClose={handleClose} diff --git a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/component.tsx b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/component.tsx index 1ff0da78ab..a0388042fc 100644 --- a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/component.tsx +++ b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/component.tsx @@ -67,7 +67,8 @@ const WorkflowVariableBlockComponent = ({ )() const [localWorkflowNodesMap, setLocalWorkflowNodesMap] = useState(workflowNodesMap) const node = localWorkflowNodesMap![variables[isRagVar ? 1 : 0]] - const isAgentContextVariable = node?.type === BlockEnum.Agent && variables[variablesLength - 1] === 'context' + const isContextVariable = (node?.type === BlockEnum.Agent || node?.type === BlockEnum.LLM) + && variables[variablesLength - 1] === 'context' const isException = isExceptionVariable(varName, node?.type) const variableValid = useMemo(() => { @@ -136,7 +137,7 @@ const WorkflowVariableBlockComponent = ({ }) }, [node, reactflow, store]) - if (isAgentContextVariable) + if (isContextVariable) return const Item = ( diff --git a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/node.tsx b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/node.tsx index 75ceb82f2d..64d6baf001 100644 --- a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/node.tsx +++ b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/node.tsx @@ -123,8 +123,9 @@ export class WorkflowVariableBlockNode extends DecoratorNode getTextContent(): string { const variables = this.getVariables() const node = this.getWorkflowNodesMap()?.[variables[0]] - const isAgentContextVariable = node?.type === BlockEnum.Agent && variables[variables.length - 1] === 'context' - const marker = isAgentContextVariable ? '@' : '#' + const isContextVariable = (node?.type === BlockEnum.Agent || node?.type === BlockEnum.LLM) + && variables[variables.length - 1] === 'context' + const marker = isContextVariable ? '@' : '#' return `{{${marker}${variables.join('.')}${marker}}}` } } diff --git a/web/app/components/workflow/constants.ts b/web/app/components/workflow/constants.ts index 4d95db7fcf..39bc15c6ce 100644 --- a/web/app/components/workflow/constants.ts +++ b/web/app/components/workflow/constants.ts @@ -131,6 +131,11 @@ export const SUPPORT_OUTPUT_VARS_NODE = [ ] export const AGENT_OUTPUT_STRUCT: Var[] = [ + { + variable: 'context', + type: VarType.arrayObject, + schemaType: 'List[promptMessage]', + }, { variable: 'usage', type: VarType.object, @@ -142,6 +147,11 @@ export const LLM_OUTPUT_STRUCT: Var[] = [ variable: 'text', type: VarType.string, }, + { + variable: 'context', + type: VarType.arrayObject, + schemaType: 'List[promptMessage]', + }, { variable: 'reasoning_content', type: VarType.string, diff --git a/web/app/components/workflow/nodes/_base/hooks/use-available-var-list.ts b/web/app/components/workflow/nodes/_base/hooks/use-available-var-list.ts index f226900899..e687813b69 100644 --- a/web/app/components/workflow/nodes/_base/hooks/use-available-var-list.ts +++ b/web/app/components/workflow/nodes/_base/hooks/use-available-var-list.ts @@ -71,14 +71,35 @@ const useAvailableVarList = (nodeId: string, { hideEnv, hideChatVar, }), ...dataSourceRagVars] + const availableNodesWithParent = [ + ...availableNodes, + ...(isDataSourceNode ? [currNode] : []), + ] + const llmNodeIds = new Set( + availableNodesWithParent + .filter(node => node?.data.type === BlockEnum.LLM) + .map(node => node!.id), + ) + const filteredAvailableVars = llmNodeIds.size + ? availableVars + .map((nodeVar) => { + if (!llmNodeIds.has(nodeVar.nodeId)) + return nodeVar + const nextVars = nodeVar.vars.filter(item => item.variable !== 'context') + if (nextVars.length === nodeVar.vars.length) + return nodeVar + return { + ...nodeVar, + vars: nextVars, + } + }) + .filter(nodeVar => nodeVar.vars.length > 0) + : availableVars return { - availableVars, + availableVars: filteredAvailableVars, availableNodes, - availableNodesWithParent: [ - ...availableNodes, - ...(isDataSourceNode ? [currNode] : []), - ], + availableNodesWithParent, } } diff --git a/web/app/components/workflow/nodes/llm/components/config-prompt.tsx b/web/app/components/workflow/nodes/llm/components/config-prompt.tsx index 147e69b6e1..d88ec95f34 100644 --- a/web/app/components/workflow/nodes/llm/components/config-prompt.tsx +++ b/web/app/components/workflow/nodes/llm/components/config-prompt.tsx @@ -6,7 +6,6 @@ import * as React from 'react' import { useCallback, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { ReactSortable } from 'react-sortablejs' -import { useStoreApi } from 'reactflow' import { v4 as uuid4 } from 'uuid' import { DragHandle } from '@/app/components/base/icons/src/vender/line/others' import { @@ -18,7 +17,6 @@ import AddButton from '@/app/components/workflow/nodes/_base/components/add-butt import Editor from '@/app/components/workflow/nodes/_base/components/prompt/editor' import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars' import { cn } from '@/utils/classnames' -import { useWorkflow } from '../../../hooks' import { useStore, useWorkflowStore } from '../../../store' import { BlockEnum, EditionType, isPromptMessageContext, PromptRole, VarType } from '../../../types' import useAvailableVarList from '../../_base/hooks/use-available-var-list' @@ -26,7 +24,6 @@ import ConfigContextItem from './config-context-item' import ConfigPromptItem from './config-prompt-item' const i18nPrefix = 'nodes.llm' - type Props = { readOnly: boolean nodeId: string @@ -66,9 +63,6 @@ const ConfigPrompt: FC = ({ setControlPromptEditorRerenderKey, } = workflowStore.getState() - const store = useStoreApi() - const { getBeforeNodesInSameBranch } = useWorkflow() - const [isContextMenuOpen, setIsContextMenuOpen] = useState(false) const contextMenuTriggerRef = useRef(null) @@ -122,40 +116,21 @@ const ConfigPrompt: FC = ({ return Array.from(merged.values()) }, [availableNodesWithParent, parentAvailableNodes]) - const contextAgentNodes = useMemo(() => { - const agentNodes = mergedAvailableNodesWithParent - .filter(node => node.data.type === BlockEnum.Agent) - - const { getNodes } = store.getState() - const allNodes = getNodes() - const currentNode = allNodes.find(n => n.id === nodeId) - const parentNodeId = currentNode?.parentId - - if (parentNodeId) { - const beforeNodes = getBeforeNodesInSameBranch(parentNodeId) - const parentAgentNodes = beforeNodes - .filter(node => node.data.type === BlockEnum.Agent) - .filter(node => !agentNodes.some(n => n.id === node.id)) - - agentNodes.unshift(...parentAgentNodes) - } - - return agentNodes - }, [mergedAvailableNodesWithParent, nodeId, store, getBeforeNodesInSameBranch]) - const contextVarOptions = useMemo(() => { - return contextAgentNodes.map(node => ({ - nodeId: node.id, - title: node.data.title, - vars: [ - { - variable: 'context', - type: VarType.arrayObject, - schemaType: 'List[promptMessage]', - }, - ], - })) - }, [contextAgentNodes]) + return mergedAvailableNodesWithParent + .filter(node => node.data.type === BlockEnum.Agent || node.data.type === BlockEnum.LLM) + .map(node => ({ + nodeId: node.id, + title: node.data.title, + vars: [ + { + variable: 'context', + type: VarType.arrayObject, + schemaType: 'List[promptMessage]', + }, + ], + })) + }, [mergedAvailableNodesWithParent]) const handleChatModePromptChange = useCallback((index: number) => { return (prompt: string) => { diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx index eec880bd77..f8418cbcbf 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx @@ -33,7 +33,6 @@ import Placeholder from './placeholder' * Example: {{@agent-123.context@}} -> captures "agent-123" */ const AGENT_CONTEXT_VAR_PATTERN = /\{\{[@#]([^.@#]+)\.context[@#]\}\}/g - const DEFAULT_MENTION_CONFIG: MentionConfig = { extractor_node_id: '', output_selector: [], @@ -151,6 +150,15 @@ const MixedVariableTextInput = ({ }, {} as Record) }, [availableNodes]) + const contextNodeIds = useMemo(() => { + const ids = new Set() + availableNodes.forEach((node) => { + if (node.data.type === BlockEnum.Agent || node.data.type === BlockEnum.LLM) + ids.add(node.id) + }) + return ids + }, [availableNodes]) + type DetectedAgent = { nodeId: string name: string @@ -165,7 +173,7 @@ const MixedVariableTextInput = ({ const variablePath = match[1] const nodeId = variablePath.split('.')[0] const node = nodesByIdMap[nodeId] - if (node?.data.type === BlockEnum.Agent) { + if (node && contextNodeIds.has(nodeId)) { return { nodeId, name: node.data.title, @@ -173,20 +181,22 @@ const MixedVariableTextInput = ({ } } return null - }, [nodesByIdMap]) + }, [contextNodeIds, nodesByIdMap]) const detectedAgentFromValue: DetectedAgent | null = useMemo(() => { return detectAgentFromText(value) }, [detectAgentFromText, value]) const agentNodes = useMemo(() => { + if (!contextNodeIds.size) + return [] return availableNodes - .filter(node => node.data.type === BlockEnum.Agent) + .filter(node => contextNodeIds.has(node.id)) .map(node => ({ id: node.id, title: node.data.title, })) - }, [availableNodes]) + }, [availableNodes, contextNodeIds]) const syncExtractorPromptFromText = useCallback((text: string) => { if (!toolNodeId || !paramKey) diff --git a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx index c1d2bcbea4..2d2ce19f76 100644 --- a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx +++ b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/index.tsx @@ -49,27 +49,32 @@ const SubGraphModal: FC = ({ const toolParam = (toolNode?.data as ToolNodeType | undefined)?.tool_parameters?.[paramKey] const toolParamValue = toolParam?.value as string | undefined - const parentAgentNodes = useMemo(() => { + const parentBeforeNodes = useMemo(() => { if (!isOpen) return [] - const beforeNodes = getBeforeNodesInSameBranch(toolNodeId, workflowNodes, workflowEdges) - return beforeNodes.filter(node => node.data.type === BlockEnum.Agent) + return getBeforeNodesInSameBranch(toolNodeId, workflowNodes, workflowEdges) }, [getBeforeNodesInSameBranch, isOpen, toolNodeId, workflowEdges, workflowNodes]) - const parentAgentNodeIds = useMemo(() => { - return parentAgentNodes.map(node => node.id) - }, [parentAgentNodes]) + const parentContextNodes = useMemo(() => { + if (!parentBeforeNodes.length) + return [] + return parentBeforeNodes.filter(node => node.data.type === BlockEnum.Agent || node.data.type === BlockEnum.LLM) + }, [parentBeforeNodes]) + + const parentContextNodeIds = useMemo(() => { + return parentContextNodes.map(node => node.id) + }, [parentContextNodes]) const parentAvailableVars = useMemo(() => { - if (!parentAgentNodeIds.length) + if (!parentContextNodeIds.length) return [] const vars = getNodeAvailableVars({ - beforeNodes: parentAgentNodes, + beforeNodes: parentContextNodes, isChatMode, filterVar: () => true, }) - return vars.filter(nodeVar => parentAgentNodeIds.includes(nodeVar.nodeId)) - }, [getNodeAvailableVars, isChatMode, parentAgentNodeIds, parentAgentNodes]) + return vars.filter(nodeVar => parentContextNodeIds.includes(nodeVar.nodeId)) + }, [getNodeAvailableVars, isChatMode, parentContextNodeIds, parentContextNodes]) const mentionConfig = useMemo(() => { const current = toolParam?.mention_config @@ -240,7 +245,7 @@ const SubGraphModal: FC = ({ onMentionConfigChange={handleMentionConfigChange} extractorNode={extractorNode} toolParamValue={toolParamValue} - parentAvailableNodes={parentAgentNodes} + parentAvailableNodes={parentContextNodes} parentAvailableVars={parentAvailableVars} onSave={handleSave} onSyncWorkflowDraft={doSyncWorkflowDraft} From 691554ad1cae548cd4c5897fc721ad562f34c2a7 Mon Sep 17 00:00:00 2001 From: zhsama Date: Thu, 15 Jan 2026 23:32:14 +0800 Subject: [PATCH 60/82] =?UTF-8?q?feat:=20=E5=B1=95=E7=A4=BA@agent=E5=BC=95?= =?UTF-8?q?=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workflow/hooks/use-checklist.ts | 6 +- .../components/workflow/nodes/tool/node.tsx | 145 +++++++++++++++++- 2 files changed, 145 insertions(+), 6 deletions(-) diff --git a/web/app/components/workflow/hooks/use-checklist.ts b/web/app/components/workflow/hooks/use-checklist.ts index 5a9e4dacb7..a721951390 100644 --- a/web/app/components/workflow/hooks/use-checklist.ts +++ b/web/app/components/workflow/hooks/use-checklist.ts @@ -197,7 +197,8 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => { // Start nodes and Trigger nodes should not show unConnected error if they have validation errors // or if they are valid start nodes (even without incoming connections) const isStartNodeMeta = nodesExtraData?.[node.data.type as BlockEnum]?.metaData.isStart ?? false - const canSkipConnectionCheck = shouldCheckStartNode ? isStartNodeMeta : true + const isSubGraphNode = Boolean((node.data as { parent_node_id?: string }).parent_node_id) + const canSkipConnectionCheck = isSubGraphNode || (shouldCheckStartNode ? isStartNodeMeta : true) const isUnconnected = !validNodes.find(n => n.id === node.id) const shouldShowError = errorMessage || (isUnconnected && !canSkipConnectionCheck) @@ -390,7 +391,8 @@ export const useChecklistBeforePublish = () => { } const isStartNodeMeta = nodesExtraData?.[node.data.type as BlockEnum]?.metaData.isStart ?? false - const canSkipConnectionCheck = shouldCheckStartNode ? isStartNodeMeta : true + const isSubGraphNode = Boolean((node.data as { parent_node_id?: string }).parent_node_id) + const canSkipConnectionCheck = isSubGraphNode || (shouldCheckStartNode ? isStartNodeMeta : true) const isUnconnected = !validNodes.find(n => n.id === node.id) if (isUnconnected && !canSkipConnectionCheck) { diff --git a/web/app/components/workflow/nodes/tool/node.tsx b/web/app/components/workflow/nodes/tool/node.tsx index 0cf4f0ff58..9df7eccee3 100644 --- a/web/app/components/workflow/nodes/tool/node.tsx +++ b/web/app/components/workflow/nodes/tool/node.tsx @@ -1,17 +1,35 @@ import type { FC } from 'react' import type { ToolNodeType } from './types' -import type { NodeProps } from '@/app/components/workflow/types' +import type { Node, NodeProps } from '@/app/components/workflow/types' +import { BlockEnum } from '@/app/components/workflow/types' +import type { AgentNodeType } from '@/app/components/workflow/nodes/agent/types' import * as React from 'react' -import { useEffect } from 'react' +import { useEffect, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { useNodes } from 'reactflow' +import AlertTriangle from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback/AlertTriangle' import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import BlockIcon from '@/app/components/workflow/block-icon' +import { useNodesMetaData } from '@/app/components/workflow/hooks' import { useNodeDataUpdate } from '@/app/components/workflow/hooks/use-node-data-update' import { useNodePluginInstallation } from '@/app/components/workflow/hooks/use-node-plugin-installation' import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button' +import { useGetLanguage } from '@/context/i18n' +import { useStrategyProviders } from '@/service/use-strategy' +import { cn } from '@/utils/classnames' +import { VarType } from './types' + +const AGENT_CONTEXT_VAR_PATTERN = /\{\{[@#]([^.@#]+)\.context[@#]\}\}/g const Node: FC> = ({ id, data, }) => { + const { t } = useTranslation() + const language = useGetLanguage() + const { nodesMap: nodesMetaDataMap } = useNodesMetaData() + const { data: strategyProviders } = useStrategyProviders() + const nodes = useNodes() const { tool_configurations, paramSchemas } = data const toolConfigs = Object.keys(tool_configurations || {}) const { @@ -38,9 +56,104 @@ const Node: FC> = ({ }) }, [data._pluginInstallLocked, data._dimmed, handleNodeDataUpdate, id, shouldDim, shouldLock]) - const hasConfigs = toolConfigs.length > 0 + const nodesById = useMemo(() => { + return nodes.reduce((acc, node) => { + acc[node.id] = node + return acc + }, {} as Record) + }, [nodes]) - if (!showInstallButton && !hasConfigs) + const mentionEntries = useMemo(() => { + const entries: Array<{ agentNodeId: string, extractorNodeId?: string }> = [] + const toolParams = data.tool_parameters || {} + Object.entries(toolParams).forEach(([paramKey, param]) => { + const value = param?.value + if (typeof value !== 'string') + return + const matches = value.matchAll(AGENT_CONTEXT_VAR_PATTERN) + for (const match of matches) { + const agentNodeId = match[1] + if (!agentNodeId) + continue + entries.push({ + agentNodeId, + extractorNodeId: param?.mention_config?.extractor_node_id + || (param?.type === VarType.mention ? `${id}_ext_${paramKey}` : undefined), + }) + } + }) + return entries + }, [data.tool_parameters, id]) + + const referenceItems = useMemo(() => { + if (!mentionEntries.length) + return [] + + const items: Array<{ key: string, label: string, type: BlockEnum, hasWarning: boolean }> = [] + const seen = new Set() + const getNodeWarning = (node?: Node) => { + if (!node) + return true + const validator = nodesMetaDataMap?.[node.data.type as BlockEnum]?.checkValid + if (!validator) + return false + let moreDataForCheckValid: any + if (node.data.type === BlockEnum.Agent) { + const agentData = node.data as AgentNodeType + const isReadyForCheckValid = !!strategyProviders + const provider = strategyProviders?.find(provider => provider.declaration.identity.name === agentData.agent_strategy_provider_name) + const strategy = provider?.declaration.strategies?.find(s => s.identity.name === agentData.agent_strategy_name) + moreDataForCheckValid = { + provider, + strategy, + language, + isReadyForCheckValid, + } + } + const { errorMessage } = validator(node.data as any, t, moreDataForCheckValid) + return Boolean(errorMessage) + } + const pushItem = (key: string, label: string, type: BlockEnum, hasWarning: boolean) => { + if (seen.has(key)) + return + seen.add(key) + items.push({ + key, + label, + type, + hasWarning, + }) + } + mentionEntries.forEach(({ agentNodeId, extractorNodeId }) => { + if (extractorNodeId) { + const extractorNode = nodesById[extractorNodeId] + if (extractorNode) { + pushItem( + extractorNode.id, + extractorNode.data.title || t(`blocks.${extractorNode.data.type}`, { ns: 'workflow' }), + extractorNode.data.type as BlockEnum, + getNodeWarning(extractorNode), + ) + } + } + + const agentNode = nodesById[agentNodeId] + const agentLabel = `@${agentNode?.data.title || agentNodeId}` + pushItem( + `agent-${agentNodeId}`, + agentLabel, + BlockEnum.Agent, + getNodeWarning(agentNode), + ) + }) + + return items + }, [mentionEntries, nodesById, nodesMetaDataMap, strategyProviders, language, t]) + + const hasConfigs = toolConfigs.length > 0 + const hasReferences = referenceItems.length > 0 + + if (!showInstallButton && !hasConfigs && !hasReferences) return null return ( @@ -86,6 +199,30 @@ const Node: FC> = ({ ))}
)} + {hasReferences && ( +
+ {referenceItems.map(item => ( +
+
+ + + {item.label} + +
+ {item.hasWarning && ( + + )} +
+ ))} +
+ )}
) } From b21875eaafcf5d5596298650acc3990c020edc86 Mon Sep 17 00:00:00 2001 From: zhsama Date: Fri, 16 Jan 2026 00:08:51 +0800 Subject: [PATCH 61/82] fix: simplify @llm warning --- .../components/workflow/nodes/tool/node.tsx | 52 +++++++------------ 1 file changed, 20 insertions(+), 32 deletions(-) diff --git a/web/app/components/workflow/nodes/tool/node.tsx b/web/app/components/workflow/nodes/tool/node.tsx index 9df7eccee3..55f75afdfb 100644 --- a/web/app/components/workflow/nodes/tool/node.tsx +++ b/web/app/components/workflow/nodes/tool/node.tsx @@ -89,8 +89,6 @@ const Node: FC> = ({ if (!mentionEntries.length) return [] - const items: Array<{ key: string, label: string, type: BlockEnum, hasWarning: boolean }> = [] - const seen = new Set() const getNodeWarning = (node?: Node) => { if (!node) return true @@ -113,41 +111,31 @@ const Node: FC> = ({ const { errorMessage } = validator(node.data as any, t, moreDataForCheckValid) return Boolean(errorMessage) } - const pushItem = (key: string, label: string, type: BlockEnum, hasWarning: boolean) => { - if (seen.has(key)) - return - seen.add(key) - items.push({ - key, - label, - type, - hasWarning, - }) - } - mentionEntries.forEach(({ agentNodeId, extractorNodeId }) => { - if (extractorNodeId) { - const extractorNode = nodesById[extractorNodeId] - if (extractorNode) { - pushItem( - extractorNode.id, - extractorNode.data.title || t(`blocks.${extractorNode.data.type}`, { ns: 'workflow' }), - extractorNode.data.type as BlockEnum, - getNodeWarning(extractorNode), - ) - } - } + const itemsMap = new Map() + + mentionEntries.forEach(({ agentNodeId, extractorNodeId }) => { const agentNode = nodesById[agentNodeId] const agentLabel = `@${agentNode?.data.title || agentNodeId}` - pushItem( - `agent-${agentNodeId}`, - agentLabel, - BlockEnum.Agent, - getNodeWarning(agentNode), - ) + const agentWarning = getNodeWarning(agentNode) + + const extractorWarning = extractorNodeId + ? getNodeWarning(nodesById[extractorNodeId]) + : false + + const key = `agent-${agentNodeId}` + const existing = itemsMap.get(key) + const hasWarning = (existing?.hasWarning || false) || agentWarning || extractorWarning + + itemsMap.set(key, { + key, + label: agentLabel, + type: BlockEnum.Agent, + hasWarning, + }) }) - return items + return Array.from(itemsMap.values()) }, [mentionEntries, nodesById, nodesMetaDataMap, strategyProviders, language, t]) const hasConfigs = toolConfigs.length > 0 From 0f3156dfbef7ec64da652377398547d2cc1cb2e3 Mon Sep 17 00:00:00 2001 From: zhsama Date: Fri, 16 Jan 2026 00:19:28 +0800 Subject: [PATCH 62/82] fix: list multiple @mentions --- .../components/workflow/nodes/tool/node.tsx | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/web/app/components/workflow/nodes/tool/node.tsx b/web/app/components/workflow/nodes/tool/node.tsx index 55f75afdfb..6a7c51b74a 100644 --- a/web/app/components/workflow/nodes/tool/node.tsx +++ b/web/app/components/workflow/nodes/tool/node.tsx @@ -64,7 +64,8 @@ const Node: FC> = ({ }, [nodes]) const mentionEntries = useMemo(() => { - const entries: Array<{ agentNodeId: string, extractorNodeId?: string }> = [] + const entries: Array<{ agentNodeId: string, extractorNodeId?: string, paramKey: string }> = [] + const seen = new Set() const toolParams = data.tool_parameters || {} Object.entries(toolParams).forEach(([paramKey, param]) => { const value = param?.value @@ -75,8 +76,13 @@ const Node: FC> = ({ const agentNodeId = match[1] if (!agentNodeId) continue + const entryKey = `${paramKey}:${agentNodeId}` + if (seen.has(entryKey)) + continue + seen.add(entryKey) entries.push({ agentNodeId, + paramKey, extractorNodeId: param?.mention_config?.extractor_node_id || (param?.type === VarType.mention ? `${id}_ext_${paramKey}` : undefined), }) @@ -112,9 +118,7 @@ const Node: FC> = ({ return Boolean(errorMessage) } - const itemsMap = new Map() - - mentionEntries.forEach(({ agentNodeId, extractorNodeId }) => { + return mentionEntries.map(({ agentNodeId, extractorNodeId, paramKey }) => { const agentNode = nodesById[agentNodeId] const agentLabel = `@${agentNode?.data.title || agentNodeId}` const agentWarning = getNodeWarning(agentNode) @@ -122,20 +126,14 @@ const Node: FC> = ({ const extractorWarning = extractorNodeId ? getNodeWarning(nodesById[extractorNodeId]) : false - - const key = `agent-${agentNodeId}` - const existing = itemsMap.get(key) - const hasWarning = (existing?.hasWarning || false) || agentWarning || extractorWarning - - itemsMap.set(key, { - key, + const hasWarning = agentWarning || extractorWarning + return { + key: `${paramKey}-${agentNodeId}-${extractorNodeId || 'no-extractor'}`, label: agentLabel, type: BlockEnum.Agent, hasWarning, - }) + } }) - - return Array.from(itemsMap.values()) }, [mentionEntries, nodesById, nodesMetaDataMap, strategyProviders, language, t]) const hasConfigs = toolConfigs.length > 0 From a7826d9ea473fa98e8fbc64aa247610e0ee56ab8 Mon Sep 17 00:00:00 2001 From: Novice Date: Fri, 16 Jan 2026 11:28:49 +0800 Subject: [PATCH 63/82] feat: agent add context --- api/core/memory/README.md | 353 +++++------------- api/core/memory/__init__.py | 4 - api/core/memory/node_token_buffer_memory.py | 311 ++++----------- .../entities/message_entities.py | 6 +- api/core/workflow/nodes/agent/agent_node.py | 113 +++++- api/core/workflow/nodes/base/node.py | 4 +- api/core/workflow/nodes/llm/llm_utils.py | 93 ++--- api/core/workflow/nodes/llm/node.py | 231 +++++++----- .../parameter_extractor_node.py | 11 +- .../question_classifier_node.py | 13 +- 10 files changed, 458 insertions(+), 681 deletions(-) diff --git a/api/core/memory/README.md b/api/core/memory/README.md index ba8f743125..055ce0fe3b 100644 --- a/api/core/memory/README.md +++ b/api/core/memory/README.md @@ -7,7 +7,7 @@ This module provides memory management for LLM conversations, enabling context r The memory module contains two types of memory implementations: 1. **TokenBufferMemory** - Conversation-level memory (existing) -2. **NodeTokenBufferMemory** - Node-level memory (to be implemented, **Chatflow only**) +2. **NodeTokenBufferMemory** - Node-level memory (**Chatflow only**) > **Note**: `NodeTokenBufferMemory` is only available in **Chatflow** (advanced-chat mode). > This is because it requires both `conversation_id` and `node_id`, which are only present in Chatflow. @@ -28,8 +28,8 @@ The memory module contains two types of memory implementations: │ ┌─────────────────────────────────────────────────────────────────────-┐ │ │ │ NodeTokenBufferMemory │ │ │ │ Scope: Node within Conversation │ │ -│ │ Storage: Object Storage (JSON file) │ │ -│ │ Key: (app_id, conversation_id, node_id) │ │ +│ │ Storage: WorkflowNodeExecutionModel.outputs["context"] │ │ +│ │ Key: (conversation_id, node_id, workflow_run_id) │ │ │ └─────────────────────────────────────────────────────────────────────-┘ │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ @@ -98,7 +98,7 @@ history = memory.get_history_prompt_messages(max_token_limit=2000, message_limit --- -## NodeTokenBufferMemory (To Be Implemented) +## NodeTokenBufferMemory ### Purpose @@ -110,114 +110,69 @@ history = memory.get_history_prompt_messages(max_token_limit=2000, message_limit 2. **Iterative Processing**: An LLM node in a loop needs to accumulate context across iterations 3. **Specialized Agents**: Each agent node maintains its own dialogue history -### Design Decisions +### Design: Zero Extra Storage -#### Storage: Object Storage for Messages (No New Database Table) +**Key insight**: LLM node already saves complete context in `outputs["context"]`. -| Aspect | Database | Object Storage | -| ------------------------- | -------------------- | ------------------ | -| Cost | High | Low | -| Query Flexibility | High | Low | -| Schema Changes | Migration required | None | -| Consistency with existing | ConversationVariable | File uploads, logs | - -**Decision**: Store message data in object storage, but still use existing database tables for file metadata. - -**What is stored in Object Storage:** - -- Message content (text) -- Message metadata (role, token_count, created_at) -- File references (upload_file_id, tool_file_id, etc.) -- Thread relationships (message_id, parent_message_id) - -**What still requires Database queries:** - -- File reconstruction: When reading node memory, file references are used to query - `UploadFile` / `ToolFile` tables via `file_factory.build_from_mapping()` to rebuild - complete `File` objects with storage_key, mime_type, etc. - -**Why this hybrid approach:** - -- No database migration required (no new tables) -- Message data may be large, object storage is cost-effective -- File metadata is already in database, no need to duplicate -- Aligns with existing storage patterns (file uploads, logs) - -#### Storage Key Format - -``` -node_memory/{app_id}/{conversation_id}/{node_id}.json -``` - -#### Data Structure - -```json -{ - "version": 1, - "messages": [ - { - "message_id": "msg-001", - "parent_message_id": null, - "role": "user", - "content": "Analyze this image", - "files": [ - { - "type": "image", - "transfer_method": "local_file", - "upload_file_id": "file-uuid-123", - "belongs_to": "user" - } - ], - "token_count": 15, - "created_at": "2026-01-07T10:00:00Z" - }, - { - "message_id": "msg-002", - "parent_message_id": "msg-001", - "role": "assistant", - "content": "This is a landscape image...", - "files": [], - "token_count": 50, - "created_at": "2026-01-07T10:00:01Z" - } - ] +Each LLM node execution outputs: +```python +outputs = { + "text": clean_text, + "context": self._build_context(prompt_messages, clean_text), # Complete dialogue history! + ... } ``` -### Thread Support +This `outputs["context"]` contains: +- All previous user/assistant messages (excluding system prompt) +- The current assistant response -Node memory also supports thread extraction (for regeneration scenarios): +**No separate storage needed** - we just read from the last execution's `outputs["context"]`. -```python -def _extract_thread( - self, - messages: list[NodeMemoryMessage], - current_message_id: str -) -> list[NodeMemoryMessage]: - """ - Extract messages belonging to the thread of current_message_id. - Similar to extract_thread_messages() in TokenBufferMemory. - """ - ... +### Benefits + +| Aspect | Old Design (Object Storage) | New Design (outputs["context"]) | +|--------|----------------------------|--------------------------------| +| Storage | Separate JSON file | Already in WorkflowNodeExecutionModel | +| Concurrency | Race condition risk | No issue (each execution is INSERT) | +| Cleanup | Need separate cleanup task | Follows node execution lifecycle | +| Migration | Required | None | +| Complexity | High | Low | + +### Data Flow + +``` +WorkflowNodeExecutionModel NodeTokenBufferMemory LLM Node + │ │ │ + │ │◀── get_history_prompt_messages() + │ │ │ + │ SELECT outputs FROM │ │ + │ workflow_node_executions │ │ + │ WHERE workflow_run_id = ? │ │ + │ AND node_id = ? │ │ + │◀─────────────────────────────────┤ │ + │ │ │ + │ outputs["context"] │ │ + ├─────────────────────────────────▶│ │ + │ │ │ + │ deserialize PromptMessages │ + │ │ │ + │ truncate by max_token_limit │ + │ │ │ + │ │ Sequence[PromptMessage] │ + │ ├──────────────────────────▶│ + │ │ │ ``` -### File Handling +### Thread Tracking -Files are stored as references (not full metadata): +Thread extraction still uses `Message` table's `parent_message_id` structure: -```python -class NodeMemoryFile(BaseModel): - type: str # image, audio, video, document, custom - transfer_method: str # local_file, remote_url, tool_file - upload_file_id: str | None # for local_file - tool_file_id: str | None # for tool_file - url: str | None # for remote_url - belongs_to: str # user / assistant -``` +1. Query `Message` table for conversation → get thread's `workflow_run_ids` +2. Get the last completed `workflow_run_id` in the thread +3. Query `WorkflowNodeExecutionModel` for that execution's `outputs["context"]` -When reading, files are rebuilt using `file_factory.build_from_mapping()`. - -### API Design +### API ```python class NodeTokenBufferMemory: @@ -226,160 +181,29 @@ class NodeTokenBufferMemory: app_id: str, conversation_id: str, node_id: str, + tenant_id: str, model_instance: ModelInstance, ): - """ - Initialize node-level memory. - - :param app_id: Application ID - :param conversation_id: Conversation ID - :param node_id: Node ID in the workflow - :param model_instance: Model instance for token counting - """ - ... - - def add_messages( - self, - message_id: str, - parent_message_id: str | None, - user_content: str, - user_files: Sequence[File], - assistant_content: str, - assistant_files: Sequence[File], - ) -> None: - """ - Append a dialogue turn (user + assistant) to node memory. - Call this after LLM node execution completes. - - :param message_id: Current message ID (from Message table) - :param parent_message_id: Parent message ID (for thread tracking) - :param user_content: User's text input - :param user_files: Files attached by user - :param assistant_content: Assistant's text response - :param assistant_files: Files generated by assistant - """ + """Initialize node-level memory.""" ... def get_history_prompt_messages( self, - current_message_id: str, - tenant_id: str, + *, max_token_limit: int = 2000, - file_upload_config: FileUploadConfig | None = None, + message_limit: int | None = None, ) -> Sequence[PromptMessage]: """ Retrieve history as PromptMessage sequence. - - :param current_message_id: Current message ID (for thread extraction) - :param tenant_id: Tenant ID (for file reconstruction) - :param max_token_limit: Maximum tokens for history - :param file_upload_config: File upload configuration - :return: Sequence of PromptMessage for LLM context + + Reads from last completed execution's outputs["context"]. """ ... - def flush(self) -> None: - """ - Persist buffered changes to object storage. - Call this at the end of node execution. - """ - ... - - def clear(self) -> None: - """ - Clear all messages in this node's memory. - """ - ... -``` - -### Data Flow - -``` -Object Storage NodeTokenBufferMemory LLM Node - │ │ │ - │ │◀── get_history_prompt_messages() - │ storage.load(key) │ │ - │◀─────────────────────────────────┤ │ - │ │ │ - │ JSON data │ │ - ├─────────────────────────────────▶│ │ - │ │ │ - │ _extract_thread() │ - │ │ │ - │ _rebuild_files() via file_factory │ - │ │ │ - │ _build_prompt_messages() │ - │ │ │ - │ _truncate_by_tokens() │ - │ │ │ - │ │ Sequence[PromptMessage] │ - │ ├──────────────────────────▶│ - │ │ │ - │ │◀── LLM execution complete │ - │ │ │ - │ │◀── add_messages() │ - │ │ │ - │ storage.save(key, data) │ │ - │◀─────────────────────────────────┤ │ - │ │ │ -``` - -### Integration with LLM Node - -```python -# In LLM Node execution - -# 1. Fetch memory based on mode -if node_data.memory and node_data.memory.mode == MemoryMode.NODE: - # Node-level memory (Chatflow only) - memory = fetch_node_memory( - variable_pool=variable_pool, - app_id=app_id, - node_id=self.node_id, - node_data_memory=node_data.memory, - model_instance=model_instance, - ) -elif node_data.memory and node_data.memory.mode == MemoryMode.CONVERSATION: - # Conversation-level memory (existing behavior) - memory = fetch_memory( - variable_pool=variable_pool, - app_id=app_id, - node_data_memory=node_data.memory, - model_instance=model_instance, - ) -else: - memory = None - -# 2. Get history for context -if memory: - if isinstance(memory, NodeTokenBufferMemory): - history = memory.get_history_prompt_messages( - current_message_id=current_message_id, - tenant_id=tenant_id, - max_token_limit=max_token_limit, - ) - else: # TokenBufferMemory - history = memory.get_history_prompt_messages( - max_token_limit=max_token_limit, - ) - prompt_messages = [*history, *current_messages] -else: - prompt_messages = current_messages - -# 3. Call LLM -response = model_instance.invoke(prompt_messages) - -# 4. Append to node memory (only for NodeTokenBufferMemory) -if isinstance(memory, NodeTokenBufferMemory): - memory.add_messages( - message_id=message_id, - parent_message_id=parent_message_id, - user_content=user_input, - user_files=user_files, - assistant_content=response.content, - assistant_files=response_files, - ) - memory.flush() + # Legacy methods (no-op, kept for compatibility) + def add_messages(self, *args, **kwargs) -> None: pass + def flush(self) -> None: pass + def clear(self) -> None: pass ``` ### Configuration @@ -388,16 +212,13 @@ Add to `MemoryConfig` in `core/workflow/nodes/llm/entities.py`: ```python class MemoryMode(StrEnum): - CONVERSATION = "conversation" # Use TokenBufferMemory (default, existing behavior) - NODE = "node" # Use NodeTokenBufferMemory (new, Chatflow only) + CONVERSATION = "conversation" # Use TokenBufferMemory (default) + NODE = "node" # Use NodeTokenBufferMemory (Chatflow only) class MemoryConfig(BaseModel): - # Existing fields role_prefix: RolePrefix | None = None window: MemoryWindowConfig | None = None query_prompt_template: str | None = None - - # Memory mode (new) mode: MemoryMode = MemoryMode.CONVERSATION ``` @@ -408,27 +229,39 @@ class MemoryConfig(BaseModel): | `conversation` | TokenBufferMemory | Entire conversation | All app modes | | `node` | NodeTokenBufferMemory | Per-node in conversation | Chatflow only | -> When `mode=node` is used in a non-Chatflow context (no conversation_id), it should -> fall back to no memory or raise a configuration error. +> When `mode=node` is used in a non-Chatflow context (no conversation_id), it falls back to no memory. --- ## Comparison -| Feature | TokenBufferMemory | NodeTokenBufferMemory | -| -------------- | ------------------------ | ------------------------- | -| Scope | Conversation | Node within Conversation | -| Storage | Database (Message table) | Object Storage (JSON) | -| Thread Support | Yes | Yes | -| File Support | Yes (via MessageFile) | Yes (via file references) | -| Token Limit | Yes | Yes | -| Use Case | Standard chat apps | Complex workflows | +| Feature | TokenBufferMemory | NodeTokenBufferMemory | +| -------------- | ------------------------ | ---------------------------------- | +| Scope | Conversation | Node within Conversation | +| Storage | Database (Message table) | WorkflowNodeExecutionModel.outputs | +| Thread Support | Yes | Yes | +| File Support | Yes (via MessageFile) | Yes (via context serialization) | +| Token Limit | Yes | Yes | +| Use Case | Standard chat apps | Complex workflows | + +--- + +## Extending to Other Nodes + +Currently only **LLM Node** outputs `context` in its outputs. To enable node memory for other nodes: + +1. Add `outputs["context"] = self._build_context(prompt_messages, response)` in the node +2. The `NodeTokenBufferMemory` will automatically pick it up + +Nodes that could potentially support this: +- `question_classifier` +- `parameter_extractor` +- `agent` --- ## Future Considerations -1. **Cleanup Task**: Add a Celery task to clean up old node memory files -2. **Concurrency**: Consider Redis lock for concurrent node executions -3. **Compression**: Compress large memory files to reduce storage costs -4. **Extension**: Other nodes (Agent, Tool) may also benefit from node-level memory +1. **Cleanup**: Node memory lifecycle follows `WorkflowNodeExecutionModel`, which already has cleanup mechanisms +2. **Compression**: For very long conversations, consider summarization strategies +3. **Extension**: Other nodes may benefit from node-level memory diff --git a/api/core/memory/__init__.py b/api/core/memory/__init__.py index 4baef1a835..d0e2babde2 100644 --- a/api/core/memory/__init__.py +++ b/api/core/memory/__init__.py @@ -1,15 +1,11 @@ from core.memory.base import BaseMemory from core.memory.node_token_buffer_memory import ( - NodeMemoryData, - NodeMemoryFile, NodeTokenBufferMemory, ) from core.memory.token_buffer_memory import TokenBufferMemory __all__ = [ "BaseMemory", - "NodeMemoryData", - "NodeMemoryFile", "NodeTokenBufferMemory", "TokenBufferMemory", ] diff --git a/api/core/memory/node_token_buffer_memory.py b/api/core/memory/node_token_buffer_memory.py index bc38c953eb..386dde9c89 100644 --- a/api/core/memory/node_token_buffer_memory.py +++ b/api/core/memory/node_token_buffer_memory.py @@ -8,73 +8,44 @@ Note: This is only available in Chatflow (advanced-chat mode) because it require both conversation_id and node_id. Design: -- Storage is indexed by workflow_run_id (each execution stores one turn) +- History is read directly from WorkflowNodeExecutionModel.outputs["context"] +- No separate storage needed - the context is already saved during node execution - Thread tracking leverages Message table's parent_message_id structure -- On read: query Message table for current thread, then filter Node Memory by workflow_run_ids """ import logging from collections.abc import Sequence -from pydantic import BaseModel from sqlalchemy import select +from sqlalchemy.orm import Session -from core.file import File, FileTransferMethod from core.memory.base import BaseMemory from core.model_manager import ModelInstance from core.model_runtime.entities import ( AssistantPromptMessage, - ImagePromptMessageContent, PromptMessage, - TextPromptMessageContent, + PromptMessageRole, + SystemPromptMessage, + ToolPromptMessage, UserPromptMessage, ) from core.prompt.utils.extract_thread_messages import extract_thread_messages from extensions.ext_database import db -from extensions.ext_storage import storage from models.model import Message +from models.workflow import WorkflowNodeExecutionModel logger = logging.getLogger(__name__) -class NodeMemoryFile(BaseModel): - """File reference stored in node memory.""" - - type: str # image, audio, video, document, custom - transfer_method: str # local_file, remote_url, tool_file - upload_file_id: str | None = None - tool_file_id: str | None = None - url: str | None = None - - -class NodeMemoryTurn(BaseModel): - """A single dialogue turn (user + assistant) in node memory.""" - - user_content: str = "" - user_files: list[NodeMemoryFile] = [] - assistant_content: str = "" - assistant_files: list[NodeMemoryFile] = [] - - -class NodeMemoryData(BaseModel): - """Root data structure for node memory storage.""" - - version: int = 1 - # Key: workflow_run_id, Value: dialogue turn - turns: dict[str, NodeMemoryTurn] = {} - - class NodeTokenBufferMemory(BaseMemory): """ Node-level Token Buffer Memory. Provides node-scoped memory within a conversation. Each LLM node can maintain - its own independent conversation history, stored in object storage. + its own independent conversation history. - Key design: Thread tracking is delegated to Message table's parent_message_id. - Storage is indexed by workflow_run_id for easy filtering. - - Storage key format: node_memory/{app_id}/{conversation_id}/{node_id}.json + Key design: History is read directly from WorkflowNodeExecutionModel.outputs["context"], + which is already saved during node execution. No separate storage needed. """ def __init__( @@ -85,132 +56,25 @@ class NodeTokenBufferMemory(BaseMemory): tenant_id: str, model_instance: ModelInstance, ): - """ - Initialize node-level memory. - - :param app_id: Application ID - :param conversation_id: Conversation ID - :param node_id: Node ID in the workflow - :param tenant_id: Tenant ID for file reconstruction - :param model_instance: Model instance for token counting - """ self.app_id = app_id self.conversation_id = conversation_id self.node_id = node_id self.tenant_id = tenant_id self.model_instance = model_instance - self._storage_key = f"node_memory/{app_id}/{conversation_id}/{node_id}.json" - self._data: NodeMemoryData | None = None - self._dirty = False - - def _load(self) -> NodeMemoryData: - """Load data from object storage.""" - if self._data is not None: - return self._data - - try: - raw = storage.load_once(self._storage_key) - self._data = NodeMemoryData.model_validate_json(raw) - except Exception: - # File not found or parse error, start fresh - self._data = NodeMemoryData() - - return self._data - - def _save(self) -> None: - """Save data to object storage.""" - if self._data is not None: - storage.save(self._storage_key, self._data.model_dump_json().encode("utf-8")) - self._dirty = False - - def _file_to_memory_file(self, file: File) -> NodeMemoryFile: - """Convert File object to NodeMemoryFile reference.""" - return NodeMemoryFile( - type=file.type.value if hasattr(file.type, "value") else str(file.type), - transfer_method=( - file.transfer_method.value if hasattr(file.transfer_method, "value") else str(file.transfer_method) - ), - upload_file_id=file.related_id if file.transfer_method == FileTransferMethod.LOCAL_FILE else None, - tool_file_id=file.related_id if file.transfer_method == FileTransferMethod.TOOL_FILE else None, - url=file.remote_url if file.transfer_method == FileTransferMethod.REMOTE_URL else None, - ) - - def _memory_file_to_mapping(self, memory_file: NodeMemoryFile) -> dict: - """Convert NodeMemoryFile to mapping for file_factory.""" - mapping: dict = { - "type": memory_file.type, - "transfer_method": memory_file.transfer_method, - } - if memory_file.upload_file_id: - mapping["upload_file_id"] = memory_file.upload_file_id - if memory_file.tool_file_id: - mapping["tool_file_id"] = memory_file.tool_file_id - if memory_file.url: - mapping["url"] = memory_file.url - return mapping - - def _rebuild_files(self, memory_files: list[NodeMemoryFile]) -> list[File]: - """Rebuild File objects from NodeMemoryFile references.""" - if not memory_files: - return [] - - from factories import file_factory - - files = [] - for mf in memory_files: - try: - mapping = self._memory_file_to_mapping(mf) - file = file_factory.build_from_mapping(mapping=mapping, tenant_id=self.tenant_id) - files.append(file) - except Exception as e: - logger.warning("Failed to rebuild file from memory: %s", e) - continue - return files - - def _build_prompt_message( - self, - role: str, - content: str, - files: list[File], - detail: ImagePromptMessageContent.DETAIL = ImagePromptMessageContent.DETAIL.HIGH, - ) -> PromptMessage: - """Build PromptMessage from content and files.""" - from core.file import file_manager - - if not files: - if role == "user": - return UserPromptMessage(content=content) - else: - return AssistantPromptMessage(content=content) - - # Build multimodal content - prompt_contents: list = [] - for file in files: - try: - prompt_content = file_manager.to_prompt_message_content(file, image_detail_config=detail) - prompt_contents.append(prompt_content) - except Exception as e: - logger.warning("Failed to convert file to prompt content: %s", e) - continue - - prompt_contents.append(TextPromptMessageContent(data=content)) - - if role == "user": - return UserPromptMessage(content=prompt_contents) - else: - return AssistantPromptMessage(content=prompt_contents) def _get_thread_workflow_run_ids(self) -> list[str]: """ Get workflow_run_ids for the current thread by querying Message table. - Returns workflow_run_ids in chronological order (oldest first). """ - # Query messages for this conversation - stmt = ( - select(Message).where(Message.conversation_id == self.conversation_id).order_by(Message.created_at.desc()) - ) - messages = db.session.scalars(stmt.limit(500)).all() + with Session(db.engine, expire_on_commit=False) as session: + stmt = ( + select(Message) + .where(Message.conversation_id == self.conversation_id) + .order_by(Message.created_at.desc()) + .limit(500) + ) + messages = list(session.scalars(stmt).all()) if not messages: return [] @@ -223,46 +87,31 @@ class NodeTokenBufferMemory(BaseMemory): thread_messages.pop(0) # Reverse to get chronological order, extract workflow_run_ids - workflow_run_ids = [] - for msg in reversed(thread_messages): - if msg.workflow_run_id: - workflow_run_ids.append(msg.workflow_run_id) + return [msg.workflow_run_id for msg in reversed(thread_messages) if msg.workflow_run_id] - return workflow_run_ids + def _deserialize_prompt_message(self, msg_dict: dict) -> PromptMessage: + """Deserialize a dict to PromptMessage based on role.""" + role = msg_dict.get("role") + if role in (PromptMessageRole.USER, "user"): + return UserPromptMessage.model_validate(msg_dict) + elif role in (PromptMessageRole.ASSISTANT, "assistant"): + return AssistantPromptMessage.model_validate(msg_dict) + elif role in (PromptMessageRole.SYSTEM, "system"): + return SystemPromptMessage.model_validate(msg_dict) + elif role in (PromptMessageRole.TOOL, "tool"): + return ToolPromptMessage.model_validate(msg_dict) + else: + return PromptMessage.model_validate(msg_dict) - def add_messages( - self, - workflow_run_id: str, - user_content: str, - user_files: Sequence[File] | None = None, - assistant_content: str = "", - assistant_files: Sequence[File] | None = None, - ) -> None: - """ - Add a dialogue turn to node memory. - Call this after LLM node execution completes. - - :param workflow_run_id: Current workflow execution ID - :param user_content: User's text input - :param user_files: Files attached by user - :param assistant_content: Assistant's text response - :param assistant_files: Files generated by assistant - """ - data = self._load() - - # Convert files to memory file references - user_memory_files = [self._file_to_memory_file(f) for f in (user_files or [])] - assistant_memory_files = [self._file_to_memory_file(f) for f in (assistant_files or [])] - - # Store the turn indexed by workflow_run_id - data.turns[workflow_run_id] = NodeMemoryTurn( - user_content=user_content, - user_files=user_memory_files, - assistant_content=assistant_content, - assistant_files=assistant_memory_files, - ) - - self._dirty = True + def _deserialize_context(self, context_data: list[dict]) -> list[PromptMessage]: + """Deserialize context data from outputs to list of PromptMessage.""" + messages = [] + for msg_dict in context_data: + try: + messages.append(self._deserialize_prompt_message(msg_dict)) + except Exception as e: + logger.warning("Failed to deserialize prompt message: %s", e) + return messages def get_history_prompt_messages( self, @@ -272,55 +121,38 @@ class NodeTokenBufferMemory(BaseMemory): ) -> Sequence[PromptMessage]: """ Retrieve history as PromptMessage sequence. - - Thread tracking is handled by querying Message table's parent_message_id structure. - - :param max_token_limit: Maximum tokens for history - :param message_limit: unused, for interface compatibility - :return: Sequence of PromptMessage for LLM context + History is read directly from the last completed node execution's outputs["context"]. """ - # message_limit is unused in NodeTokenBufferMemory (uses token limit instead) - _ = message_limit - detail = ImagePromptMessageContent.DETAIL.HIGH - data = self._load() + _ = message_limit # unused, kept for interface compatibility - if not data.turns: - return [] - - # Get workflow_run_ids for current thread from Message table thread_workflow_run_ids = self._get_thread_workflow_run_ids() - if not thread_workflow_run_ids: return [] - # Build prompt messages in thread order - prompt_messages: list[PromptMessage] = [] - for wf_run_id in thread_workflow_run_ids: - turn = data.turns.get(wf_run_id) - if not turn: - # This workflow execution didn't have node memory stored - continue + # Get the last completed workflow_run_id (contains accumulated context) + last_run_id = thread_workflow_run_ids[-1] - # Build user message - user_files = self._rebuild_files(turn.user_files) if turn.user_files else [] - user_msg = self._build_prompt_message( - role="user", - content=turn.user_content, - files=user_files, - detail=detail, + with Session(db.engine, expire_on_commit=False) as session: + stmt = select(WorkflowNodeExecutionModel).where( + WorkflowNodeExecutionModel.workflow_run_id == last_run_id, + WorkflowNodeExecutionModel.node_id == self.node_id, + WorkflowNodeExecutionModel.status == "succeeded", ) - prompt_messages.append(user_msg) + execution = session.scalars(stmt).first() - # Build assistant message - assistant_files = self._rebuild_files(turn.assistant_files) if turn.assistant_files else [] - assistant_msg = self._build_prompt_message( - role="assistant", - content=turn.assistant_content, - files=assistant_files, - detail=detail, - ) - prompt_messages.append(assistant_msg) + if not execution: + return [] + outputs = execution.outputs_dict + if not outputs: + return [] + + context_data = outputs.get("context") + + if not context_data or not isinstance(context_data, list): + return [] + + prompt_messages = self._deserialize_context(context_data) if not prompt_messages: return [] @@ -334,20 +166,3 @@ class NodeTokenBufferMemory(BaseMemory): logger.warning("Failed to count tokens for truncation: %s", e) return prompt_messages - - def flush(self) -> None: - """ - Persist buffered changes to object storage. - Call this at the end of node execution. - """ - if self._dirty: - self._save() - - def clear(self) -> None: - """Clear all messages in this node's memory.""" - self._data = NodeMemoryData() - self._save() - - def exists(self) -> bool: - """Check if node memory exists in storage.""" - return storage.exists(self._storage_key) diff --git a/api/core/model_runtime/entities/message_entities.py b/api/core/model_runtime/entities/message_entities.py index 9e46d72893..5a07a22023 100644 --- a/api/core/model_runtime/entities/message_entities.py +++ b/api/core/model_runtime/entities/message_entities.py @@ -276,7 +276,5 @@ class ToolPromptMessage(PromptMessage): :return: True if prompt message is empty, False otherwise """ - if not super().is_empty() and not self.tool_call_id: - return False - - return True + # ToolPromptMessage is not empty if it has content OR has a tool_call_id + return super().is_empty() and not self.tool_call_id diff --git a/api/core/workflow/nodes/agent/agent_node.py b/api/core/workflow/nodes/agent/agent_node.py index ebbacafcff..c527c50280 100644 --- a/api/core/workflow/nodes/agent/agent_node.py +++ b/api/core/workflow/nodes/agent/agent_node.py @@ -17,6 +17,12 @@ from core.memory.node_token_buffer_memory import NodeTokenBufferMemory from core.memory.token_buffer_memory import TokenBufferMemory from core.model_manager import ModelInstance, ModelManager from core.model_runtime.entities.llm_entities import LLMUsage, LLMUsageMetadata +from core.model_runtime.entities.message_entities import ( + AssistantPromptMessage, + PromptMessage, + ToolPromptMessage, + UserPromptMessage, +) from core.model_runtime.entities.model_entities import AIModelEntity, ModelType from core.model_runtime.utils.encoders import jsonable_encoder from core.prompt.entities.advanced_prompt_entities import MemoryMode @@ -527,6 +533,95 @@ class AgentNode(Node[AgentNodeData]): # Conversation-level memory doesn't need saving here return None + def _build_context( + self, + parameters_for_log: dict[str, Any], + user_query: str, + assistant_response: str, + agent_logs: list[AgentLogEvent], + ) -> list[PromptMessage]: + """ + Build context from user query, tool calls, and assistant response. + Format: user -> assistant(with tool_calls) -> tool -> assistant + + The context includes: + - Current user query (always present, may be empty) + - Assistant message with tool_calls (if tools were called) + - Tool results + - Assistant's final response + """ + context_messages: list[PromptMessage] = [] + + # Always add user query (even if empty, to maintain conversation structure) + context_messages.append(UserPromptMessage(content=user_query or "")) + + # Extract actual tool calls from agent logs + # Only include logs with label starting with "CALL " - these are real tool invocations + tool_calls: list[AssistantPromptMessage.ToolCall] = [] + tool_results: list[tuple[str, str, str]] = [] # (tool_call_id, tool_name, result) + + for log in agent_logs: + if log.status == "success" and log.label and log.label.startswith("CALL "): + # Extract tool name from label (format: "CALL tool_name") + tool_name = log.label[5:] # Remove "CALL " prefix + tool_call_id = log.message_id + + # Parse tool response from data + data = log.data or {} + tool_response = "" + + # Try to extract the actual tool response + if "tool_response" in data: + tool_response = data["tool_response"] + elif "output" in data: + tool_response = data["output"] + elif "result" in data: + tool_response = data["result"] + + if isinstance(tool_response, dict): + tool_response = str(tool_response) + + # Get tool input for arguments + tool_input = data.get("tool_call_input", {}) or data.get("input", {}) + if isinstance(tool_input, dict): + import json + + tool_input_str = json.dumps(tool_input, ensure_ascii=False) + else: + tool_input_str = str(tool_input) if tool_input else "" + + if tool_response: + tool_calls.append( + AssistantPromptMessage.ToolCall( + id=tool_call_id, + type="function", + function=AssistantPromptMessage.ToolCall.ToolCallFunction( + name=tool_name, + arguments=tool_input_str, + ), + ) + ) + tool_results.append((tool_call_id, tool_name, str(tool_response))) + + # Add assistant message with tool_calls if there were tool calls + if tool_calls: + context_messages.append(AssistantPromptMessage(content="", tool_calls=tool_calls)) + + # Add tool result messages + for tool_call_id, tool_name, result in tool_results: + context_messages.append( + ToolPromptMessage( + content=result, + tool_call_id=tool_call_id, + name=tool_name, + ) + ) + + # Add final assistant response + context_messages.append(AssistantPromptMessage(content=assistant_response)) + + return context_messages + def _transform_message( self, messages: Generator[ToolInvokeMessage, None, None], @@ -782,20 +877,11 @@ class AgentNode(Node[AgentNodeData]): is_final=True, ) - # Save to node memory if in node memory mode - from core.workflow.nodes.llm import llm_utils + # Get user query from parameters for building context + user_query = parameters_for_log.get("query", "") - # Get user query from sys.query - user_query_var = self.graph_runtime_state.variable_pool.get(["sys", SystemVariableKey.QUERY]) - user_query = user_query_var.text if user_query_var else "" - - llm_utils.save_node_memory( - memory=memory, - variable_pool=self.graph_runtime_state.variable_pool, - user_query=user_query, - assistant_response=text, - assistant_files=files, - ) + # Build context from history, user query, tool calls and assistant response + context = self._build_context(parameters_for_log, user_query, text, agent_logs) yield StreamCompletedEvent( node_run_result=NodeRunResult( @@ -805,6 +891,7 @@ class AgentNode(Node[AgentNodeData]): "usage": jsonable_encoder(llm_usage), "files": ArrayFileSegment(value=files), "json": json_output, + "context": context, **variables, }, metadata={ diff --git a/api/core/workflow/nodes/base/node.py b/api/core/workflow/nodes/base/node.py index d4a8a92569..87bebf413c 100644 --- a/api/core/workflow/nodes/base/node.py +++ b/api/core/workflow/nodes/base/node.py @@ -285,7 +285,7 @@ class Node(Generic[NodeDataT]): extractor_configs.append(node_config) return extractor_configs - def _execute_extractor_nodes(self) -> Generator[GraphNodeEventBase, None, None]: + def _execute_mention_nodes(self) -> Generator[GraphNodeEventBase, None, None]: """ Execute all extractor nodes associated with this node. @@ -349,7 +349,7 @@ class Node(Generic[NodeDataT]): self._start_at = naive_utc_now() # Step 1: Execute associated extractor nodes before main node execution - yield from self._execute_extractor_nodes() + yield from self._execute_mention_nodes() # Create and push start event with required fields start_event = NodeRunStartedEvent( diff --git a/api/core/workflow/nodes/llm/llm_utils.py b/api/core/workflow/nodes/llm/llm_utils.py index 9b170a237b..1b412df0ea 100644 --- a/api/core/workflow/nodes/llm/llm_utils.py +++ b/api/core/workflow/nodes/llm/llm_utils.py @@ -12,6 +12,13 @@ from core.memory import NodeTokenBufferMemory, TokenBufferMemory from core.memory.base import BaseMemory from core.model_manager import ModelInstance, ModelManager from core.model_runtime.entities.llm_entities import LLMUsage +from core.model_runtime.entities.message_entities import ( + AssistantPromptMessage, + MultiModalPromptMessageContent, + PromptMessage, + PromptMessageContentUnionTypes, + PromptMessageRole, +) from core.model_runtime.entities.model_entities import ModelType from core.model_runtime.model_providers.__base.large_language_model import LargeLanguageModel from core.prompt.entities.advanced_prompt_entities import MemoryConfig, MemoryMode @@ -139,50 +146,6 @@ def fetch_memory( return TokenBufferMemory(conversation=conversation, model_instance=model_instance) -def save_node_memory( - memory: BaseMemory | None, - variable_pool: VariablePool, - user_query: str, - assistant_response: str, - user_files: Sequence["File"] | None = None, - assistant_files: Sequence["File"] | None = None, -) -> None: - """ - Save dialogue turn to node memory if applicable. - - This function handles the storage logic for NodeTokenBufferMemory. - For TokenBufferMemory (conversation-level), no action is taken as it uses - the Message table which is managed elsewhere. - - :param memory: Memory instance (NodeTokenBufferMemory or TokenBufferMemory) - :param variable_pool: Variable pool containing system variables - :param user_query: User's input text - :param assistant_response: Assistant's response text - :param user_files: Files attached by user (optional) - :param assistant_files: Files generated by assistant (optional) - """ - if not isinstance(memory, NodeTokenBufferMemory): - return - - # Get workflow_run_id as the key for this execution - workflow_run_id_var = variable_pool.get(["sys", SystemVariableKey.WORKFLOW_EXECUTION_ID]) - if not isinstance(workflow_run_id_var, StringSegment): - return - - workflow_run_id = workflow_run_id_var.value - if not workflow_run_id: - return - - memory.add_messages( - workflow_run_id=workflow_run_id, - user_content=user_query, - user_files=list(user_files) if user_files else None, - assistant_content=assistant_response, - assistant_files=list(assistant_files) if assistant_files else None, - ) - memory.flush() - - def deduct_llm_quota(tenant_id: str, model_instance: ModelInstance, usage: LLMUsage): provider_model_bundle = model_instance.provider_model_bundle provider_configuration = provider_model_bundle.configuration @@ -246,3 +209,45 @@ def deduct_llm_quota(tenant_id: str, model_instance: ModelInstance, usage: LLMUs ) session.execute(stmt) session.commit() + + +def build_context( + prompt_messages: Sequence[PromptMessage], + assistant_response: str, +) -> list[PromptMessage]: + """ + Build context from prompt messages and assistant response. + Excludes system messages and includes the current LLM response. + Returns list[PromptMessage] for use with ArrayPromptMessageSegment. + + Note: Multi-modal content base64 data is truncated to avoid storing large data in context. + """ + context_messages: list[PromptMessage] = [ + _truncate_multimodal_content(m) for m in prompt_messages if m.role != PromptMessageRole.SYSTEM + ] + context_messages.append(AssistantPromptMessage(content=assistant_response)) + return context_messages + + +def _truncate_multimodal_content(message: PromptMessage) -> PromptMessage: + """ + Truncate multi-modal content base64 data in a message to avoid storing large data. + Preserves the PromptMessage structure for ArrayPromptMessageSegment compatibility. + """ + content = message.content + if content is None or isinstance(content, str): + return message + + # Process list content, truncating multi-modal base64 data + new_content: list[PromptMessageContentUnionTypes] = [] + for item in content: + if isinstance(item, MultiModalPromptMessageContent): + # Truncate base64_data similar to prompt_messages_to_prompt_for_saving + truncated_base64 = "" + if item.base64_data: + truncated_base64 = item.base64_data[:10] + "...[TRUNCATED]..." + item.base64_data[-10:] + new_content.append(item.model_copy(update={"base64_data": truncated_base64})) + else: + new_content.append(item) + + return message.model_copy(update={"content": new_content}) diff --git a/api/core/workflow/nodes/llm/node.py b/api/core/workflow/nodes/llm/node.py index 6fb75591dd..cce0e0679a 100644 --- a/api/core/workflow/nodes/llm/node.py +++ b/api/core/workflow/nodes/llm/node.py @@ -20,7 +20,6 @@ from core.memory.base import BaseMemory from core.model_manager import ModelInstance, ModelManager from core.model_runtime.entities import ( ImagePromptMessageContent, - MultiModalPromptMessageContent, PromptMessage, PromptMessageContentType, TextPromptMessageContent, @@ -327,25 +326,13 @@ class LLMNode(Node[LLMNodeData]): "reasoning_content": reasoning_content, "usage": jsonable_encoder(usage), "finish_reason": finish_reason, - "context": self._build_context(prompt_messages, clean_text), + "context": llm_utils.build_context(prompt_messages, clean_text), } if structured_output: outputs["structured_output"] = structured_output.structured_output if self._file_outputs: outputs["files"] = ArrayFileSegment(value=self._file_outputs) - # Write to Node Memory if in node memory mode - # Resolve the query template to get actual user content - actual_query = variable_pool.convert_template(query or "").text - llm_utils.save_node_memory( - memory=memory, - variable_pool=variable_pool, - user_query=actual_query, - assistant_response=clean_text, - user_files=files, - assistant_files=self._file_outputs, - ) - # Send final chunk event to indicate streaming is complete yield StreamChunkEvent( selector=[self._node_id, "text"], @@ -607,48 +594,6 @@ class LLMNode(Node[LLMNodeData]): # Separated mode: always return clean text and reasoning_content return clean_text, reasoning_content or "" - @staticmethod - def _build_context( - prompt_messages: Sequence[PromptMessage], - assistant_response: str, - ) -> list[PromptMessage]: - """ - Build context from prompt messages and assistant response. - Excludes system messages and includes the current LLM response. - Returns list[PromptMessage] for use with ArrayPromptMessageSegment. - - Note: Multi-modal content base64 data is truncated to avoid storing large data in context. - """ - context_messages: list[PromptMessage] = [ - LLMNode._truncate_multimodal_content(m) for m in prompt_messages if m.role != PromptMessageRole.SYSTEM - ] - context_messages.append(AssistantPromptMessage(content=assistant_response)) - return context_messages - - @staticmethod - def _truncate_multimodal_content(message: PromptMessage) -> PromptMessage: - """ - Truncate multi-modal content base64 data in a message to avoid storing large data. - Preserves the PromptMessage structure for ArrayPromptMessageSegment compatibility. - """ - content = message.content - if content is None or isinstance(content, str): - return message - - # Process list content, truncating multi-modal base64 data - new_content: list[PromptMessageContentUnionTypes] = [] - for item in content: - if isinstance(item, MultiModalPromptMessageContent): - # Truncate base64_data similar to prompt_messages_to_prompt_for_saving - truncated_base64 = "" - if item.base64_data: - truncated_base64 = item.base64_data[:10] + "...[TRUNCATED]..." + item.base64_data[-10:] - new_content.append(item.model_copy(update={"base64_data": truncated_base64})) - else: - new_content.append(item) - - return message.model_copy(update={"content": new_content}) - def _transform_chat_messages( self, messages: Sequence[LLMNodeChatModelMessage] | LLMNodeCompletionModelPromptTemplate, / ) -> Sequence[LLMNodeChatModelMessage] | LLMNodeCompletionModelPromptTemplate: @@ -716,54 +661,158 @@ class LLMNode(Node[LLMNodeData]): """ variable_pool = self.graph_runtime_state.variable_pool - # Build a map from context index to its messages - context_messages_map: dict[int, list[PromptMessage]] = {} + # Process messages in DSL order: iterate once and handle each type directly + combined_messages: list[PromptMessage] = [] context_idx = 0 - for idx, type_ in template_order: + static_idx = 0 + + for _, type_ in template_order: if type_ == "context": + # Handle context reference ctx_ref = context_refs[context_idx] ctx_var = variable_pool.get(ctx_ref.value_selector) if ctx_var is None: raise VariableNotFoundError(f"Variable {'.'.join(ctx_ref.value_selector)} not found") if not isinstance(ctx_var, ArrayPromptMessageSegment): raise InvalidVariableTypeError(f"Variable {'.'.join(ctx_ref.value_selector)} is not array[message]") - context_messages_map[idx] = list(ctx_var.value) + combined_messages.extend(ctx_var.value) context_idx += 1 - - # Process static messages - static_prompt_messages: Sequence[PromptMessage] = [] - stop: Sequence[str] | None = None - if static_messages: - static_prompt_messages, stop = LLMNode.fetch_prompt_messages( - sys_query=query, - sys_files=files, - context=context, - memory=memory, - model_config=model_config, - prompt_template=cast(Sequence[LLMNodeChatModelMessage], self.node_data.prompt_template), - memory_config=self.node_data.memory, - vision_enabled=self.node_data.vision.enabled, - vision_detail=self.node_data.vision.configs.detail, - variable_pool=variable_pool, - jinja2_variables=self.node_data.prompt_config.jinja2_variables, - tenant_id=self.tenant_id, - context_files=context_files, - ) - - # Combine messages according to original DSL order - combined_messages: list[PromptMessage] = [] - static_msg_iter = iter(static_prompt_messages) - for idx, type_ in template_order: - if type_ == "context": - combined_messages.extend(context_messages_map[idx]) else: - if msg := next(static_msg_iter, None): - combined_messages.append(msg) - # Append any remaining static messages (e.g., memory messages) - combined_messages.extend(static_msg_iter) + # Handle static message + static_msg = static_messages[static_idx] + processed_msgs = LLMNode.handle_list_messages( + messages=[static_msg], + context=context, + jinja2_variables=self.node_data.prompt_config.jinja2_variables or [], + variable_pool=variable_pool, + vision_detail_config=self.node_data.vision.configs.detail, + ) + combined_messages.extend(processed_msgs) + static_idx += 1 + + # Append memory messages + memory_messages = _handle_memory_chat_mode( + memory=memory, + memory_config=self.node_data.memory, + model_config=model_config, + ) + combined_messages.extend(memory_messages) + + # Append current query if provided + if query: + query_message = LLMNodeChatModelMessage( + text=query, + role=PromptMessageRole.USER, + edition_type="basic", + ) + query_msgs = LLMNode.handle_list_messages( + messages=[query_message], + context="", + jinja2_variables=[], + variable_pool=variable_pool, + vision_detail_config=self.node_data.vision.configs.detail, + ) + combined_messages.extend(query_msgs) + + # Handle files (sys_files and context_files) + combined_messages = self._append_files_to_messages( + messages=combined_messages, + sys_files=files, + context_files=context_files, + model_config=model_config, + ) + + # Filter empty messages and get stop sequences + combined_messages = self._filter_messages(combined_messages, model_config) + stop = self._get_stop_sequences(model_config) return combined_messages, stop + def _append_files_to_messages( + self, + *, + messages: list[PromptMessage], + sys_files: Sequence[File], + context_files: list[File], + model_config: ModelConfigWithCredentialsEntity, + ) -> list[PromptMessage]: + """Append sys_files and context_files to messages.""" + vision_enabled = self.node_data.vision.enabled + vision_detail = self.node_data.vision.configs.detail + + # Handle sys_files (will be deprecated later) + if vision_enabled and sys_files: + file_prompts = [ + file_manager.to_prompt_message_content(file, image_detail_config=vision_detail) for file in sys_files + ] + if messages and isinstance(messages[-1], UserPromptMessage) and isinstance(messages[-1].content, list): + messages[-1] = UserPromptMessage(content=file_prompts + messages[-1].content) + else: + messages.append(UserPromptMessage(content=file_prompts)) + + # Handle context_files + if vision_enabled and context_files: + file_prompts = [ + file_manager.to_prompt_message_content(file, image_detail_config=vision_detail) + for file in context_files + ] + if messages and isinstance(messages[-1], UserPromptMessage) and isinstance(messages[-1].content, list): + messages[-1] = UserPromptMessage(content=file_prompts + messages[-1].content) + else: + messages.append(UserPromptMessage(content=file_prompts)) + + return messages + + def _filter_messages( + self, messages: list[PromptMessage], model_config: ModelConfigWithCredentialsEntity + ) -> list[PromptMessage]: + """Filter empty messages and unsupported content types.""" + filtered_messages: list[PromptMessage] = [] + + for message in messages: + if isinstance(message.content, list): + filtered_content: list[PromptMessageContentUnionTypes] = [] + for content_item in message.content: + # Skip non-text content if features are not defined + if not model_config.model_schema.features: + if content_item.type != PromptMessageContentType.TEXT: + continue + filtered_content.append(content_item) + continue + + # Skip content if corresponding feature is not supported + feature_map = { + PromptMessageContentType.IMAGE: ModelFeature.VISION, + PromptMessageContentType.DOCUMENT: ModelFeature.DOCUMENT, + PromptMessageContentType.VIDEO: ModelFeature.VIDEO, + PromptMessageContentType.AUDIO: ModelFeature.AUDIO, + } + required_feature = feature_map.get(content_item.type) + if required_feature and required_feature not in model_config.model_schema.features: + continue + filtered_content.append(content_item) + + # Simplify single text content + if len(filtered_content) == 1 and filtered_content[0].type == PromptMessageContentType.TEXT: + message.content = filtered_content[0].data + else: + message.content = filtered_content + + if not message.is_empty(): + filtered_messages.append(message) + + if not filtered_messages: + raise NoPromptFoundError( + "No prompt found in the LLM configuration. " + "Please ensure a prompt is properly configured before proceeding." + ) + + return filtered_messages + + def _get_stop_sequences(self, model_config: ModelConfigWithCredentialsEntity) -> Sequence[str] | None: + """Get stop sequences from model config.""" + return model_config.stop + def _fetch_jinja_inputs(self, node_data: LLMNodeData) -> dict[str, str]: variables: dict[str, Any] = {} diff --git a/api/core/workflow/nodes/parameter_extractor/parameter_extractor_node.py b/api/core/workflow/nodes/parameter_extractor/parameter_extractor_node.py index ddb48de145..f78aa0cc3e 100644 --- a/api/core/workflow/nodes/parameter_extractor/parameter_extractor_node.py +++ b/api/core/workflow/nodes/parameter_extractor/parameter_extractor_node.py @@ -246,13 +246,9 @@ class ParameterExtractorNode(Node[ParameterExtractorNodeData]): # transform result into standard format result = self._transform_result(data=node_data, result=result or {}) - # Save to node memory if in node memory mode - llm_utils.save_node_memory( - memory=memory, - variable_pool=variable_pool, - user_query=query, - assistant_response=json.dumps(result, ensure_ascii=False), - ) + # Build context from prompt messages and response + assistant_response = json.dumps(result, ensure_ascii=False) + context = llm_utils.build_context(prompt_messages, assistant_response) return NodeRunResult( status=WorkflowNodeExecutionStatus.SUCCEEDED, @@ -262,6 +258,7 @@ class ParameterExtractorNode(Node[ParameterExtractorNodeData]): "__is_success": 1 if not error else 0, "__reason": error, "__usage": jsonable_encoder(usage), + "context": context, **result, }, metadata={ diff --git a/api/core/workflow/nodes/question_classifier/question_classifier_node.py b/api/core/workflow/nodes/question_classifier/question_classifier_node.py index 6d72fcfe25..564e548e9f 100644 --- a/api/core/workflow/nodes/question_classifier/question_classifier_node.py +++ b/api/core/workflow/nodes/question_classifier/question_classifier_node.py @@ -199,20 +199,17 @@ class QuestionClassifierNode(Node[QuestionClassifierNodeData]): "model_provider": model_config.provider, "model_name": model_config.model, } + # Build context from prompt messages and response + assistant_response = f"class_name: {category_name}, class_id: {category_id}" + context = llm_utils.build_context(prompt_messages, assistant_response) + outputs = { "class_name": category_name, "class_id": category_id, "usage": jsonable_encoder(usage), + "context": context, } - # Save to node memory if in node memory mode - llm_utils.save_node_memory( - memory=memory, - variable_pool=variable_pool, - user_query=query or "", - assistant_response=f"class_name: {category_name}, class_id: {category_id}", - ) - return NodeRunResult( status=WorkflowNodeExecutionStatus.SUCCEEDED, inputs=variables, From 7222a896d848d03b531eeba0bd7d242afe32123b Mon Sep 17 00:00:00 2001 From: zhsama Date: Fri, 16 Jan 2026 15:01:11 +0800 Subject: [PATCH 64/82] Align warning styles for agent mentions --- .../agent-header-bar.tsx | 18 ++++++- .../mixed-variable-text-input/index.tsx | 50 ++++++++++++++++++- .../components/workflow/nodes/tool/node.tsx | 5 +- 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/agent-header-bar.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/agent-header-bar.tsx index f956188474..f5edd80f89 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/agent-header-bar.tsx +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/agent-header-bar.tsx @@ -2,25 +2,36 @@ import type { FC } from 'react' import { RiCloseLine, RiEqualizer2Line } from '@remixicon/react' import { memo } from 'react' import { useTranslation } from 'react-i18next' +import AlertTriangle from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback/AlertTriangle' import { Agent } from '@/app/components/base/icons/src/vender/workflow' +import { cn } from '@/utils/classnames' type AgentHeaderBarProps = { agentName: string onRemove: () => void onViewInternals?: () => void + hasWarning?: boolean } const AgentHeaderBar: FC = ({ agentName, onRemove, onViewInternals, + hasWarning, }) => { const { t } = useTranslation() return (
-
+
@@ -39,11 +50,14 @@ const AgentHeaderBar: FC = ({
) diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx index eec880bd77..1e3848bf48 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx @@ -1,5 +1,6 @@ import type { AgentNode } from '@/app/components/base/prompt-editor/types' import type { MentionConfig, VarKindType } from '@/app/components/workflow/nodes/_base/types' +import type { AgentNodeType } from '@/app/components/workflow/nodes/agent/types' import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types' import type { Node, @@ -15,7 +16,7 @@ import { useState, } from 'react' import { useTranslation } from 'react-i18next' -import { useStoreApi } from 'reactflow' +import { useStore as useReactflowStore, useStoreApi } from 'reactflow' import PromptEditor from '@/app/components/base/prompt-editor' import { useNodesMetaData, useNodesSyncDraft } from '@/app/components/workflow/hooks' import { VarKindType as VarKindTypeEnum } from '@/app/components/workflow/nodes/_base/types' @@ -23,6 +24,8 @@ import { Type } from '@/app/components/workflow/nodes/llm/types' import { useStore } from '@/app/components/workflow/store' import { BlockEnum, EditionType, isPromptMessageContext, PromptRole } from '@/app/components/workflow/types' import { generateNewNode, getNodeCustomTypeByNodeDataType } from '@/app/components/workflow/utils' +import { useGetLanguage } from '@/context/i18n' +import { useStrategyProviders } from '@/service/use-strategy' import { cn } from '@/utils/classnames' import SubGraphModal from '../sub-graph-modal' import AgentHeaderBar from './agent-header-bar' @@ -137,7 +140,10 @@ const MixedVariableTextInput = ({ paramKey = '', }: MixedVariableTextInputProps) => { const { t } = useTranslation() + const language = useGetLanguage() + const { data: strategyProviders } = useStrategyProviders() const reactFlowStore = useStoreApi() + const nodes = useReactflowStore(state => state.nodes) const controlPromptEditorRerenderKey = useStore(s => s.controlPromptEditorRerenderKey) const setControlPromptEditorRerenderKey = useStore(s => s.setControlPromptEditorRerenderKey) const { nodesMap: nodesMetaDataMap } = useNodesMetaData() @@ -151,6 +157,13 @@ const MixedVariableTextInput = ({ }, {} as Record) }, [availableNodes]) + const nodesById = useMemo(() => { + return nodes.reduce((acc, node) => { + acc[node.id] = node + return acc + }, {} as Record) + }, [nodes]) + type DetectedAgent = { nodeId: string name: string @@ -188,6 +201,40 @@ const MixedVariableTextInput = ({ })) }, [availableNodes]) + const getNodeWarning = useCallback((node?: Node) => { + if (!node) + return true + const validator = nodesMetaDataMap?.[node.data.type as BlockEnum]?.checkValid + if (!validator) + return false + let moreDataForCheckValid: any + if (node.data.type === BlockEnum.Agent) { + const agentData = node.data as AgentNodeType + const isReadyForCheckValid = !!strategyProviders + const provider = strategyProviders?.find(provider => provider.declaration.identity.name === agentData.agent_strategy_provider_name) + const strategy = provider?.declaration.strategies?.find(s => s.identity.name === agentData.agent_strategy_name) + moreDataForCheckValid = { + provider, + strategy, + language, + isReadyForCheckValid, + } + } + const { errorMessage } = validator(node.data as any, t, moreDataForCheckValid) + return Boolean(errorMessage) + }, [language, nodesMetaDataMap, strategyProviders, t]) + + const hasAgentWarning = useMemo(() => { + if (!detectedAgentFromValue) + return false + const agentWarning = getNodeWarning(nodesById[detectedAgentFromValue.nodeId]) + if (!toolNodeId || !paramKey) + return agentWarning + const extractorNodeId = `${toolNodeId}_ext_${paramKey}` + const extractorWarning = getNodeWarning(nodesById[extractorNodeId]) + return agentWarning || extractorWarning + }, [detectedAgentFromValue, getNodeWarning, nodesById, paramKey, toolNodeId]) + const syncExtractorPromptFromText = useCallback((text: string) => { if (!toolNodeId || !paramKey) return @@ -341,6 +388,7 @@ const MixedVariableTextInput = ({ agentName={detectedAgentFromValue.name} onRemove={handleAgentRemove} onViewInternals={handleOpenSubGraphModal} + hasWarning={hasAgentWarning} /> )} > = ({ {referenceItems.map(item => (
Date: Fri, 16 Jan 2026 15:09:42 +0800 Subject: [PATCH 65/82] Use warning token borders for mentions --- .../mixed-variable-text-input/agent-header-bar.tsx | 2 +- web/app/components/workflow/nodes/tool/node.tsx | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/agent-header-bar.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/agent-header-bar.tsx index f5edd80f89..dcb46f78d2 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/agent-header-bar.tsx +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/agent-header-bar.tsx @@ -28,7 +28,7 @@ const AgentHeaderBar: FC = ({ className={cn( 'flex items-center gap-1 rounded-md border-[0.5px] px-1.5 py-0.5 shadow-xs', hasWarning - ? 'border-components-badge-status-light-warning-border-inner bg-components-badge-status-light-warning-halo' + ? 'border-text-warning-secondary bg-components-badge-status-light-warning-halo' : 'border-components-panel-border-subtle bg-components-badge-white-to-dark', )} > diff --git a/web/app/components/workflow/nodes/tool/node.tsx b/web/app/components/workflow/nodes/tool/node.tsx index 938aa51b4b..9535b3fbb6 100644 --- a/web/app/components/workflow/nodes/tool/node.tsx +++ b/web/app/components/workflow/nodes/tool/node.tsx @@ -191,8 +191,10 @@ const Node: FC> = ({
From 18abc6658589e6755cee42bd49a33dc9ea678bda Mon Sep 17 00:00:00 2001 From: Novice Date: Fri, 16 Jan 2026 17:01:19 +0800 Subject: [PATCH 66/82] feat: add context file support --- api/core/file/file_manager.py | 146 +++++++++++++- api/core/memory/node_token_buffer_memory.py | 31 ++- .../entities/message_entities.py | 3 + api/core/workflow/nodes/llm/llm_utils.py | 54 +++++- api/core/workflow/nodes/llm/node.py | 4 +- .../unit_tests/core/file/test_file_manager.py | 182 ++++++++++++++++++ .../core/workflow/nodes/llm/test_llm_utils.py | 174 +++++++++++++++++ 7 files changed, 585 insertions(+), 9 deletions(-) create mode 100644 api/tests/unit_tests/core/file/test_file_manager.py create mode 100644 api/tests/unit_tests/core/workflow/nodes/llm/test_llm_utils.py diff --git a/api/core/file/file_manager.py b/api/core/file/file_manager.py index 120fb73cdb..93c1a9be99 100644 --- a/api/core/file/file_manager.py +++ b/api/core/file/file_manager.py @@ -1,4 +1,5 @@ import base64 +import logging from collections.abc import Mapping from configs import dify_config @@ -10,7 +11,10 @@ from core.model_runtime.entities import ( TextPromptMessageContent, VideoPromptMessageContent, ) -from core.model_runtime.entities.message_entities import PromptMessageContentUnionTypes +from core.model_runtime.entities.message_entities import ( + MultiModalPromptMessageContent, + PromptMessageContentUnionTypes, +) from core.tools.signature import sign_tool_file from extensions.ext_storage import storage @@ -18,6 +22,8 @@ from . import helpers from .enums import FileAttribute from .models import File, FileTransferMethod, FileType +logger = logging.getLogger(__name__) + def get_attr(*, file: File, attr: FileAttribute): match attr: @@ -89,6 +95,8 @@ def to_prompt_message_content( "format": f.extension.removeprefix("."), "mime_type": f.mime_type, "filename": f.filename or "", + # Encoded file reference for context restoration: "transfer_method:related_id" or "remote:url" + "file_ref": _encode_file_ref(f), } if f.type == FileType.IMAGE: params["detail"] = image_detail_config or ImagePromptMessageContent.DETAIL.LOW @@ -96,6 +104,17 @@ def to_prompt_message_content( return prompt_class_map[f.type].model_validate(params) +def _encode_file_ref(f: File) -> str | None: + """Encode file reference as 'transfer_method:id_or_url' string.""" + if f.transfer_method == FileTransferMethod.REMOTE_URL: + return f"remote:{f.remote_url}" if f.remote_url else None + elif f.transfer_method == FileTransferMethod.LOCAL_FILE: + return f"local:{f.related_id}" if f.related_id else None + elif f.transfer_method == FileTransferMethod.TOOL_FILE: + return f"tool:{f.related_id}" if f.related_id else None + return None + + def download(f: File, /): if f.transfer_method in ( FileTransferMethod.TOOL_FILE, @@ -164,3 +183,128 @@ def _to_url(f: File, /): return sign_tool_file(tool_file_id=f.related_id, extension=f.extension) else: raise ValueError(f"Unsupported transfer method: {f.transfer_method}") + + +def restore_multimodal_content( + content: MultiModalPromptMessageContent, +) -> MultiModalPromptMessageContent: + """ + Restore base64_data or url for multimodal content from file_ref. + + file_ref format: "transfer_method:id_or_url" (e.g., "local:abc123", "remote:https://...") + + Args: + content: MultiModalPromptMessageContent with file_ref field + + Returns: + MultiModalPromptMessageContent with restored base64_data or url + """ + # Skip if no file reference or content already has data + if not content.file_ref: + return content + if content.base64_data or content.url: + return content + + try: + file = _build_file_from_ref( + file_ref=content.file_ref, + file_format=content.format, + mime_type=content.mime_type, + filename=content.filename, + ) + if not file: + return content + + # Restore content based on config + if dify_config.MULTIMODAL_SEND_FORMAT == "base64": + restored_base64 = _get_encoded_string(file) + return content.model_copy(update={"base64_data": restored_base64}) + else: + restored_url = _to_url(file) + return content.model_copy(update={"url": restored_url}) + + except Exception as e: + logger.warning("Failed to restore multimodal content: %s", e) + return content + + +def _build_file_from_ref( + file_ref: str, + file_format: str | None, + mime_type: str | None, + filename: str | None, +) -> File | None: + """ + Build a File object from encoded file_ref string. + + Args: + file_ref: Encoded reference "transfer_method:id_or_url" + file_format: The file format/extension (without dot) + mime_type: The mime type + filename: The filename + + Returns: + File object with storage_key loaded, or None if not found + """ + from sqlalchemy import select + from sqlalchemy.orm import Session + + from extensions.ext_database import db + from models.model import UploadFile + from models.tools import ToolFile + + # Parse file_ref: "method:value" + if ":" not in file_ref: + logger.warning("Invalid file_ref format: %s", file_ref) + return None + + method, value = file_ref.split(":", 1) + extension = f".{file_format}" if file_format else None + + if method == "remote": + return File( + tenant_id="", + type=FileType.IMAGE, + transfer_method=FileTransferMethod.REMOTE_URL, + remote_url=value, + extension=extension, + mime_type=mime_type, + filename=filename, + storage_key="", + ) + + # Query database for storage_key + with Session(db.engine) as session: + if method == "local": + stmt = select(UploadFile).where(UploadFile.id == value) + upload_file = session.scalar(stmt) + if upload_file: + return File( + tenant_id=upload_file.tenant_id, + type=FileType(upload_file.extension) + if hasattr(FileType, upload_file.extension.upper()) + else FileType.IMAGE, + transfer_method=FileTransferMethod.LOCAL_FILE, + related_id=value, + extension=extension or ("." + upload_file.extension if upload_file.extension else None), + mime_type=mime_type or upload_file.mime_type, + filename=filename or upload_file.name, + storage_key=upload_file.key, + ) + elif method == "tool": + stmt = select(ToolFile).where(ToolFile.id == value) + tool_file = session.scalar(stmt) + if tool_file: + return File( + tenant_id=tool_file.tenant_id, + type=FileType.IMAGE, + transfer_method=FileTransferMethod.TOOL_FILE, + related_id=value, + extension=extension, + mime_type=mime_type or tool_file.mimetype, + filename=filename or tool_file.name, + storage_key=tool_file.file_key, + ) + + logger.warning("File not found for file_ref: %s", file_ref) + return None diff --git a/api/core/memory/node_token_buffer_memory.py b/api/core/memory/node_token_buffer_memory.py index 386dde9c89..ec6b04b13e 100644 --- a/api/core/memory/node_token_buffer_memory.py +++ b/api/core/memory/node_token_buffer_memory.py @@ -15,20 +15,24 @@ Design: import logging from collections.abc import Sequence +from typing import cast from sqlalchemy import select from sqlalchemy.orm import Session +from core.file import file_manager from core.memory.base import BaseMemory from core.model_manager import ModelInstance from core.model_runtime.entities import ( AssistantPromptMessage, + MultiModalPromptMessageContent, PromptMessage, PromptMessageRole, SystemPromptMessage, ToolPromptMessage, UserPromptMessage, ) +from core.model_runtime.entities.message_entities import PromptMessageContentUnionTypes from core.prompt.utils.extract_thread_messages import extract_thread_messages from extensions.ext_database import db from models.model import Message @@ -108,11 +112,36 @@ class NodeTokenBufferMemory(BaseMemory): messages = [] for msg_dict in context_data: try: - messages.append(self._deserialize_prompt_message(msg_dict)) + msg = self._deserialize_prompt_message(msg_dict) + msg = self._restore_multimodal_content(msg) + messages.append(msg) except Exception as e: logger.warning("Failed to deserialize prompt message: %s", e) return messages + def _restore_multimodal_content(self, message: PromptMessage) -> PromptMessage: + """ + Restore multimodal content (base64 or url) from file_ref. + + When context is saved, base64_data is cleared to save storage space. + This method restores the content by parsing file_ref (format: "method:id_or_url"). + """ + content = message.content + if content is None or isinstance(content, str): + return message + + # Process list content, restoring multimodal data from file references + restored_content: list[PromptMessageContentUnionTypes] = [] + for item in content: + if isinstance(item, MultiModalPromptMessageContent): + # restore_multimodal_content preserves the concrete subclass type + restored_item = file_manager.restore_multimodal_content(item) + restored_content.append(cast(PromptMessageContentUnionTypes, restored_item)) + else: + restored_content.append(item) + + return message.model_copy(update={"content": restored_content}) + def get_history_prompt_messages( self, *, diff --git a/api/core/model_runtime/entities/message_entities.py b/api/core/model_runtime/entities/message_entities.py index 5a07a22023..284f4dba01 100644 --- a/api/core/model_runtime/entities/message_entities.py +++ b/api/core/model_runtime/entities/message_entities.py @@ -91,6 +91,9 @@ class MultiModalPromptMessageContent(PromptMessageContent): mime_type: str = Field(default=..., description="the mime type of multi-modal file") filename: str = Field(default="", description="the filename of multi-modal file") + # File reference for context restoration, format: "transfer_method:related_id" or "remote:url" + file_ref: str | None = Field(default=None, description="Encoded file reference for restoration") + @property def data(self): return self.url or f"data:{self.mime_type};base64,{self.base64_data}" diff --git a/api/core/workflow/nodes/llm/llm_utils.py b/api/core/workflow/nodes/llm/llm_utils.py index 1b412df0ea..966c34a0d7 100644 --- a/api/core/workflow/nodes/llm/llm_utils.py +++ b/api/core/workflow/nodes/llm/llm_utils.py @@ -233,21 +233,63 @@ def _truncate_multimodal_content(message: PromptMessage) -> PromptMessage: """ Truncate multi-modal content base64 data in a message to avoid storing large data. Preserves the PromptMessage structure for ArrayPromptMessageSegment compatibility. + + If file_ref is present, clears base64_data and url (they can be restored later). + Otherwise, truncates base64_data as fallback for legacy data. """ content = message.content if content is None or isinstance(content, str): return message - # Process list content, truncating multi-modal base64 data + # Process list content, handling multi-modal data based on file_ref availability new_content: list[PromptMessageContentUnionTypes] = [] for item in content: if isinstance(item, MultiModalPromptMessageContent): - # Truncate base64_data similar to prompt_messages_to_prompt_for_saving - truncated_base64 = "" - if item.base64_data: - truncated_base64 = item.base64_data[:10] + "...[TRUNCATED]..." + item.base64_data[-10:] - new_content.append(item.model_copy(update={"base64_data": truncated_base64})) + if item.file_ref: + # Clear base64 and url, keep file_ref for later restoration + new_content.append(item.model_copy(update={"base64_data": "", "url": ""})) + else: + # Fallback: truncate base64_data if no file_ref (legacy data) + truncated_base64 = "" + if item.base64_data: + truncated_base64 = item.base64_data[:10] + "...[TRUNCATED]..." + item.base64_data[-10:] + new_content.append(item.model_copy(update={"base64_data": truncated_base64})) else: new_content.append(item) return message.model_copy(update={"content": new_content}) + + +def restore_multimodal_content_in_messages(messages: Sequence[PromptMessage]) -> list[PromptMessage]: + """ + Restore multimodal content (base64 or url) in a list of PromptMessages. + + When context is saved, base64_data is cleared to save storage space. + This function restores the content by parsing file_ref in each MultiModalPromptMessageContent. + + Args: + messages: List of PromptMessages that may contain truncated multimodal content + + Returns: + List of PromptMessages with restored multimodal content + """ + from core.file import file_manager + + return [_restore_message_content(msg, file_manager) for msg in messages] + + +def _restore_message_content(message: PromptMessage, file_manager) -> PromptMessage: + """Restore multimodal content in a single PromptMessage.""" + content = message.content + if content is None or isinstance(content, str): + return message + + restored_content: list[PromptMessageContentUnionTypes] = [] + for item in content: + if isinstance(item, MultiModalPromptMessageContent): + restored_item = file_manager.restore_multimodal_content(item) + restored_content.append(cast(PromptMessageContentUnionTypes, restored_item)) + else: + restored_content.append(item) + + return message.model_copy(update={"content": restored_content}) diff --git a/api/core/workflow/nodes/llm/node.py b/api/core/workflow/nodes/llm/node.py index cce0e0679a..bde43d8f08 100644 --- a/api/core/workflow/nodes/llm/node.py +++ b/api/core/workflow/nodes/llm/node.py @@ -675,7 +675,9 @@ class LLMNode(Node[LLMNodeData]): raise VariableNotFoundError(f"Variable {'.'.join(ctx_ref.value_selector)} not found") if not isinstance(ctx_var, ArrayPromptMessageSegment): raise InvalidVariableTypeError(f"Variable {'.'.join(ctx_ref.value_selector)} is not array[message]") - combined_messages.extend(ctx_var.value) + # Restore multimodal content (base64/url) that was truncated when saving context + restored_messages = llm_utils.restore_multimodal_content_in_messages(ctx_var.value) + combined_messages.extend(restored_messages) context_idx += 1 else: # Handle static message diff --git a/api/tests/unit_tests/core/file/test_file_manager.py b/api/tests/unit_tests/core/file/test_file_manager.py new file mode 100644 index 0000000000..018bdee4d7 --- /dev/null +++ b/api/tests/unit_tests/core/file/test_file_manager.py @@ -0,0 +1,182 @@ +"""Tests for file_manager module, specifically multimodal content handling.""" + +from unittest.mock import patch + +from core.file import File, FileTransferMethod, FileType +from core.file.file_manager import ( + _encode_file_ref, + restore_multimodal_content, + to_prompt_message_content, +) +from core.model_runtime.entities.message_entities import ImagePromptMessageContent + + +class TestEncodeFileRef: + """Tests for _encode_file_ref function.""" + + def test_encodes_local_file(self): + """Local file should be encoded as 'local:id'.""" + file = File( + tenant_id="t", + type=FileType.IMAGE, + transfer_method=FileTransferMethod.LOCAL_FILE, + related_id="abc123", + storage_key="key", + ) + assert _encode_file_ref(file) == "local:abc123" + + def test_encodes_tool_file(self): + """Tool file should be encoded as 'tool:id'.""" + file = File( + tenant_id="t", + type=FileType.IMAGE, + transfer_method=FileTransferMethod.TOOL_FILE, + related_id="xyz789", + storage_key="key", + ) + assert _encode_file_ref(file) == "tool:xyz789" + + def test_encodes_remote_url(self): + """Remote URL should be encoded as 'remote:url'.""" + file = File( + tenant_id="t", + type=FileType.IMAGE, + transfer_method=FileTransferMethod.REMOTE_URL, + remote_url="https://example.com/image.png", + storage_key="", + ) + assert _encode_file_ref(file) == "remote:https://example.com/image.png" + + +class TestToPromptMessageContent: + """Tests for to_prompt_message_content function with file_ref field.""" + + @patch("core.file.file_manager.dify_config") + @patch("core.file.file_manager._get_encoded_string") + def test_includes_file_ref(self, mock_get_encoded, mock_config): + """Generated content should include file_ref field.""" + mock_config.MULTIMODAL_SEND_FORMAT = "base64" + mock_get_encoded.return_value = "base64data" + + file = File( + id="test-message-file-id", + tenant_id="test-tenant", + type=FileType.IMAGE, + transfer_method=FileTransferMethod.LOCAL_FILE, + related_id="test-related-id", + remote_url=None, + extension=".png", + mime_type="image/png", + filename="test.png", + storage_key="test-key", + ) + + result = to_prompt_message_content(file) + + assert isinstance(result, ImagePromptMessageContent) + assert result.file_ref == "local:test-related-id" + assert result.base64_data == "base64data" + + +class TestRestoreMultimodalContent: + """Tests for restore_multimodal_content function.""" + + def test_returns_content_unchanged_when_no_file_ref(self): + """Content without file_ref should pass through unchanged.""" + content = ImagePromptMessageContent( + format="png", + base64_data="existing-data", + mime_type="image/png", + file_ref=None, + ) + + result = restore_multimodal_content(content) + + assert result.base64_data == "existing-data" + + def test_returns_content_unchanged_when_already_has_data(self): + """Content that already has base64_data should not be reloaded.""" + content = ImagePromptMessageContent( + format="png", + base64_data="existing-data", + mime_type="image/png", + file_ref="local:file-id", + ) + + result = restore_multimodal_content(content) + + assert result.base64_data == "existing-data" + + def test_returns_content_unchanged_when_already_has_url(self): + """Content that already has url should not be reloaded.""" + content = ImagePromptMessageContent( + format="png", + url="https://example.com/image.png", + mime_type="image/png", + file_ref="local:file-id", + ) + + result = restore_multimodal_content(content) + + assert result.url == "https://example.com/image.png" + + @patch("core.file.file_manager.dify_config") + @patch("core.file.file_manager._build_file_from_ref") + @patch("core.file.file_manager._to_url") + def test_restores_url_from_file_ref(self, mock_to_url, mock_build_file, mock_config): + """Content should be restored from file_ref when url is empty (url mode).""" + mock_config.MULTIMODAL_SEND_FORMAT = "url" + mock_build_file.return_value = "mock_file" + mock_to_url.return_value = "https://restored-url.com/image.png" + + content = ImagePromptMessageContent( + format="png", + base64_data="", + url="", + mime_type="image/png", + filename="test.png", + file_ref="local:test-file-id", + ) + + result = restore_multimodal_content(content) + + assert result.url == "https://restored-url.com/image.png" + mock_build_file.assert_called_once() + + @patch("core.file.file_manager.dify_config") + @patch("core.file.file_manager._build_file_from_ref") + @patch("core.file.file_manager._get_encoded_string") + def test_restores_base64_from_file_ref(self, mock_get_encoded, mock_build_file, mock_config): + """Content should be restored as base64 when in base64 mode.""" + mock_config.MULTIMODAL_SEND_FORMAT = "base64" + mock_build_file.return_value = "mock_file" + mock_get_encoded.return_value = "restored-base64-data" + + content = ImagePromptMessageContent( + format="png", + base64_data="", + url="", + mime_type="image/png", + filename="test.png", + file_ref="local:test-file-id", + ) + + result = restore_multimodal_content(content) + + assert result.base64_data == "restored-base64-data" + mock_build_file.assert_called_once() + + def test_handles_invalid_file_ref_gracefully(self): + """Invalid file_ref format should be handled gracefully.""" + content = ImagePromptMessageContent( + format="png", + base64_data="", + url="", + mime_type="image/png", + file_ref="invalid_format_no_colon", + ) + + result = restore_multimodal_content(content) + + # Should return unchanged on error + assert result.base64_data == "" diff --git a/api/tests/unit_tests/core/workflow/nodes/llm/test_llm_utils.py b/api/tests/unit_tests/core/workflow/nodes/llm/test_llm_utils.py new file mode 100644 index 0000000000..e327e03159 --- /dev/null +++ b/api/tests/unit_tests/core/workflow/nodes/llm/test_llm_utils.py @@ -0,0 +1,174 @@ +"""Tests for llm_utils module, specifically multimodal content handling.""" + +import string +from unittest.mock import patch + +from core.model_runtime.entities.message_entities import ( + ImagePromptMessageContent, + TextPromptMessageContent, + UserPromptMessage, +) +from core.workflow.nodes.llm.llm_utils import ( + _truncate_multimodal_content, + build_context, + restore_multimodal_content_in_messages, +) + + +class TestTruncateMultimodalContent: + """Tests for _truncate_multimodal_content function.""" + + def test_returns_message_unchanged_for_string_content(self): + """String content should pass through unchanged.""" + message = UserPromptMessage(content="Hello, world!") + result = _truncate_multimodal_content(message) + assert result.content == "Hello, world!" + + def test_returns_message_unchanged_for_none_content(self): + """None content should pass through unchanged.""" + message = UserPromptMessage(content=None) + result = _truncate_multimodal_content(message) + assert result.content is None + + def test_clears_base64_when_file_ref_present(self): + """When file_ref is present, base64_data and url should be cleared.""" + image_content = ImagePromptMessageContent( + format="png", + base64_data=string.ascii_lowercase, + url="https://example.com/image.png", + mime_type="image/png", + filename="test.png", + file_ref="local:test-file-id", + ) + message = UserPromptMessage(content=[image_content]) + + result = _truncate_multimodal_content(message) + + assert isinstance(result.content, list) + assert len(result.content) == 1 + result_content = result.content[0] + assert isinstance(result_content, ImagePromptMessageContent) + assert result_content.base64_data == "" + assert result_content.url == "" + # file_ref should be preserved + assert result_content.file_ref == "local:test-file-id" + + def test_truncates_base64_when_no_file_ref(self): + """When file_ref is missing (legacy), base64_data should be truncated.""" + long_base64 = "a" * 100 + image_content = ImagePromptMessageContent( + format="png", + base64_data=long_base64, + mime_type="image/png", + filename="test.png", + file_ref=None, + ) + message = UserPromptMessage(content=[image_content]) + + result = _truncate_multimodal_content(message) + + assert isinstance(result.content, list) + result_content = result.content[0] + assert isinstance(result_content, ImagePromptMessageContent) + # Should be truncated with marker + assert "...[TRUNCATED]..." in result_content.base64_data + assert len(result_content.base64_data) < len(long_base64) + + def test_preserves_text_content(self): + """Text content should pass through unchanged.""" + text_content = TextPromptMessageContent(data="Hello!") + image_content = ImagePromptMessageContent( + format="png", + base64_data="test123", + mime_type="image/png", + file_ref="local:file-id", + ) + message = UserPromptMessage(content=[text_content, image_content]) + + result = _truncate_multimodal_content(message) + + assert isinstance(result.content, list) + assert len(result.content) == 2 + # Text content unchanged + assert result.content[0].data == "Hello!" + # Image content base64 cleared + assert result.content[1].base64_data == "" + + +class TestBuildContext: + """Tests for build_context function.""" + + def test_excludes_system_messages(self): + """System messages should be excluded from context.""" + from core.model_runtime.entities.message_entities import SystemPromptMessage + + messages = [ + SystemPromptMessage(content="You are a helpful assistant."), + UserPromptMessage(content="Hello!"), + ] + + context = build_context(messages, "Hi there!") + + # Should have user message + assistant response, no system message + assert len(context) == 2 + assert context[0].content == "Hello!" + assert context[1].content == "Hi there!" + + def test_appends_assistant_response(self): + """Assistant response should be appended to context.""" + messages = [UserPromptMessage(content="What is 2+2?")] + + context = build_context(messages, "The answer is 4.") + + assert len(context) == 2 + assert context[1].content == "The answer is 4." + + +class TestRestoreMultimodalContentInMessages: + """Tests for restore_multimodal_content_in_messages function.""" + + @patch("core.file.file_manager.restore_multimodal_content") + def test_restores_multimodal_content(self, mock_restore): + """Should restore multimodal content in messages.""" + # Setup mock + restored_content = ImagePromptMessageContent( + format="png", + base64_data="restored-base64", + mime_type="image/png", + file_ref="local:abc123", + ) + mock_restore.return_value = restored_content + + # Create message with truncated content + truncated_content = ImagePromptMessageContent( + format="png", + base64_data="", + mime_type="image/png", + file_ref="local:abc123", + ) + message = UserPromptMessage(content=[truncated_content]) + + result = restore_multimodal_content_in_messages([message]) + + assert len(result) == 1 + assert result[0].content[0].base64_data == "restored-base64" + mock_restore.assert_called_once() + + def test_passes_through_string_content(self): + """String content should pass through unchanged.""" + message = UserPromptMessage(content="Hello!") + + result = restore_multimodal_content_in_messages([message]) + + assert len(result) == 1 + assert result[0].content == "Hello!" + + def test_passes_through_text_content(self): + """TextPromptMessageContent should pass through unchanged.""" + text_content = TextPromptMessageContent(data="Hello!") + message = UserPromptMessage(content=[text_content]) + + result = restore_multimodal_content_in_messages([message]) + + assert len(result) == 1 + assert result[0].content[0].data == "Hello!" From 1bc1c04be57eaceedeabb75d2f997f23a2aa7d36 Mon Sep 17 00:00:00 2001 From: zhsama Date: Fri, 16 Jan 2026 17:03:22 +0800 Subject: [PATCH 67/82] feat: add assemble variables entry --- .../plugins/component-picker-block/index.tsx | 18 ++++++++++- .../components/base/prompt-editor/types.ts | 2 ++ .../variable/var-reference-vars.tsx | 31 +++++++++++++++++++ web/i18n/en-US/workflow.json | 1 + web/i18n/ja-JP/workflow.json | 1 + web/i18n/zh-Hans/workflow.json | 1 + web/i18n/zh-Hant/workflow.json | 1 + 7 files changed, 54 insertions(+), 1 deletion(-) diff --git a/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx b/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx index 1511754128..56a9c251bd 100644 --- a/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx +++ b/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx @@ -161,6 +161,19 @@ const ComponentPicker = ({ editor.dispatchCommand(KEY_ESCAPE_COMMAND, escapeEvent) }, [editor]) + const handleSelectAssembleVariables = useCallback(() => { + editor.update(() => { + const match = checkForTriggerMatch(triggerString, editor) + if (!match) + return + const needRemove = $splitNodeContainingQuery(match) + if (needRemove) + needRemove.remove() + }) + workflowVariableBlock?.onAssembleVariables?.() + handleClose() + }, [editor, checkForTriggerMatch, triggerString, workflowVariableBlock, handleClose]) + const handleSelectAgent = useCallback((agent: { id: string, title: string }) => { editor.update(() => { const needRemove = $splitNodeContainingQuery(checkForTriggerMatch(triggerString, editor)!) @@ -182,6 +195,7 @@ const ComponentPicker = ({ }, [editor, checkForTriggerMatch, triggerString, agentBlock, handleClose]) const isAgentTrigger = triggerString === '@' && agentBlock?.show + const showAssembleVariables = triggerString === '/' && workflowVariableBlock?.showAssembleVariables const agentNodes = agentBlock?.agentNodes || [] const renderMenu = useCallback>(( @@ -246,6 +260,8 @@ const ComponentPicker = ({ onBlur={handleClose} showManageInputField={workflowVariableBlock.showManageInputField} onManageInputField={workflowVariableBlock.onManageInputField} + showAssembleVariables={showAssembleVariables} + onAssembleVariables={showAssembleVariables ? handleSelectAssembleVariables : undefined} autoFocus={false} isInCodeGeneratorInstructionEditor={currentBlock?.generatorType === GeneratorType.code} /> @@ -289,7 +305,7 @@ const ComponentPicker = ({ } ) - }, [isAgentTrigger, agentNodes, allFlattenOptions.length, workflowVariableBlock?.show, floatingStyles, isPositioned, refs, handleSelectAgent, handleClose, workflowVariableOptions, isSupportFileVar, currentBlock?.generatorType, handleSelectWorkflowVariable, queryString, workflowVariableBlock?.showManageInputField, workflowVariableBlock?.onManageInputField]) + }, [isAgentTrigger, agentNodes, allFlattenOptions.length, workflowVariableBlock?.show, floatingStyles, isPositioned, refs, handleSelectAgent, handleClose, workflowVariableOptions, isSupportFileVar, currentBlock?.generatorType, handleSelectWorkflowVariable, queryString, workflowVariableBlock?.showManageInputField, workflowVariableBlock?.onManageInputField, showAssembleVariables, handleSelectAssembleVariables]) return ( void + showAssembleVariables?: boolean + onAssembleVariables?: () => void } export type AgentNode = { diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx index d44f560e08..a2b00a9506 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx @@ -10,6 +10,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows' import { CodeAssistant, MagicEdit } from '@/app/components/base/icons/src/vender/line/general' +import { MagicWand } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices' import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development' import Input from '@/app/components/base/input' import { @@ -255,6 +256,8 @@ type Props = { isInCodeGeneratorInstructionEditor?: boolean showManageInputField?: boolean onManageInputField?: () => void + showAssembleVariables?: boolean + onAssembleVariables?: () => void autoFocus?: boolean preferSchemaType?: boolean } @@ -272,6 +275,8 @@ const VarReferenceVars: FC = ({ isInCodeGeneratorInstructionEditor, showManageInputField, onManageInputField, + showAssembleVariables, + onAssembleVariables, autoFocus = true, preferSchemaType, }) => { @@ -285,6 +290,13 @@ const VarReferenceVars: FC = ({ } } + const handleAssembleVariables = (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + onAssembleVariables?.() + onClose?.() + } + const filteredVars = vars.filter((v) => { const children = v.vars.filter(v => checkKeys([v.variable], false).isValid || isSpecialVar(v.variable.split('.')[0])) return children.length > 0 @@ -393,6 +405,25 @@ const VarReferenceVars: FC = ({ /> ) } + { + showAssembleVariables && ( +
+ +
+ ) + } ) } diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index 315179cd9c..d31fe052cf 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -767,6 +767,7 @@ "nodes.templateTransform.inputVars": "Input Variables", "nodes.templateTransform.outputVars.output": "Transformed content", "nodes.tool.authorize": "Authorize", + "nodes.tool.assembleVariables": "Assemble variables", "nodes.tool.inputVars": "Input Variables", "nodes.tool.insertPlaceholder1": "Type or press", "nodes.tool.insertPlaceholder2": "insert variable", diff --git a/web/i18n/ja-JP/workflow.json b/web/i18n/ja-JP/workflow.json index f74a865eaf..02958405d6 100644 --- a/web/i18n/ja-JP/workflow.json +++ b/web/i18n/ja-JP/workflow.json @@ -765,6 +765,7 @@ "nodes.templateTransform.inputVars": "入力変数", "nodes.templateTransform.outputVars.output": "変換されたコンテンツ", "nodes.tool.authorize": "認証する", + "nodes.tool.assembleVariables": "変数を組み立てる", "nodes.tool.inputVars": "入力変数", "nodes.tool.insertPlaceholder1": "タイプするか押してください", "nodes.tool.insertPlaceholder2": "変数を挿入する", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index 7eb9f556dc..25c159f446 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -765,6 +765,7 @@ "nodes.templateTransform.inputVars": "输入变量", "nodes.templateTransform.outputVars.output": "转换后内容", "nodes.tool.authorize": "授权", + "nodes.tool.assembleVariables": "组装变量", "nodes.tool.inputVars": "输入变量", "nodes.tool.insertPlaceholder1": "键入", "nodes.tool.insertPlaceholder2": "插入变量", diff --git a/web/i18n/zh-Hant/workflow.json b/web/i18n/zh-Hant/workflow.json index cebfdd2feb..62162aaae3 100644 --- a/web/i18n/zh-Hant/workflow.json +++ b/web/i18n/zh-Hant/workflow.json @@ -765,6 +765,7 @@ "nodes.templateTransform.inputVars": "輸入變數", "nodes.templateTransform.outputVars.output": "轉換後內容", "nodes.tool.authorize": "授權", + "nodes.tool.assembleVariables": "組裝變數", "nodes.tool.inputVars": "輸入變數", "nodes.tool.insertPlaceholder1": "輸入或按壓", "nodes.tool.insertPlaceholder2": "插入變數", From 4ee49552ce7ec657c02407cdcd9b9892dec166a7 Mon Sep 17 00:00:00 2001 From: Novice Date: Fri, 16 Jan 2026 17:10:09 +0800 Subject: [PATCH 68/82] feat: add prompt variable message --- api/controllers/console/app/workflow_draft_variable.py | 4 +++- api/factories/variable_factory.py | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/api/controllers/console/app/workflow_draft_variable.py b/api/controllers/console/app/workflow_draft_variable.py index 3382b65acc..3ff388d330 100644 --- a/api/controllers/console/app/workflow_draft_variable.py +++ b/api/controllers/console/app/workflow_draft_variable.py @@ -17,7 +17,7 @@ from controllers.console.wraps import account_initialization_required, edit_perm from controllers.web.error import InvalidArgumentError, NotFoundError from core.file import helpers as file_helpers from core.variables.segment_group import SegmentGroup -from core.variables.segments import ArrayFileSegment, FileSegment, Segment +from core.variables.segments import ArrayFileSegment, ArrayPromptMessageSegment, FileSegment, Segment from core.variables.types import SegmentType from core.workflow.constants import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID from extensions.ext_database import db @@ -58,6 +58,8 @@ def _convert_values_to_json_serializable_object(value: Segment): return value.value.model_dump() elif isinstance(value, ArrayFileSegment): return [i.model_dump() for i in value.value] + elif isinstance(value, ArrayPromptMessageSegment): + return value.to_object() elif isinstance(value, SegmentGroup): return [_convert_values_to_json_serializable_object(i) for i in value.value] else: diff --git a/api/factories/variable_factory.py b/api/factories/variable_factory.py index 03a096b773..17cbb9cfdd 100644 --- a/api/factories/variable_factory.py +++ b/api/factories/variable_factory.py @@ -285,6 +285,10 @@ def build_segment_with_type(segment_type: SegmentType, value: Any) -> Segment: ): segment_class = _segment_factory[inferred_type] return segment_class(value_type=inferred_type, value=value) + elif segment_type == SegmentType.ARRAY_PROMPT_MESSAGE and inferred_type == SegmentType.ARRAY_OBJECT: + # PromptMessage serializes to dict, so ARRAY_OBJECT is compatible with ARRAY_PROMPT_MESSAGE + segment_class = _segment_factory[segment_type] + return segment_class(value_type=segment_type, value=value) else: raise TypeMismatchError(f"Type mismatch: expected {segment_type}, but got {inferred_type}, value={value}") From 77401e6f5c94abaf7b7115019de78edf15968a05 Mon Sep 17 00:00:00 2001 From: zhsama Date: Fri, 16 Jan 2026 18:21:43 +0800 Subject: [PATCH 69/82] feat: optimize variable picker styling and optimize agent nodes --- .../plugins/component-picker-block/index.tsx | 6 ++++-- .../_base/components/variable/var-reference-vars.tsx | 10 ++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx b/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx index 56a9c251bd..c7ed721dde 100644 --- a/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx +++ b/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx @@ -13,6 +13,7 @@ import type { WorkflowVariableBlockType, } from '../../types' import type { PickerBlockMenuOption } from './menu' +import type { AgentNode } from '@/app/components/base/prompt-editor/types' import { flip, offset, @@ -30,6 +31,7 @@ import { Fragment, memo, useCallback, + useMemo, useState, } from 'react' import ReactDOM from 'react-dom' @@ -195,8 +197,8 @@ const ComponentPicker = ({ }, [editor, checkForTriggerMatch, triggerString, agentBlock, handleClose]) const isAgentTrigger = triggerString === '@' && agentBlock?.show - const showAssembleVariables = triggerString === '/' && workflowVariableBlock?.showAssembleVariables - const agentNodes = agentBlock?.agentNodes || [] + const showAssembleVariables = triggerString === '/' + const agentNodes: AgentNode[] = useMemo(() => agentBlock?.agentNodes || [], [agentBlock?.agentNodes]) const renderMenu = useCallback>(( anchorElementRef, diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx index a2b00a9506..f5c159f63f 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx @@ -10,8 +10,8 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows' import { CodeAssistant, MagicEdit } from '@/app/components/base/icons/src/vender/line/general' -import { MagicWand } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices' import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development' +import { MagicWand } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices' import Input from '@/app/components/base/input' import { PortalToFollowElem, @@ -410,14 +410,12 @@ const VarReferenceVars: FC = ({
From 8d643e4b8512c111c88e45bf4a8eac8e3f16f251 Mon Sep 17 00:00:00 2001 From: zhsama Date: Fri, 16 Jan 2026 18:45:28 +0800 Subject: [PATCH 70/82] feat: add assemble variables icon --- .../line/general/assemble-variables.svg | 6 + .../line/general/AssembleVariables.json | 53 ++++++ .../vender/line/general/AssembleVariables.tsx | 20 +++ .../icons/src/vender/line/general/index.ts | 1 + .../variable/var-reference-vars.tsx | 7 +- .../agent-header-bar.tsx | 28 +-- .../mixed-variable-text-input/index.tsx | 169 ++++++++++++++---- 7 files changed, 236 insertions(+), 48 deletions(-) create mode 100644 web/app/components/base/icons/assets/vender/line/general/assemble-variables.svg create mode 100644 web/app/components/base/icons/src/vender/line/general/AssembleVariables.json create mode 100644 web/app/components/base/icons/src/vender/line/general/AssembleVariables.tsx diff --git a/web/app/components/base/icons/assets/vender/line/general/assemble-variables.svg b/web/app/components/base/icons/assets/vender/line/general/assemble-variables.svg new file mode 100644 index 0000000000..0575036fa9 --- /dev/null +++ b/web/app/components/base/icons/assets/vender/line/general/assemble-variables.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/web/app/components/base/icons/src/vender/line/general/AssembleVariables.json b/web/app/components/base/icons/src/vender/line/general/AssembleVariables.json new file mode 100644 index 0000000000..5db6132599 --- /dev/null +++ b/web/app/components/base/icons/src/vender/line/general/AssembleVariables.json @@ -0,0 +1,53 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "12", + "height": "12", + "viewBox": "0 0 12 12", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M2.91992 1.6875C3.23055 1.68754 3.48242 1.93937 3.48242 2.25C3.48242 2.56063 3.23055 2.81246 2.91992 2.8125C2.63855 2.8125 2.41064 3.04041 2.41064 3.32178V5.46436C2.41061 5.61344 2.35148 5.75637 2.24609 5.86182L2.10791 6L2.24609 6.13818C2.35148 6.24363 2.41061 6.38656 2.41064 6.53564V8.67822C2.41064 8.95959 2.63855 9.1875 2.91992 9.1875C3.23055 9.18754 3.48242 9.43937 3.48242 9.75C3.48242 10.0606 3.23055 10.3125 2.91992 10.3125C2.01723 10.3125 1.28564 9.58091 1.28564 8.67822V6.76855L0.914551 6.39795C0.809062 6.29246 0.75 6.14918 0.75 6C0.75 5.85082 0.809062 5.70754 0.914551 5.60205L1.28564 5.23145V3.32178C1.28564 2.41909 2.01723 1.6875 2.91992 1.6875Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M9.08008 1.6875C9.98276 1.68751 10.7144 2.41909 10.7144 3.32178V5.23145L11.085 5.60205C11.1904 5.70754 11.25 5.85082 11.25 6C11.25 6.14918 11.1904 6.29246 11.085 6.39795L10.7144 6.76855V8.67822C10.7144 9.58107 9.98213 10.3125 9.08008 10.3125C8.76942 10.3125 8.51758 10.0607 8.51758 9.75C8.51758 9.43934 8.76942 9.1875 9.08008 9.1875C9.36113 9.18749 9.58936 8.95943 9.58936 8.67822V6.53564C9.58939 6.38654 9.64849 6.24363 9.75391 6.13818L9.89209 6L9.75391 5.86182C9.64849 5.75637 9.58939 5.61346 9.58936 5.46436V3.32178C9.58936 3.04041 9.36144 2.81251 9.08008 2.8125C8.76942 2.8125 8.51758 2.56066 8.51758 2.25C8.51758 1.93934 8.76942 1.6875 9.08008 1.6875Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M5.24707 5.07715C5.36302 5.07715 5.46712 5.14866 5.50879 5.25684L5.8335 6.10059C5.88932 6.24563 6.00388 6.36018 6.14893 6.41602L6.99268 6.74072C7.10086 6.78238 7.17236 6.88648 7.17236 7.00244C7.17229 7.11832 7.10078 7.22202 6.99268 7.26367L6.14893 7.58838C6.00378 7.64424 5.88929 7.75912 5.8335 7.9043L5.50879 8.74756C5.46715 8.8558 5.36307 8.92725 5.24707 8.92725C5.13116 8.92717 5.02746 8.85572 4.98584 8.74756L4.66113 7.9043C4.60526 7.75904 4.49046 7.6442 4.34521 7.58838L3.50195 7.26367C3.39378 7.22205 3.32234 7.11835 3.32227 7.00244C3.32227 6.88645 3.39371 6.78236 3.50195 6.74072L4.34521 6.41602C4.49039 6.36022 4.60523 6.24573 4.66113 6.10059L4.98584 5.25684C5.02749 5.14874 5.13121 5.07723 5.24707 5.07715Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M6.89746 2.87744C6.98013 2.87754 7.05427 2.92822 7.08398 3.00537L7.29053 3.54297C7.34635 3.68816 7.46125 3.80302 7.60645 3.85889L8.14404 4.06543C8.22123 4.0952 8.27246 4.16966 8.27246 4.25244C8.27236 4.33513 8.22116 4.40922 8.14404 4.43896L7.60645 4.64551C7.46125 4.70138 7.34635 4.81624 7.29053 4.96143L7.08398 5.49902C7.05428 5.57614 6.98014 5.62734 6.89746 5.62744C6.81468 5.62744 6.74019 5.57622 6.71045 5.49902L6.50391 4.96143C6.44808 4.81624 6.33318 4.70138 6.18799 4.64551L5.65039 4.43896C5.57328 4.40922 5.52256 4.33513 5.52246 4.25244C5.52246 4.16966 5.5732 4.0952 5.65039 4.06543L6.18799 3.85889C6.33318 3.80302 6.44808 3.68816 6.50391 3.54297L6.71045 3.00537C6.74019 2.92814 6.81469 2.87744 6.89746 2.87744Z", + "fill": "currentColor" + }, + "children": [] + } + ] + }, + "name": "AssembleVariables" +} diff --git a/web/app/components/base/icons/src/vender/line/general/AssembleVariables.tsx b/web/app/components/base/icons/src/vender/line/general/AssembleVariables.tsx new file mode 100644 index 0000000000..40b72561f2 --- /dev/null +++ b/web/app/components/base/icons/src/vender/line/general/AssembleVariables.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import type { IconData } from '@/app/components/base/icons/IconBase' +import * as React from 'react' +import IconBase from '@/app/components/base/icons/IconBase' +import data from './AssembleVariables.json' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject> + }, +) => + +Icon.displayName = 'AssembleVariables' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/line/general/index.ts b/web/app/components/base/icons/src/vender/line/general/index.ts index 2409367264..e4d9b39cfe 100644 --- a/web/app/components/base/icons/src/vender/line/general/index.ts +++ b/web/app/components/base/icons/src/vender/line/general/index.ts @@ -1,4 +1,5 @@ export { default as AtSign } from './AtSign' +export { default as AssembleVariables } from './AssembleVariables' export { default as Bookmark } from './Bookmark' export { default as Check } from './Check' export { default as CheckDone01 } from './CheckDone01' diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx index a2b00a9506..f0e50014c9 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx @@ -9,8 +9,7 @@ import * as React from 'react' import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows' -import { CodeAssistant, MagicEdit } from '@/app/components/base/icons/src/vender/line/general' -import { MagicWand } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices' +import { AssembleVariables, CodeAssistant, MagicEdit } from '@/app/components/base/icons/src/vender/line/general' import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development' import Input from '@/app/components/base/input' import { @@ -414,8 +413,8 @@ const VarReferenceVars: FC = ({ onClick={handleAssembleVariables} onMouseDown={e => e.preventDefault()} > - - + + {t('nodes.tool.assembleVariables', { ns: 'workflow' })} diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/agent-header-bar.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/agent-header-bar.tsx index dcb46f78d2..ac757431c5 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/agent-header-bar.tsx +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/agent-header-bar.tsx @@ -11,6 +11,7 @@ type AgentHeaderBarProps = { onRemove: () => void onViewInternals?: () => void hasWarning?: boolean + showAtPrefix?: boolean } const AgentHeaderBar: FC = ({ @@ -18,6 +19,7 @@ const AgentHeaderBar: FC = ({ onRemove, onViewInternals, hasWarning, + showAtPrefix = true, }) => { const { t } = useTranslation() @@ -36,7 +38,7 @@ const AgentHeaderBar: FC = ({
- @ + {showAtPrefix && '@'} {agentName}
- + {onViewInternals && ( + + )}
) } diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx index 84aa94ba87..ecbf509950 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx @@ -2,6 +2,7 @@ import type { AgentNode, WorkflowVariableBlockType } from '@/app/components/base import type { StrategyDetail, StrategyPluginDetail } from '@/app/components/plugins/types' import type { MentionConfig, VarKindType } from '@/app/components/workflow/nodes/_base/types' import type { AgentNodeType } from '@/app/components/workflow/nodes/agent/types' +import type { CodeNodeType } from '@/app/components/workflow/nodes/code/types' import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types' import type { CommonNodeType, @@ -10,6 +11,7 @@ import type { PromptTemplateItem, ValueSelector, Node as WorkflowNode, + VarType, } from '@/app/components/workflow/types' import { memo, @@ -38,6 +40,11 @@ import Placeholder from './placeholder' * Example: {{@agent-123.context@}} -> captures "agent-123" */ const AGENT_CONTEXT_VAR_PATTERN = /\{\{[@#]([^.@#]+)\.context[@#]\}\}/g +const buildAssemblePlaceholder = (toolNodeId?: string, paramKey?: string) => { + if (!toolNodeId || !paramKey) + return '' + return `{{#${toolNodeId}_ext_${paramKey}.result#}}` +} const DEFAULT_MENTION_CONFIG: MentionConfig = { extractor_node_id: '', output_selector: [], @@ -166,6 +173,16 @@ const MixedVariableTextInput = ({ }, {} as Record) }, [availableNodes]) + const assemblePlaceholder = useMemo(() => { + return buildAssemblePlaceholder(toolNodeId, paramKey) + }, [paramKey, toolNodeId]) + + const isAssembleValue = useMemo(() => { + if (!assemblePlaceholder) + return false + return value.trim() === assemblePlaceholder + }, [assemblePlaceholder, value]) + const contextNodeIds = useMemo(() => { const ids = new Set() availableNodes.forEach((node) => { @@ -182,6 +199,12 @@ const MixedVariableTextInput = ({ }, {} as Record) }, [nodes]) + const assembleExtractorNodeId = useMemo(() => { + if (!toolNodeId || !paramKey) + return '' + return `${toolNodeId}_ext_${paramKey}` + }, [paramKey, toolNodeId]) + type DetectedAgent = { nodeId: string name: string @@ -278,6 +301,12 @@ const MixedVariableTextInput = ({ return agentWarning || extractorWarning }, [detectedAgentFromValue, getNodeWarning, nodesById, paramKey, toolNodeId]) + const hasAssembleWarning = useMemo(() => { + if (!isAssembleValue || !assembleExtractorNodeId) + return false + return getNodeWarning(nodesById[assembleExtractorNodeId]) + }, [assembleExtractorNodeId, getNodeWarning, isAssembleValue, nodesById]) + const syncExtractorPromptFromText = useCallback((text: string) => { if (!toolNodeId || !paramKey) return @@ -408,6 +437,70 @@ const MixedVariableTextInput = ({ setControlPromptEditorRerenderKey(Date.now()) }, [handleSyncWorkflowDraft, nodesMetaDataMap, onChange, paramKey, reactFlowStore, setControlPromptEditorRerenderKey, syncExtractorPromptFromText, toolNodeId, value]) + const handleAssembleSelect = useCallback(() => { + if (!onChange || !toolNodeId || !paramKey || !assemblePlaceholder) + return + + const defaultValue = nodesMetaDataMap?.[BlockEnum.Code]?.defaultValue as Partial | undefined + if (!defaultValue) + return + + const extractorNodeId = `${toolNodeId}_ext_${paramKey}` + const { getNodes, setNodes } = reactFlowStore.getState() + const currentNodes = getNodes() + const existingNode = currentNodes.find(node => node.id === extractorNodeId) + const shouldReplace = existingNode && existingNode.data.type !== BlockEnum.Code + const shouldCreate = !existingNode || shouldReplace + + if (shouldCreate) { + const nextNodes = shouldReplace + ? currentNodes.filter(node => node.id !== extractorNodeId) + : currentNodes + const { newNode } = generateNewNode({ + id: extractorNodeId, + type: getNodeCustomTypeByNodeDataType(BlockEnum.Code), + data: { + ...defaultValue, + type: BlockEnum.Code, + title: defaultValue?.title ?? '', + desc: defaultValue?.desc || '', + parent_node_id: toolNodeId, + outputs: { + result: { + type: VarType.string, + children: null, + }, + }, + }, + position: { + x: 0, + y: 0, + }, + hidden: true, + }) + setNodes([...nextNodes, newNode]) + handleSyncWorkflowDraft() + } + + const mentionConfigWithOutputSelector: MentionConfig = { + ...DEFAULT_MENTION_CONFIG, + extractor_node_id: extractorNodeId, + output_selector: ['result'], + } + onChange(assemblePlaceholder, VarKindTypeEnum.mention, mentionConfigWithOutputSelector) + setControlPromptEditorRerenderKey(Date.now()) + }, [assemblePlaceholder, handleSyncWorkflowDraft, nodesMetaDataMap, onChange, paramKey, reactFlowStore, setControlPromptEditorRerenderKey, toolNodeId]) + + const handleAssembleRemove = useCallback(() => { + if (!onChange || !assemblePlaceholder) + return + + const nextValue = value.replace(assemblePlaceholder, '') + removeExtractorNode() + onChange(nextValue, VarKindTypeEnum.mixed, null) + setControlPromptEditorRerenderKey(Date.now()) + }, [assemblePlaceholder, onChange, removeExtractorNode, setControlPromptEditorRerenderKey, value]) + const handleOpenSubGraphModal = useCallback(() => { setIsSubGraphModalOpen(true) }, []) @@ -427,7 +520,15 @@ const MixedVariableTextInput = ({ 'focus-within:border-components-input-border-active focus-within:bg-components-input-bg-active focus-within:shadow-xs', )} > - {detectedAgentFromValue && ( + {isAssembleValue && ( + + )} + {!isAssembleValue && detectedAgentFromValue && ( )} - 0 && !detectedAgentFromValue, - agentNodes, - onSelect: handleAgentSelect, - }} - placeholder={} - onChange={(text) => { - const hasPlaceholder = new RegExp(AGENT_CONTEXT_VAR_PATTERN.source).test(text) - if (hasPlaceholder) - syncExtractorPromptFromText(text) - if (detectedAgentFromValue && !hasPlaceholder) { - removeExtractorNode() - onChange?.(text, VarKindTypeEnum.mixed, null) - return - } - onChange?.(text) - }} - /> + {!isAssembleValue && ( + 0 && !detectedAgentFromValue, + agentNodes, + onSelect: handleAgentSelect, + }} + placeholder={} + onChange={(text) => { + const hasPlaceholder = new RegExp(AGENT_CONTEXT_VAR_PATTERN.source).test(text) + if (hasPlaceholder) + syncExtractorPromptFromText(text) + if (detectedAgentFromValue && !hasPlaceholder) { + removeExtractorNode() + onChange?.(text, VarKindTypeEnum.mixed, null) + return + } + onChange?.(text) + }} + /> + )} {toolNodeId && detectedAgentFromValue && sourceVariable && ( Date: Mon, 19 Jan 2026 14:59:08 +0800 Subject: [PATCH 71/82] fix: Fix assemble variables insertion in prompt editor --- .../plugins/component-picker-block/index.tsx | 8 +- .../components/base/prompt-editor/types.ts | 2 +- .../_base/components/form-input-item.tsx | 10 +- .../variable/var-reference-vars.tsx | 2 +- .../mixed-variable-text-input/index.tsx | 217 ++++++++++-------- .../components/workflow/nodes/tool/node.tsx | 2 +- 6 files changed, 141 insertions(+), 100 deletions(-) diff --git a/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx b/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx index c7ed721dde..8ff0ed5a02 100644 --- a/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx +++ b/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx @@ -14,6 +14,7 @@ import type { } from '../../types' import type { PickerBlockMenuOption } from './menu' import type { AgentNode } from '@/app/components/base/prompt-editor/types' +import type { ValueSelector } from '@/app/components/workflow/types' import { flip, offset, @@ -163,7 +164,7 @@ const ComponentPicker = ({ editor.dispatchCommand(KEY_ESCAPE_COMMAND, escapeEvent) }, [editor]) - const handleSelectAssembleVariables = useCallback(() => { + const handleSelectAssembleVariables = useCallback((): ValueSelector | null => { editor.update(() => { const match = checkForTriggerMatch(triggerString, editor) if (!match) @@ -172,8 +173,11 @@ const ComponentPicker = ({ if (needRemove) needRemove.remove() }) - workflowVariableBlock?.onAssembleVariables?.() + const assembleVariables = workflowVariableBlock?.onAssembleVariables?.() + if (assembleVariables && assembleVariables.length) + editor.dispatchCommand(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, assembleVariables) handleClose() + return assembleVariables ?? null }, [editor, checkForTriggerMatch, triggerString, workflowVariableBlock, handleClose]) const handleSelectAgent = useCallback((agent: { id: string, title: string }) => { diff --git a/web/app/components/base/prompt-editor/types.ts b/web/app/components/base/prompt-editor/types.ts index 81c0baaced..3b4565e5c6 100644 --- a/web/app/components/base/prompt-editor/types.ts +++ b/web/app/components/base/prompt-editor/types.ts @@ -72,7 +72,7 @@ export type WorkflowVariableBlockType = { showManageInputField?: boolean onManageInputField?: () => void showAssembleVariables?: boolean - onAssembleVariables?: () => void + onAssembleVariables?: () => ValueSelector | null } export type AgentNode = { diff --git a/web/app/components/workflow/nodes/_base/components/form-input-item.tsx b/web/app/components/workflow/nodes/_base/components/form-input-item.tsx index 661c69c4dc..b75a16a491 100644 --- a/web/app/components/workflow/nodes/_base/components/form-input-item.tsx +++ b/web/app/components/workflow/nodes/_base/components/form-input-item.tsx @@ -235,7 +235,15 @@ const FormInputItem: FC = ({ const handleValueChange = (newValue: any, newType?: VarKindType, mentionConfig?: MentionConfig | null) => { const normalizedValue = isNumber ? Number.parseFloat(newValue) : newValue - const resolvedType = newType ?? (varInput?.type === VarKindType.mention ? VarKindType.mention : getVarKindType()) + const assemblePlaceholder = nodeId && variable + ? `{{#${nodeId}_ext_${variable}.result#}}` + : '' + const isAssembleValue = typeof normalizedValue === 'string' + && assemblePlaceholder + && normalizedValue.includes(assemblePlaceholder) + const resolvedType = isAssembleValue + ? VarKindType.mixed + : newType ?? (varInput?.type === VarKindType.mention ? VarKindType.mention : getVarKindType()) const resolvedMentionConfig = resolvedType === VarKindType.mention ? (mentionConfig ?? varInput?.mention_config ?? { extractor_node_id: '', diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx index 16901ae37a..708ef3a77b 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx @@ -256,7 +256,7 @@ type Props = { showManageInputField?: boolean onManageInputField?: () => void showAssembleVariables?: boolean - onAssembleVariables?: () => void + onAssembleVariables?: () => ValueSelector | null autoFocus?: boolean preferSchemaType?: boolean } diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx index 5283583bf9..33eb6ae3d8 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx @@ -38,7 +38,8 @@ import Placeholder from './placeholder' * Matches agent context variable syntax: {{@nodeId.context@}} * Example: {{@agent-123.context@}} -> captures "agent-123" */ -const AGENT_CONTEXT_VAR_PATTERN = /\{\{[@#]([^.@#]+)\.context[@#]\}\}/g +const AGENT_CONTEXT_VAR_PATTERN = /\{\{@([^.@#]+)\.context@\}\}/g + const buildAssemblePlaceholder = (toolNodeId?: string, paramKey?: string) => { if (!toolNodeId || !paramKey) return '' @@ -179,7 +180,7 @@ const MixedVariableTextInput = ({ const isAssembleValue = useMemo(() => { if (!assemblePlaceholder) return false - return value.trim() === assemblePlaceholder + return value.includes(assemblePlaceholder) }, [assemblePlaceholder, value]) const contextNodeIds = useMemo(() => { @@ -204,6 +205,99 @@ const MixedVariableTextInput = ({ return `${toolNodeId}_ext_${paramKey}` }, [paramKey, toolNodeId]) + const ensureExtractorNode = useCallback((payload: { + extractorNodeId: string + nodeType: BlockEnum + data: Partial + }) => { + if (!toolNodeId) + return null + const defaultValue = nodesMetaDataMap?.[payload.nodeType]?.defaultValue as Partial | undefined + if (!defaultValue) + return null + + const { getNodes, setNodes } = reactFlowStore.getState() + const currentNodes = getNodes() + const existingNode = currentNodes.find(node => node.id === payload.extractorNodeId) + const shouldReplace = existingNode && existingNode.data.type !== payload.nodeType + if (!existingNode || shouldReplace) { + const nextNodes = shouldReplace + ? currentNodes.filter(node => node.id !== payload.extractorNodeId) + : currentNodes + const { newNode } = generateNewNode({ + id: payload.extractorNodeId, + type: getNodeCustomTypeByNodeDataType(payload.nodeType), + data: { + ...defaultValue, + ...payload.data, + type: payload.nodeType, + title: defaultValue?.title ?? '', + desc: defaultValue.desc || '', + parent_node_id: toolNodeId, + }, + position: { + x: 0, + y: 0, + }, + hidden: true, + }) + setNodes([...nextNodes, newNode]) + handleSyncWorkflowDraft() + return newNode + } + + return existingNode + }, [handleSyncWorkflowDraft, nodesMetaDataMap, reactFlowStore, toolNodeId]) + + const ensureAssembleExtractorNode = useCallback(() => { + if (!assembleExtractorNodeId) + return '' + const extractorNode = ensureExtractorNode({ + extractorNodeId: assembleExtractorNodeId, + nodeType: BlockEnum.Code, + data: { + outputs: { + result: { + type: VarType.string, + children: null, + }, + }, + }, + }) + if (!extractorNode) + return '' + if (extractorNode.data.type !== BlockEnum.Code) + return assembleExtractorNodeId + + const outputs = (extractorNode.data as CodeNodeType).outputs || {} + const resultOutput = outputs.result + if (!resultOutput || resultOutput.type !== VarType.string) { + const { getNodes, setNodes } = reactFlowStore.getState() + const currentNodes = getNodes() + const nextOutputs = { + ...outputs, + result: { + type: VarType.string, + children: null, + }, + } + setNodes(currentNodes.map((node) => { + if (node.id !== assembleExtractorNodeId) + return node + return { + ...node, + data: { + ...node.data, + outputs: nextOutputs, + }, + } + })) + handleSyncWorkflowDraft() + } + + return assembleExtractorNodeId + }, [assembleExtractorNodeId, ensureExtractorNode, handleSyncWorkflowDraft, reactFlowStore]) + type DetectedAgent = { nodeId: string name: string @@ -315,7 +409,7 @@ const MixedVariableTextInput = ({ return const escapedAgentId = detectedAgent.nodeId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const leadingPattern = new RegExp(`^\\{\\{[@#]${escapedAgentId}\\.context[@#]\\}\\}`) + const leadingPattern = new RegExp(`^\\{\\{@${escapedAgentId}\\.context@\\}\\}`) const promptText = text.replace(leadingPattern, '') const extractorNodeId = `${toolNodeId}_ext_${paramKey}` @@ -385,45 +479,25 @@ const MixedVariableTextInput = ({ const newValue = `{{@${agent.id}.context@}}${valueWithoutTrigger}` if (toolNodeId && paramKey) { - const extractorNodeId = `${toolNodeId}_ext_${paramKey}` - const defaultValue = nodesMetaDataMap?.[BlockEnum.LLM]?.defaultValue as Partial | undefined - const { getNodes, setNodes } = reactFlowStore.getState() - const nodes = getNodes() - const hasExtractorNode = nodes.some(node => node.id === extractorNodeId) - - if (!hasExtractorNode && defaultValue) { - const { newNode } = generateNewNode({ - id: extractorNodeId, - type: getNodeCustomTypeByNodeDataType(BlockEnum.LLM), - data: { - ...defaultValue, - type: BlockEnum.LLM, - title: defaultValue?.title ?? '', - desc: defaultValue.desc || '', - parent_node_id: toolNodeId, - structured_output_enabled: true, - structured_output: { - schema: { - type: Type.object, - properties: { - [paramKey]: { - type: Type.string, - }, + ensureExtractorNode({ + extractorNodeId: `${toolNodeId}_ext_${paramKey}`, + nodeType: BlockEnum.LLM, + data: { + structured_output_enabled: true, + structured_output: { + schema: { + type: Type.object, + properties: { + [paramKey]: { + type: Type.string, }, - required: [paramKey], - additionalProperties: false, }, + required: [paramKey], + additionalProperties: false, }, }, - position: { - x: 0, - y: 0, - }, - hidden: true, - }) - setNodes([...nodes, newNode]) - handleSyncWorkflowDraft() - } + }, + }) } const mentionConfigWithOutputSelector: MentionConfig = { @@ -434,71 +508,26 @@ const MixedVariableTextInput = ({ onChange(newValue, VarKindTypeEnum.mention, mentionConfigWithOutputSelector) syncExtractorPromptFromText(newValue) setControlPromptEditorRerenderKey(Date.now()) - }, [handleSyncWorkflowDraft, nodesMetaDataMap, onChange, paramKey, reactFlowStore, setControlPromptEditorRerenderKey, syncExtractorPromptFromText, toolNodeId, value]) + }, [ensureExtractorNode, onChange, paramKey, setControlPromptEditorRerenderKey, syncExtractorPromptFromText, toolNodeId, value]) - const handleAssembleSelect = useCallback(() => { - if (!onChange || !toolNodeId || !paramKey || !assemblePlaceholder) - return - - const defaultValue = nodesMetaDataMap?.[BlockEnum.Code]?.defaultValue as Partial | undefined - if (!defaultValue) - return - - const extractorNodeId = `${toolNodeId}_ext_${paramKey}` - const { getNodes, setNodes } = reactFlowStore.getState() - const currentNodes = getNodes() - const existingNode = currentNodes.find(node => node.id === extractorNodeId) - const shouldReplace = existingNode && existingNode.data.type !== BlockEnum.Code - const shouldCreate = !existingNode || shouldReplace - - if (shouldCreate) { - const nextNodes = shouldReplace - ? currentNodes.filter(node => node.id !== extractorNodeId) - : currentNodes - const { newNode } = generateNewNode({ - id: extractorNodeId, - type: getNodeCustomTypeByNodeDataType(BlockEnum.Code), - data: { - ...defaultValue, - type: BlockEnum.Code, - title: defaultValue?.title ?? '', - desc: defaultValue?.desc || '', - parent_node_id: toolNodeId, - outputs: { - result: { - type: VarType.string, - children: null, - }, - }, - }, - position: { - x: 0, - y: 0, - }, - hidden: true, - }) - setNodes([...nextNodes, newNode]) - handleSyncWorkflowDraft() - } - - const mentionConfigWithOutputSelector: MentionConfig = { - ...DEFAULT_MENTION_CONFIG, - extractor_node_id: extractorNodeId, - output_selector: ['result'], - } - onChange(assemblePlaceholder, VarKindTypeEnum.mention, mentionConfigWithOutputSelector) + const handleAssembleSelect = useCallback((): ValueSelector | null => { + if (!toolNodeId || !paramKey || !assemblePlaceholder) + return null + const extractorNodeId = assembleExtractorNodeId || `${toolNodeId}_ext_${paramKey}` + ensureAssembleExtractorNode() + onChange?.(assemblePlaceholder, VarKindTypeEnum.mixed, null) setControlPromptEditorRerenderKey(Date.now()) - }, [assemblePlaceholder, handleSyncWorkflowDraft, nodesMetaDataMap, onChange, paramKey, reactFlowStore, setControlPromptEditorRerenderKey, toolNodeId]) + return [extractorNodeId, 'result'] + }, [assembleExtractorNodeId, assemblePlaceholder, ensureAssembleExtractorNode, onChange, paramKey, setControlPromptEditorRerenderKey, toolNodeId]) const handleAssembleRemove = useCallback(() => { if (!onChange || !assemblePlaceholder) return - const nextValue = value.replace(assemblePlaceholder, '') removeExtractorNode() - onChange(nextValue, VarKindTypeEnum.mixed, null) + onChange('', VarKindTypeEnum.mixed, null) setControlPromptEditorRerenderKey(Date.now()) - }, [assemblePlaceholder, onChange, removeExtractorNode, setControlPromptEditorRerenderKey, value]) + }, [assemblePlaceholder, onChange, removeExtractorNode, setControlPromptEditorRerenderKey]) const handleOpenSubGraphModal = useCallback(() => { setIsSubGraphModalOpen(true) diff --git a/web/app/components/workflow/nodes/tool/node.tsx b/web/app/components/workflow/nodes/tool/node.tsx index 5c888b66a3..555bc1218f 100644 --- a/web/app/components/workflow/nodes/tool/node.tsx +++ b/web/app/components/workflow/nodes/tool/node.tsx @@ -20,7 +20,7 @@ import { useStrategyProviders } from '@/service/use-strategy' import { cn } from '@/utils/classnames' import { VarType } from './types' -const AGENT_CONTEXT_VAR_PATTERN = /\{\{[@#]([^.@#]+)\.context[@#]\}\}/g +const AGENT_CONTEXT_VAR_PATTERN = /\{\{@([^.@#]+)\.context@\}\}/g type AgentCheckValidContext = { provider?: StrategyPluginDetail strategy?: StrategyDetail From dbc70f8f05e9bdd1f2510a4737b8466acdc9d0c4 Mon Sep 17 00:00:00 2001 From: Novice Date: Mon, 19 Jan 2026 17:13:07 +0800 Subject: [PATCH 72/82] feat: add inner graph api --- api/controllers/console/app/workflow.py | 63 ++++++++ api/services/workflow/entities.py | 26 ++++ .../workflow/mention_graph_service.py | 140 ++++++++++++++++++ 3 files changed, 229 insertions(+) create mode 100644 api/services/workflow/mention_graph_service.py diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index b4f2ef0ba8..f3534b7e9a 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -46,6 +46,8 @@ from models.workflow import Workflow from services.app_generate_service import AppGenerateService from services.errors.app import WorkflowHashNotEqualError from services.errors.llm import InvokeRateLimitError +from services.workflow.entities import MentionGraphRequest, MentionParameterSchema +from services.workflow.mention_graph_service import MentionGraphService from services.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError, WorkflowService logger = logging.getLogger(__name__) @@ -188,6 +190,15 @@ class DraftWorkflowTriggerRunAllPayload(BaseModel): node_ids: list[str] +class MentionGraphPayload(BaseModel): + """Request payload for generating mention graph.""" + + parent_node_id: str = Field(description="ID of the parent node that uses the extracted value") + parameter_key: str = Field(description="Key of the parameter being extracted") + context_source: list[str] = Field(description="Variable selector for the context source") + parameter_schema: dict[str, Any] = Field(description="Schema of the parameter to extract") + + def reg(cls: type[BaseModel]): console_ns.schema_model(cls.__name__, cls.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) @@ -205,6 +216,7 @@ reg(WorkflowListQuery) reg(WorkflowUpdatePayload) reg(DraftWorkflowTriggerRunPayload) reg(DraftWorkflowTriggerRunAllPayload) +reg(MentionGraphPayload) # TODO(QuantumGhost): Refactor existing node run API to handle file parameter parsing @@ -1166,3 +1178,54 @@ class DraftWorkflowTriggerRunAllApi(Resource): "status": "error", } ), 400 + + +@console_ns.route("/apps//workflows/draft/mention-graph") +class MentionGraphApi(Resource): + """ + API for generating Mention LLM node graph structures. + + This endpoint creates a complete graph structure containing an LLM node + configured to extract values from list[PromptMessage] variables. + """ + + @console_ns.doc("generate_mention_graph") + @console_ns.doc(description="Generate a Mention LLM node graph structure") + @console_ns.doc(params={"app_id": "Application ID"}) + @console_ns.expect(console_ns.models[MentionGraphPayload.__name__]) + @console_ns.response(200, "Mention graph generated successfully") + @console_ns.response(400, "Invalid request parameters") + @console_ns.response(403, "Permission denied") + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) + @edit_permission_required + def post(self, app_model: App): + """ + Generate a Mention LLM node graph structure. + + Returns a complete graph structure containing a single LLM node + configured for extracting values from list[PromptMessage] context. + """ + + payload = MentionGraphPayload.model_validate(console_ns.payload or {}) + + parameter_schema = MentionParameterSchema( + name=payload.parameter_schema.get("name", payload.parameter_key), + type=payload.parameter_schema.get("type", "string"), + description=payload.parameter_schema.get("description", ""), + ) + + request = MentionGraphRequest( + parent_node_id=payload.parent_node_id, + parameter_key=payload.parameter_key, + context_source=payload.context_source, + parameter_schema=parameter_schema, + ) + + with Session(db.engine) as session: + service = MentionGraphService(session) + response = service.generate_mention_graph(tenant_id=app_model.tenant_id, request=request) + + return response.model_dump() diff --git a/api/services/workflow/entities.py b/api/services/workflow/entities.py index 70ec8d6e2a..cf5519527d 100644 --- a/api/services/workflow/entities.py +++ b/api/services/workflow/entities.py @@ -163,3 +163,29 @@ class WorkflowScheduleCFSPlanEntity(BaseModel): schedule_strategy: Strategy granularity: int = Field(default=-1) # -1 means infinite + + +# ========== Mention Graph Entities ========== + + +class MentionParameterSchema(BaseModel): + """Schema for the parameter to be extracted from mention context.""" + + name: str = Field(description="Parameter name (e.g., 'query')") + type: str = Field(default="string", description="Parameter type (e.g., 'string', 'number')") + description: str = Field(default="", description="Parameter description for LLM") + + +class MentionGraphRequest(BaseModel): + """Request payload for generating mention graph.""" + + parent_node_id: str = Field(description="ID of the parent node that uses the extracted value") + parameter_key: str = Field(description="Key of the parameter being extracted") + context_source: list[str] = Field(description="Variable selector for the context source") + parameter_schema: MentionParameterSchema = Field(description="Schema of the parameter to extract") + + +class MentionGraphResponse(BaseModel): + """Response containing the generated mention graph.""" + + graph: Mapping[str, Any] = Field(description="Complete graph structure with nodes, edges, viewport") diff --git a/api/services/workflow/mention_graph_service.py b/api/services/workflow/mention_graph_service.py new file mode 100644 index 0000000000..05b5c303cc --- /dev/null +++ b/api/services/workflow/mention_graph_service.py @@ -0,0 +1,140 @@ +""" +Service for generating Mention LLM node graph structures. + +This service creates graph structures containing LLM nodes configured for +extracting values from list[PromptMessage] variables. +""" + +from typing import Any + +from sqlalchemy.orm import Session + +from core.model_runtime.entities import LLMMode +from core.workflow.enums import NodeType +from services.model_provider_service import ModelProviderService +from services.workflow.entities import MentionGraphRequest, MentionGraphResponse, MentionParameterSchema + + +class MentionGraphService: + """Service for generating Mention LLM node graph structures.""" + + def __init__(self, session: Session): + self._session = session + + def generate_mention_node_id(self, node_id: str, parameter_name: str) -> str: + """Generate mention node ID following the naming convention. + + Format: {node_id}_ext_{parameter_name} + """ + return f"{node_id}_ext_{parameter_name}" + + def generate_mention_graph(self, tenant_id: str, request: MentionGraphRequest) -> MentionGraphResponse: + """Generate a complete graph structure containing a Mention LLM node. + + Args: + tenant_id: The tenant ID for fetching default model config + request: The mention graph generation request + + Returns: + Complete graph structure with nodes, edges, and viewport + """ + node_id = self.generate_mention_node_id(request.parent_node_id, request.parameter_key) + model_config = self._get_default_model_config(tenant_id) + node = self._build_mention_llm_node( + node_id=node_id, + parent_node_id=request.parent_node_id, + context_source=request.context_source, + parameter_schema=request.parameter_schema, + model_config=model_config, + ) + + graph = { + "nodes": [node], + "edges": [], + "viewport": {}, + } + + return MentionGraphResponse(graph=graph) + + def _get_default_model_config(self, tenant_id: str) -> dict[str, Any]: + """Get the default LLM model configuration for the tenant.""" + model_provider_service = ModelProviderService() + default_model = model_provider_service.get_default_model_of_model_type( + tenant_id=tenant_id, + model_type="llm", + ) + + if default_model: + return { + "provider": default_model.provider.provider, + "name": default_model.model, + "mode": LLMMode.CHAT.value, + "completion_params": {}, + } + + # Fallback to empty config if no default model is configured + return { + "provider": "", + "name": "", + "mode": LLMMode.CHAT.value, + "completion_params": {}, + } + + def _build_mention_llm_node( + self, + *, + node_id: str, + parent_node_id: str, + context_source: list[str], + parameter_schema: MentionParameterSchema, + model_config: dict[str, Any], + ) -> dict[str, Any]: + """Build the Mention LLM node structure. + + The node uses: + - $context in prompt_template to reference the PromptMessage list + - structured_output for extracting the specific parameter + - parent_node_id to associate with the parent node + """ + prompt_template = [ + { + "role": "system", + "text": "Extract the required parameter value from the conversation context above.", + }, + {"$context": context_source}, + {"role": "user", "text": ""}, + ] + + structured_output = { + "type": "object", + "properties": { + parameter_schema.name: { + "type": parameter_schema.type, + "description": parameter_schema.description, + } + }, + "required": [parameter_schema.name], + } + + return { + "id": node_id, + "position": {"x": 0, "y": 0}, + "data": { + "type": NodeType.LLM.value, + "title": f"Mention: {parameter_schema.name}", + "desc": f"Extract {parameter_schema.name} from conversation context", + "parent_node_id": parent_node_id, + "model": model_config, + "prompt_template": prompt_template, + "context": { + "enabled": False, + "variable_selector": None, + }, + "vision": { + "enabled": False, + }, + "memory": None, + "structured_output_enabled": True, + "structured_output": structured_output, + }, + } From 1bdc47220b28ad3a3a5fdd8f4a7566b1fd4d829b Mon Sep 17 00:00:00 2001 From: Novice Date: Mon, 19 Jan 2026 19:59:19 +0800 Subject: [PATCH 73/82] fix: mention graph config don't support structured output --- .../workflow/mention_graph_service.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/api/services/workflow/mention_graph_service.py b/api/services/workflow/mention_graph_service.py index 05b5c303cc..d0729c6d20 100644 --- a/api/services/workflow/mention_graph_service.py +++ b/api/services/workflow/mention_graph_service.py @@ -106,14 +106,17 @@ class MentionGraphService: ] structured_output = { - "type": "object", - "properties": { - parameter_schema.name: { - "type": parameter_schema.type, - "description": parameter_schema.description, - } - }, - "required": [parameter_schema.name], + "schema": { + "type": "object", + "properties": { + parameter_schema.name: { + "type": parameter_schema.type, + "description": parameter_schema.description, + } + }, + "required": [parameter_schema.name], + "additionalProperties": False, + } } return { From f44305af0d1e909e472af28595f0059cc3b23de4 Mon Sep 17 00:00:00 2001 From: zhsama Date: Mon, 19 Jan 2026 22:29:28 +0800 Subject: [PATCH 74/82] feat: add AssembleVariablesAlt icon and integrate into sub-graph components. --- .../line/general/AssembleVariablesAlt.json | 26 +++ .../line/general/AssembleVariablesAlt.tsx | 20 ++ .../icons/src/vender/line/general/index.ts | 1 + .../components/sub-graph-children.tsx | 54 +++-- .../sub-graph/components/sub-graph-main.tsx | 78 +++++--- web/app/components/sub-graph/index.tsx | 188 ++++++++++++------ web/app/components/sub-graph/types.ts | 32 ++- .../components/workflow/hooks-store/store.ts | 3 + web/app/components/workflow/index.tsx | 15 +- .../variable/var-reference-vars.tsx | 38 ++-- .../nodes/sub-graph-start/constants.ts | 1 + .../workflow/nodes/sub-graph-start/index.tsx | 60 ++++++ .../agent-header-bar.tsx | 8 +- .../mixed-variable-text-input/index.tsx | 34 +++- .../tool/components/sub-graph-modal/index.tsx | 156 ++++++++++----- .../sub-graph-modal/sub-graph-canvas.tsx | 34 +--- .../tool/components/sub-graph-modal/types.ts | 39 ++-- .../components/workflow/nodes/tool/node.tsx | 11 +- .../components/workflow/utils/elk-layout.ts | 2 + .../nodes/sub-graph-start/index.tsx | 60 ++++++ .../workflow/workflow-preview/index.tsx | 3 + 21 files changed, 611 insertions(+), 252 deletions(-) create mode 100644 web/app/components/base/icons/src/vender/line/general/AssembleVariablesAlt.json create mode 100644 web/app/components/base/icons/src/vender/line/general/AssembleVariablesAlt.tsx create mode 100644 web/app/components/workflow/nodes/sub-graph-start/constants.ts create mode 100644 web/app/components/workflow/nodes/sub-graph-start/index.tsx create mode 100644 web/app/components/workflow/workflow-preview/components/nodes/sub-graph-start/index.tsx diff --git a/web/app/components/base/icons/src/vender/line/general/AssembleVariablesAlt.json b/web/app/components/base/icons/src/vender/line/general/AssembleVariablesAlt.json new file mode 100644 index 0000000000..9823224134 --- /dev/null +++ b/web/app/components/base/icons/src/vender/line/general/AssembleVariablesAlt.json @@ -0,0 +1,26 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "12", + "height": "12", + "viewBox": "0 0 12 12", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M5.14286 5.14286V3.42857L8 5.71429L5.14286 8V6.28571H0V5.14286H5.14286ZM0.83303 7.42857H2.04658C2.72474 9.10389 4.36721 10.28571 6.28571 10.28571C8.81049 10.28571 10.85717 8.23903 10.85717 5.71429C10.85717 3.18956 8.81049 1.14285 6.28571 1.14285C4.36721 1.14285 2.72474 2.32467 2.04658 4H0.83303C1.56118 1.68165 3.72706 0 6.28571 0C9.4416 0 12 2.55837 12 5.71429C12 8.87014 9.4416 11.42854 6.28571 11.42854C3.72706 11.42854 1.56118 9.74691 0.83303 7.42857Z", + "fill": "currentColor" + }, + "children": [] + } + ] + }, + "name": "AssembleVariablesAlt" +} diff --git a/web/app/components/base/icons/src/vender/line/general/AssembleVariablesAlt.tsx b/web/app/components/base/icons/src/vender/line/general/AssembleVariablesAlt.tsx new file mode 100644 index 0000000000..980d9fc2b1 --- /dev/null +++ b/web/app/components/base/icons/src/vender/line/general/AssembleVariablesAlt.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import type { IconData } from '@/app/components/base/icons/IconBase' +import * as React from 'react' +import IconBase from '@/app/components/base/icons/IconBase' +import data from './AssembleVariablesAlt.json' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject> + }, +) => + +Icon.displayName = 'AssembleVariablesAlt' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/line/general/index.ts b/web/app/components/base/icons/src/vender/line/general/index.ts index 3ce1f86b62..90c37a6665 100644 --- a/web/app/components/base/icons/src/vender/line/general/index.ts +++ b/web/app/components/base/icons/src/vender/line/general/index.ts @@ -1,4 +1,5 @@ export { default as AssembleVariables } from './AssembleVariables' +export { default as AssembleVariablesAlt } from './AssembleVariablesAlt' export { default as AtSign } from './AtSign' export { default as Bookmark } from './Bookmark' export { default as Check } from './Check' diff --git a/web/app/components/sub-graph/components/sub-graph-children.tsx b/web/app/components/sub-graph/components/sub-graph-children.tsx index a8867d1963..4a18a66a1c 100644 --- a/web/app/components/sub-graph/components/sub-graph-children.tsx +++ b/web/app/components/sub-graph/components/sub-graph-children.tsx @@ -7,22 +7,28 @@ import { useShallow } from 'zustand/react/shallow' import { useIsChatMode, useWorkflowVariables } from '@/app/components/workflow/hooks' import Panel from '@/app/components/workflow/panel' import { useStore } from '@/app/components/workflow/store' -import { BlockEnum } from '@/app/components/workflow/types' import ConfigPanel from './config-panel' -type SubGraphChildrenProps = { - agentName: string - extractorNodeId: string - mentionConfig: MentionConfig - onMentionConfigChange: (config: MentionConfig) => void -} +type SubGraphChildrenProps + = | { + variant: 'agent' + title: string + extractorNodeId: string + mentionConfig: MentionConfig + onMentionConfigChange: (config: MentionConfig) => void + } + | { + variant: 'assemble' + title: string + extractorNodeId: string + } -const SubGraphChildren: FC = ({ - agentName, - extractorNodeId, - mentionConfig, - onMentionConfigChange, -}) => { +const SubGraphChildren: FC = (props) => { + const { + variant, + title, + extractorNodeId, + } = props const { getNodeAvailableVars } = useWorkflowVariables() const isChatMode = useIsChatMode() const nodePanelWidth = useStore(s => s.nodePanelWidth) @@ -32,7 +38,7 @@ const SubGraphChildren: FC = ({ })) const extractorNode = useReactFlowStore(useShallow((s) => { - return s.getNodes().find(node => node.data.type === BlockEnum.LLM) + return s.getNodes().find(node => node.id === extractorNodeId) })) const availableNodes = useMemo(() => { @@ -51,8 +57,10 @@ const SubGraphChildren: FC = ({ return vars.filter(item => item.nodeId === extractorNode.id) }, [extractorNode, getNodeAvailableVars, isChatMode]) + const agentProps = variant === 'agent' ? props : null + const panelRight = useMemo(() => { - if (selectedNode) + if (!agentProps || selectedNode) return null return ( @@ -62,17 +70,25 @@ const SubGraphChildren: FC = ({ style={{ width: `${nodePanelWidth}px` }} >
) - }, [agentName, availableNodes, availableVars, extractorNodeId, mentionConfig, nodePanelWidth, onMentionConfigChange, selectedNode]) + }, [agentProps, availableNodes, availableVars, extractorNodeId, nodePanelWidth, selectedNode, title]) + + if (variant === 'assemble') { + return ( + + ) + } return ( void + selectableNodeTypes?: BlockEnum[] onSave?: (nodes: Node[], edges: Edge[]) => void onSyncWorkflowDraft?: SyncWorkflowDraft } -const SubGraphMain: FC = ({ - nodes, - edges, - viewport, - agentName, - extractorNodeId, - configsMap, - mentionConfig, - onMentionConfigChange, - onSave, - onSyncWorkflowDraft, -}) => { +type SubGraphMainProps + = | (SubGraphMainBaseProps & { + variant: 'agent' + mentionConfig: MentionConfig + onMentionConfigChange: (config: MentionConfig) => void + }) + | (SubGraphMainBaseProps & { + variant: 'assemble' + }) + +const SubGraphMain: FC = (props) => { + const { + nodes, + edges, + viewport, + variant, + title, + extractorNodeId, + configsMap, + selectableNodeTypes, + onSave, + onSyncWorkflowDraft, + } = props const reactFlowStore = useStoreApi() const availableNodesMetaData = useAvailableNodesMetaData() const flowType = configsMap?.flowType ?? FlowType.appFlow @@ -76,32 +87,53 @@ const SubGraphMain: FC = ({ } }, [handleSyncSubGraphDraft, onSyncWorkflowDraft]) + const resolvedSelectableTypes = useMemo(() => { + if (selectableNodeTypes && selectableNodeTypes.length > 0) + return selectableNodeTypes + return variant === 'agent' ? [BlockEnum.LLM] : [BlockEnum.Code] + }, [selectableNodeTypes, variant]) + const hooksStore = useMemo(() => ({ interactionMode: 'subgraph', + subGraphSelectableNodeTypes: resolvedSelectableTypes, availableNodesMetaData, configsMap, fetchInspectVars, ...inspectVarsCrud, doSyncWorkflowDraft: handleSyncWorkflowDraft, syncWorkflowDraftWhenPageClose: handleSyncSubGraphDraft, - }), [availableNodesMetaData, configsMap, fetchInspectVars, handleSyncSubGraphDraft, handleSyncWorkflowDraft, inspectVarsCrud]) + }), [availableNodesMetaData, configsMap, fetchInspectVars, handleSyncSubGraphDraft, handleSyncWorkflowDraft, inspectVarsCrud, resolvedSelectableTypes]) + + const subGraphChildren = variant === 'agent' + ? ( + + ) + : ( + + ) return ( - + {subGraphChildren} ) } diff --git a/web/app/components/sub-graph/index.tsx b/web/app/components/sub-graph/index.tsx index c136cc929b..28adbac608 100644 --- a/web/app/components/sub-graph/index.tsx +++ b/web/app/components/sub-graph/index.tsx @@ -7,6 +7,7 @@ import { memo, useEffect, useMemo } from 'react' import WorkflowWithDefaultContext from '@/app/components/workflow' import { NODE_WIDTH_X_OFFSET, START_INITIAL_POSITION } from '@/app/components/workflow/constants' import { WorkflowContextProvider } from '@/app/components/workflow/context' +import { CUSTOM_SUB_GRAPH_START_NODE } from '@/app/components/workflow/nodes/sub-graph-start/constants' import { useStore } from '@/app/components/workflow/store' import { BlockEnum, EditionType, isPromptMessageContext, PromptRole } from '@/app/components/workflow/types' import SubGraphMain from './components/sub-graph-main' @@ -18,7 +19,7 @@ const SUB_GRAPH_ENTRY_POSITION = { x: START_INITIAL_POSITION.x, y: 150, } -const SUB_GRAPH_LLM_POSITION = { +const SUB_GRAPH_EXTRACTOR_POSITION = { x: SUB_GRAPH_ENTRY_POSITION.x + NODE_WIDTH_X_OFFSET - SUB_GRAPH_EDGE_GAP, y: SUB_GRAPH_ENTRY_POSITION.y, } @@ -33,19 +34,19 @@ const SubGraphContent: FC = (props) => { const { toolNodeId, paramKey, - agentName, - agentNodeId, - mentionConfig, - onMentionConfigChange, - extractorNode, toolParamValue, parentAvailableNodes, parentAvailableVars, configsMap, + selectableNodeTypes, onSave, onSyncWorkflowDraft, } = props + const isAgentVariant = props.variant === 'agent' + const sourceTitle = isAgentVariant ? (props.agentName || '') : (props.title || '') + const resolvedAgentNodeId = isAgentVariant ? props.agentNodeId : '' + const setParentAvailableVars = useStore(state => state.setParentAvailableVars) const setParentAvailableNodes = useStore(state => state.setParentAvailableNodes) @@ -55,28 +56,47 @@ const SubGraphContent: FC = (props) => { }, [parentAvailableNodes, parentAvailableVars, setParentAvailableNodes, setParentAvailableVars]) const promptText = useMemo(() => { - if (!toolParamValue) + if (!isAgentVariant || !toolParamValue) return '' // Reason: escape agent id before building a regex pattern. - const escapedAgentId = agentNodeId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const escapedAgentId = resolvedAgentNodeId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const leadingPattern = new RegExp(`^\\{\\{[@#]${escapedAgentId}\\.context[@#]\\}\\}`) return toolParamValue.replace(leadingPattern, '') - }, [agentNodeId, toolParamValue]) + }, [isAgentVariant, resolvedAgentNodeId, toolParamValue]) const startNode = useMemo(() => { + if (!isAgentVariant) { + return { + id: 'subgraph-source', + type: CUSTOM_SUB_GRAPH_START_NODE, + position: SUB_GRAPH_ENTRY_POSITION, + data: { + type: BlockEnum.Start, + title: sourceTitle, + desc: '', + selected: false, + iconType: 'assemble', + variables: [], + }, + selected: false, + selectable: false, + draggable: false, + connectable: false, + focusable: false, + deletable: false, + } + } + return { id: 'subgraph-source', - type: 'custom', + type: CUSTOM_SUB_GRAPH_START_NODE, position: SUB_GRAPH_ENTRY_POSITION, data: { type: BlockEnum.Start, - title: agentName, + title: sourceTitle, desc: '', - _connectedSourceHandleIds: ['source'], - _connectedTargetHandleIds: [], - _subGraphEntry: true, - _iconTypeOverride: BlockEnum.Agent, selected: false, + iconType: 'agent', variables: [], }, selected: false, @@ -86,65 +106,83 @@ const SubGraphContent: FC = (props) => { focusable: false, deletable: false, } - }, [agentName]) + }, [isAgentVariant, sourceTitle]) const extractorDisplayNode = useMemo(() => { - if (!extractorNode) - return null + if (isAgentVariant) { + const extractorNode = props.extractorNode + if (!extractorNode) + return null - const applyPromptText = (item: PromptItem) => { - if (item.edition_type === EditionType.jinja2) { - return { - ...item, - text: promptText, - jinja2_text: promptText, - } - } - return { ...item, text: promptText } - } - - const nextPromptTemplate = (() => { - const template = extractorNode.data.prompt_template - if (!Array.isArray(template)) - return applyPromptText(template as PromptItem) - - const userIndex = template.findIndex( - item => !isPromptMessageContext(item) && (item as PromptItem).role === PromptRole.user, - ) - if (userIndex >= 0) { - return template.map((item, index) => { - if (index !== userIndex) - return item - return applyPromptText(item as PromptItem) - }) as PromptTemplateItem[] - } - - const useJinja = template.some( - item => !isPromptMessageContext(item) && (item as PromptItem).edition_type === EditionType.jinja2, - ) - const defaultUserPrompt: PromptItem = useJinja - ? { - role: PromptRole.user, + const applyPromptText = (item: PromptItem) => { + if (item.edition_type === EditionType.jinja2) { + return { + ...item, text: promptText, jinja2_text: promptText, - edition_type: EditionType.jinja2, } - : { role: PromptRole.user, text: promptText } - return [...template, defaultUserPrompt] as PromptTemplateItem[] - })() + } + return { ...item, text: promptText } + } + + const nextPromptTemplate = (() => { + const template = extractorNode.data.prompt_template + if (!Array.isArray(template)) + return applyPromptText(template as PromptItem) + + const userIndex = template.findIndex( + item => !isPromptMessageContext(item) && (item as PromptItem).role === PromptRole.user, + ) + if (userIndex >= 0) { + return template.map((item, index) => { + if (index !== userIndex) + return item + return applyPromptText(item as PromptItem) + }) as PromptTemplateItem[] + } + + const useJinja = template.some( + item => !isPromptMessageContext(item) && (item as PromptItem).edition_type === EditionType.jinja2, + ) + const defaultUserPrompt: PromptItem = useJinja + ? { + role: PromptRole.user, + text: promptText, + jinja2_text: promptText, + edition_type: EditionType.jinja2, + } + : { role: PromptRole.user, text: promptText } + return [...template, defaultUserPrompt] as PromptTemplateItem[] + })() + + return { + ...extractorNode, + hidden: false, + selected: false, + position: SUB_GRAPH_EXTRACTOR_POSITION, + data: { + ...extractorNode.data, + selected: false, + prompt_template: nextPromptTemplate, + }, + } + } + + const extractorNode = props.extractorNode + if (!extractorNode) + return null return { ...extractorNode, hidden: false, selected: false, - position: SUB_GRAPH_LLM_POSITION, + position: SUB_GRAPH_EXTRACTOR_POSITION, data: { ...extractorNode.data, selected: false, - prompt_template: nextPromptTemplate, }, } - }, [extractorNode, promptText]) + }, [isAgentVariant, promptText, props.extractorNode]) const nodesSource = useMemo(() => { if (!extractorDisplayNode) @@ -168,30 +206,54 @@ const SubGraphContent: FC = (props) => { selectable: false, data: { sourceType: BlockEnum.Start, - targetType: BlockEnum.LLM, + targetType: isAgentVariant ? BlockEnum.LLM : BlockEnum.Code, _isTemp: true, _isSubGraphTemp: true, }, }, ] - }, [extractorDisplayNode, startNode]) + }, [extractorDisplayNode, isAgentVariant, startNode]) const { nodes, edges } = useSubGraphNodes(nodesSource, edgesSource) + if (isAgentVariant) { + return ( + + + + ) + } + return ( diff --git a/web/app/components/sub-graph/types.ts b/web/app/components/sub-graph/types.ts index 2ffd7f91eb..94e3b4584d 100644 --- a/web/app/components/sub-graph/types.ts +++ b/web/app/components/sub-graph/types.ts @@ -1,8 +1,9 @@ import type { StateCreator } from 'zustand' import type { Shape as HooksStoreShape } from '@/app/components/workflow/hooks-store' import type { MentionConfig } from '@/app/components/workflow/nodes/_base/types' +import type { CodeNodeType } from '@/app/components/workflow/nodes/code/types' import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types' -import type { Edge, Node, NodeOutPutVar, ValueSelector } from '@/app/components/workflow/types' +import type { BlockEnum, Edge, Node, NodeOutPutVar, ValueSelector } from '@/app/components/workflow/types' export type SyncWorkflowDraftCallback = { onSuccess?: () => void @@ -15,23 +16,38 @@ export type SyncWorkflowDraft = ( callback?: SyncWorkflowDraftCallback, ) => Promise -export type SubGraphProps = { +export type SubGraphVariant = 'agent' | 'assemble' + +type BaseSubGraphProps = { toolNodeId: string paramKey: string - sourceVariable: ValueSelector - agentNodeId: string - agentName: string configsMap?: HooksStoreShape['configsMap'] - mentionConfig: MentionConfig - onMentionConfigChange: (config: MentionConfig) => void - extractorNode?: Node toolParamValue?: string parentAvailableNodes?: Node[] parentAvailableVars?: NodeOutPutVar[] + selectableNodeTypes?: BlockEnum[] onSave?: (nodes: Node[], edges: Edge[]) => void onSyncWorkflowDraft?: SyncWorkflowDraft } +export type AgentSubGraphProps = BaseSubGraphProps & { + variant: 'agent' + sourceVariable: ValueSelector + agentNodeId: string + agentName: string + mentionConfig: MentionConfig + onMentionConfigChange: (config: MentionConfig) => void + extractorNode?: Node +} + +export type AssembleSubGraphProps = BaseSubGraphProps & { + variant: 'assemble' + title: string + extractorNode?: Node +} + +export type SubGraphProps = AgentSubGraphProps | AssembleSubGraphProps + export type SubGraphSliceShape = { parentAvailableVars: NodeOutPutVar[] parentAvailableNodes: Node[] diff --git a/web/app/components/workflow/hooks-store/store.ts b/web/app/components/workflow/hooks-store/store.ts index 989c5e5063..716d77b7a7 100644 --- a/web/app/components/workflow/hooks-store/store.ts +++ b/web/app/components/workflow/hooks-store/store.ts @@ -46,6 +46,7 @@ export type CommonHooksFnMap = { handleWorkflowTriggerWebhookRunInWorkflow: (params: { nodeId: string }) => void handleWorkflowTriggerPluginRunInWorkflow: (nodeId?: string) => void handleWorkflowRunAllTriggersInWorkflow: (nodeIds: string[]) => void + subGraphSelectableNodeTypes?: BlockEnum[] availableNodesMetaData?: AvailableNodesMetaData getWorkflowRunAndTraceUrl: (runId?: string) => { runUrl: string, traceUrl: string } exportCheck?: () => Promise @@ -93,6 +94,7 @@ export const createHooksStore = ({ handleWorkflowTriggerWebhookRunInWorkflow = noop, handleWorkflowTriggerPluginRunInWorkflow = noop, handleWorkflowRunAllTriggersInWorkflow = noop, + subGraphSelectableNodeTypes, availableNodesMetaData = { nodes: [], }, @@ -136,6 +138,7 @@ export const createHooksStore = ({ handleWorkflowTriggerWebhookRunInWorkflow, handleWorkflowTriggerPluginRunInWorkflow, handleWorkflowRunAllTriggersInWorkflow, + subGraphSelectableNodeTypes, availableNodesMetaData, getWorkflowRunAndTraceUrl, exportCheck, diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx index f0846f2996..185bd9c34a 100644 --- a/web/app/components/workflow/index.tsx +++ b/web/app/components/workflow/index.tsx @@ -89,6 +89,8 @@ import CustomIterationStartNode from './nodes/iteration-start' import { CUSTOM_ITERATION_START_NODE } from './nodes/iteration-start/constants' import CustomLoopStartNode from './nodes/loop-start' import { CUSTOM_LOOP_START_NODE } from './nodes/loop-start/constants' +import CustomSubGraphStartNode from './nodes/sub-graph-start' +import { CUSTOM_SUB_GRAPH_START_NODE } from './nodes/sub-graph-start/constants' import CustomNoteNode from './note-node' import { CUSTOM_NOTE_NODE } from './note-node/constants' import Operator from './operator' @@ -119,6 +121,7 @@ const nodeTypes = { [CUSTOM_NODE]: CustomNode, [CUSTOM_NOTE_NODE]: CustomNoteNode, [CUSTOM_SIMPLE_NODE]: CustomSimpleNode, + [CUSTOM_SUB_GRAPH_START_NODE]: CustomSubGraphStartNode, [CUSTOM_ITERATION_START_NODE]: CustomIterationStartNode, [CUSTOM_LOOP_START_NODE]: CustomLoopStartNode, [CUSTOM_DATA_SOURCE_EMPTY_NODE]: CustomDataSourceEmptyNode, @@ -355,6 +358,7 @@ export const Workflow: FC = memo(({ const dataSourceList = useStore(s => s.dataSourceList) // buildInTools, customTools, workflowTools, mcpTools, dataSourceList const configsMap = useHooksStore(s => s.configsMap) + const subGraphSelectableNodeTypes = useHooksStore(s => s.subGraphSelectableNodeTypes) const [isLoadedVars, setIsLoadedVars] = useState(false) const [vars, setVars] = useState([]) useEffect(() => { @@ -393,12 +397,17 @@ export const Workflow: FC = memo(({ const handleNodeClickInMode = useCallback( (event, node) => { - if (isSubGraph && node.data.type !== BlockEnum.LLM) - return + if (isSubGraph) { + const allowTypes = subGraphSelectableNodeTypes?.length + ? subGraphSelectableNodeTypes + : [BlockEnum.LLM] + if (!allowTypes.includes(node.data.type)) + return + } handleNodeClick(event, node) }, - [handleNodeClick, isSubGraph], + [handleNodeClick, isSubGraph, subGraphSelectableNodeTypes], ) return ( diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx index 708ef3a77b..745b383305 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx @@ -351,6 +351,25 @@ const VarReferenceVars: FC = ({ ) } + { + showAssembleVariables && ( +
+ +
+ ) + } {filteredVars.length > 0 ? (
@@ -404,25 +423,6 @@ const VarReferenceVars: FC = ({ /> ) } - { - showAssembleVariables && ( -
- -
- ) - } ) } diff --git a/web/app/components/workflow/nodes/sub-graph-start/constants.ts b/web/app/components/workflow/nodes/sub-graph-start/constants.ts new file mode 100644 index 0000000000..4cb8c08038 --- /dev/null +++ b/web/app/components/workflow/nodes/sub-graph-start/constants.ts @@ -0,0 +1 @@ +export const CUSTOM_SUB_GRAPH_START_NODE = 'custom-sub-graph-start' diff --git a/web/app/components/workflow/nodes/sub-graph-start/index.tsx b/web/app/components/workflow/nodes/sub-graph-start/index.tsx new file mode 100644 index 0000000000..b5fff65994 --- /dev/null +++ b/web/app/components/workflow/nodes/sub-graph-start/index.tsx @@ -0,0 +1,60 @@ +import type { NodeProps } from 'reactflow' +import type { CommonNodeType } from '@/app/components/workflow/types' +import { memo } from 'react' +import { useTranslation } from 'react-i18next' +import { AssembleVariablesAlt } from '@/app/components/base/icons/src/vender/line/general' +import { Agent } from '@/app/components/base/icons/src/vender/workflow' +import Tooltip from '@/app/components/base/tooltip' +import { NodeSourceHandle } from '@/app/components/workflow/nodes/_base/components/node-handle' +import { cn } from '@/utils/classnames' + +type SubGraphStartNodeData = CommonNodeType<{ + tooltip?: string + iconType?: string +}> + +type IconComponent = typeof Agent + +const iconMap: Record = { + agent: Agent, + assemble: AssembleVariablesAlt, +} + +const SubGraphStartNode = ({ id, data }: NodeProps) => { + const { t } = useTranslation() + const iconType = data?.iconType || 'agent' + const Icon = iconMap[iconType] || Agent + const rawTitle = data?.title?.trim() || '' + const showTitle = iconType === 'agent' && !!rawTitle + const displayTitle = showTitle && (rawTitle.startsWith('@') ? rawTitle : `@${rawTitle}`) + const tooltip = data?.tooltip + || (iconType === 'assemble' ? t('blocks.start', { ns: 'workflow' }) : (data?.title || t('blocks.start', { ns: 'workflow' }))) + + return ( +
+ +
+ +
+
+ {showTitle && ( + + {displayTitle} + + )} + +
+ ) +} + +export default memo(SubGraphStartNode) diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/agent-header-bar.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/agent-header-bar.tsx index ac757431c5..b4445a747d 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/agent-header-bar.tsx +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/agent-header-bar.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react' import { RiCloseLine, RiEqualizer2Line } from '@remixicon/react' import { memo } from 'react' import { useTranslation } from 'react-i18next' +import { AssembleVariables } from '@/app/components/base/icons/src/vender/line/general' import AlertTriangle from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback/AlertTriangle' import { Agent } from '@/app/components/base/icons/src/vender/workflow' import { cn } from '@/utils/classnames' @@ -34,8 +35,11 @@ const AgentHeaderBar: FC = ({ : 'border-components-panel-border-subtle bg-components-badge-white-to-dark', )} > -
- +
+ {showAtPrefix ? : }
{showAtPrefix && '@'} diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx index 33eb6ae3d8..1527a27178 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx @@ -36,9 +36,16 @@ import Placeholder from './placeholder' /** * Matches agent context variable syntax: {{@nodeId.context@}} - * Example: {{@agent-123.context@}} -> captures "agent-123" + * Example: {{@agent-123.context@}} */ -const AGENT_CONTEXT_VAR_PATTERN = /\{\{@([^.@#]+)\.context@\}\}/g +const AGENT_CONTEXT_VAR_PATTERN = /\{\{@[^.@#]+\.context@\}\}/g +const AGENT_CONTEXT_VAR_PREFIX = '{{@' +const AGENT_CONTEXT_VAR_SUFFIX = '.context@}}' +const getAgentNodeIdFromContextVar = (placeholder: string) => { + if (!placeholder.startsWith(AGENT_CONTEXT_VAR_PREFIX) || !placeholder.endsWith(AGENT_CONTEXT_VAR_SUFFIX)) + return '' + return placeholder.slice(AGENT_CONTEXT_VAR_PREFIX.length, -AGENT_CONTEXT_VAR_SUFFIX.length) +} const buildAssemblePlaceholder = (toolNodeId?: string, paramKey?: string) => { if (!toolNodeId || !paramKey) @@ -309,8 +316,9 @@ const MixedVariableTextInput = ({ const matches = text.matchAll(AGENT_CONTEXT_VAR_PATTERN) for (const match of matches) { - const variablePath = match[1] - const nodeId = variablePath.split('.')[0] + const nodeId = getAgentNodeIdFromContextVar(match[0]) + if (!nodeId) + continue const node = nodesByIdMap[nodeId] if (node && contextNodeIds.has(nodeId)) { return { @@ -461,8 +469,8 @@ const MixedVariableTextInput = ({ if (!agentNodeId || !onChange) return - const valueWithoutAgentVars = value.replace(AGENT_CONTEXT_VAR_PATTERN, (match, variablePath) => { - const nodeId = variablePath.split('.')[0] + const valueWithoutAgentVars = value.replace(AGENT_CONTEXT_VAR_PATTERN, (match) => { + const nodeId = getAgentNodeIdFromContextVar(match) return nodeId === agentNodeId ? '' : match }) @@ -552,6 +560,7 @@ const MixedVariableTextInput = ({ @@ -599,10 +608,21 @@ const MixedVariableTextInput = ({ }} /> )} - {toolNodeId && detectedAgentFromValue && sourceVariable && ( + {toolNodeId && paramKey && isAssembleValue && ( + )} + {toolNodeId && paramKey && !isAssembleValue && detectedAgentFromValue && sourceVariable && ( + = ({ - isOpen, - onClose, - toolNodeId, - paramKey, - sourceVariable, - agentName, - agentNodeId, -}) => { +const SubGraphModal: FC = (props) => { const { t } = useTranslation() + const { isOpen, onClose, variant, toolNodeId, paramKey } = props + const isAgentVariant = variant === 'agent' + const resolvedAgentNodeId = isAgentVariant ? props.agentNodeId : '' + const agentName = isAgentVariant ? props.agentName : '' + const assembleTitle = !isAgentVariant ? props.title : '' + const modalTitle = useMemo(() => { + const baseTitle = isAgentVariant + ? agentName + : (assembleTitle || t('nodes.tool.assembleVariables', { ns: 'workflow' })) + const prefix = isAgentVariant && baseTitle ? '@' : '' + return `${prefix}${baseTitle} ${t('subGraphModal.title', { ns: 'workflow' })}`.trim() + }, [agentName, assembleTitle, isAgentVariant, t]) const reactflowStore = useStoreApi() const workflowNodes = useWorkflowStore(state => state.nodes) const workflowEdges = useReactFlowStore(state => state.edges) @@ -41,13 +47,16 @@ const SubGraphModal: FC = ({ const extractorNodeId = `${toolNodeId}_ext_${paramKey}` const extractorNode = useMemo(() => { - return workflowNodes.find(node => node.id === extractorNodeId) as Node | undefined + return workflowNodes.find(node => node.id === extractorNodeId) as Node | undefined }, [extractorNodeId, workflowNodes]) const toolNode = useMemo(() => { return workflowNodes.find(node => node.id === toolNodeId) }, [toolNodeId, workflowNodes]) const toolParam = (toolNode?.data as ToolNodeType | undefined)?.tool_parameters?.[paramKey] const toolParamValue = toolParam?.value as string | undefined + const assemblePlaceholder = useMemo(() => { + return `{{#${toolNodeId}_ext_${paramKey}.result#}}` + }, [paramKey, toolNodeId]) const parentBeforeNodes = useMemo(() => { if (!isOpen) @@ -56,25 +65,28 @@ const SubGraphModal: FC = ({ }, [getBeforeNodesInSameBranch, isOpen, toolNodeId, workflowEdges, workflowNodes]) const parentContextNodes = useMemo(() => { - if (!parentBeforeNodes.length) + if (!parentBeforeNodes.length || !isAgentVariant) return [] return parentBeforeNodes.filter(node => node.data.type === BlockEnum.Agent || node.data.type === BlockEnum.LLM) - }, [parentBeforeNodes]) + }, [isAgentVariant, parentBeforeNodes]) - const parentContextNodeIds = useMemo(() => { - return parentContextNodes.map(node => node.id) - }, [parentContextNodes]) + const parentAvailableNodes = useMemo(() => { + if (!isOpen) + return [] + return isAgentVariant ? parentContextNodes : parentBeforeNodes + }, [isAgentVariant, isOpen, parentBeforeNodes, parentContextNodes]) const parentAvailableVars = useMemo(() => { - if (!parentContextNodeIds.length) + if (!parentAvailableNodes.length) return [] const vars = getNodeAvailableVars({ - beforeNodes: parentContextNodes, + beforeNodes: parentAvailableNodes, isChatMode, filterVar: () => true, }) - return vars.filter(nodeVar => parentContextNodeIds.includes(nodeVar.nodeId)) - }, [getNodeAvailableVars, isChatMode, parentContextNodeIds, parentContextNodes]) + const availableNodeIds = new Set(parentAvailableNodes.map(node => node.id)) + return vars.filter(nodeVar => availableNodeIds.has(nodeVar.nodeId)) + }, [getNodeAvailableVars, isChatMode, parentAvailableNodes]) const mentionConfig = useMemo(() => { const current = toolParam?.mention_config @@ -91,6 +103,9 @@ const SubGraphModal: FC = ({ }, [extractorNodeId, paramKey, toolParam?.mention_config]) const handleMentionConfigChange = useCallback((config: MentionConfig) => { + if (!isAgentVariant) + return + const { getNodes, setNodes } = reactflowStore.getState() const nextNodes = getNodes().map((node) => { if (node.id !== toolNodeId) @@ -118,10 +133,10 @@ const SubGraphModal: FC = ({ }) setNodes(nextNodes) handleSyncWorkflowDraft() - }, [handleSyncWorkflowDraft, paramKey, reactflowStore, toolNodeId]) + }, [handleSyncWorkflowDraft, isAgentVariant, paramKey, reactflowStore, toolNodeId]) useEffect(() => { - if (!toolParam || (toolParam.type && toolParam.type !== VarKindType.mention)) + if (!isAgentVariant || !toolParam || (toolParam.type && toolParam.type !== VarKindType.mention)) return const current = toolParam.mention_config @@ -132,7 +147,7 @@ const SubGraphModal: FC = ({ if (needsExtractor || needsNullStrategy || needsOutputSelector || needsDefaultValue) handleMentionConfigChange(mentionConfig) - }, [handleMentionConfigChange, mentionConfig, toolParam]) + }, [handleMentionConfigChange, isAgentVariant, mentionConfig, toolParam]) const getUserPromptText = useCallback((promptTemplate?: PromptTemplateItem[] | PromptItem) => { if (!promptTemplate) @@ -156,23 +171,46 @@ const SubGraphModal: FC = ({ // TODO: handle external workflow updates while sub-graph modal is open. const handleSave = useCallback((subGraphNodes: Node[]) => { - const extractorNodeData = subGraphNodes.find(node => node.id === extractorNodeId) as Node | undefined + const extractorNodeData = subGraphNodes.find(node => node.id === extractorNodeId) as Node | undefined if (!extractorNodeData) return - const userPromptText = getUserPromptText(extractorNodeData.data?.prompt_template) - const placeholder = `{{@${agentNodeId}.context@}}` - const nextValue = `${placeholder}${userPromptText}` + const ensureAssembleOutputs = (payload: CodeNodeType) => { + const outputs = payload.outputs || {} + if (outputs.result) + return payload + return { + ...payload, + outputs: { + ...outputs, + result: { + type: VarType.string, + children: null, + }, + }, + } + } + + const userPromptText = isAgentVariant + ? getUserPromptText((extractorNodeData.data as LLMNodeType).prompt_template) + : '' + const placeholder = isAgentVariant && resolvedAgentNodeId ? `{{@${resolvedAgentNodeId}.context@}}` : '' + const nextValue = isAgentVariant + ? `${placeholder}${userPromptText}` + : assemblePlaceholder const { getNodes, setNodes } = reactflowStore.getState() const nextNodes = getNodes().map((node) => { if (node.id === extractorNodeId) { + const nextData = isAgentVariant + ? extractorNodeData.data + : ensureAssembleOutputs(extractorNodeData.data as CodeNodeType) return { ...node, hidden: true, data: { ...node.data, - ...extractorNodeData.data, + ...nextData, parent_node_id: toolNodeId, }, } @@ -200,7 +238,7 @@ const SubGraphModal: FC = ({ }) setNodes(nextNodes) setControlPromptEditorRerenderKey(Date.now()) - }, [agentNodeId, extractorNodeId, getUserPromptText, paramKey, reactflowStore, setControlPromptEditorRerenderKey, toolNodeId]) + }, [assemblePlaceholder, extractorNodeId, getUserPromptText, isAgentVariant, paramKey, reactflowStore, resolvedAgentNodeId, setControlPromptEditorRerenderKey, toolNodeId]) return ( @@ -215,13 +253,12 @@ const SubGraphModal: FC = ({
- + {isAgentVariant + ? + : }
- @ - {agentName} - {' '} - {t('subGraphModal.title', { ns: 'workflow' })} + {modalTitle}
- + {variant === 'agent' + ? ( + | undefined} + toolParamValue={toolParamValue} + parentAvailableNodes={parentAvailableNodes} + parentAvailableVars={parentAvailableVars} + onSave={handleSave} + onSyncWorkflowDraft={doSyncWorkflowDraft} + /> + ) + : ( + | undefined} + toolParamValue={toolParamValue} + parentAvailableNodes={parentAvailableNodes} + parentAvailableVars={parentAvailableVars} + onSave={handleSave} + onSyncWorkflowDraft={doSyncWorkflowDraft} + /> + )}
diff --git a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/sub-graph-canvas.tsx b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/sub-graph-canvas.tsx index c8f3b59708..a9e9e2565d 100644 --- a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/sub-graph-canvas.tsx +++ b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/sub-graph-canvas.tsx @@ -4,40 +4,10 @@ import type { SubGraphCanvasProps } from './types' import { memo } from 'react' import SubGraph from '@/app/components/sub-graph' -const SubGraphCanvas: FC = ({ - toolNodeId, - paramKey, - sourceVariable, - agentNodeId, - agentName, - configsMap, - mentionConfig, - onMentionConfigChange, - extractorNode, - toolParamValue, - parentAvailableNodes, - parentAvailableVars, - onSave, - onSyncWorkflowDraft, -}) => { +const SubGraphCanvas: FC = (props) => { return (
- +
) } diff --git a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/types.ts b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/types.ts index a6ed7b9a8f..8a29b402d1 100644 --- a/web/app/components/workflow/nodes/tool/components/sub-graph-modal/types.ts +++ b/web/app/components/workflow/nodes/tool/components/sub-graph-modal/types.ts @@ -1,34 +1,25 @@ -import type { SyncWorkflowDraft } from '@/app/components/sub-graph/types' -import type { Shape as HooksStoreShape } from '@/app/components/workflow/hooks-store' -import type { MentionConfig } from '@/app/components/workflow/nodes/_base/types' -import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types' -import type { NodeOutPutVar, Edge as WorkflowEdge, Node as WorkflowNode } from '@/app/components/workflow/types' +import type { SubGraphProps } from '@/app/components/sub-graph/types' +import type { ValueSelector } from '@/app/components/workflow/types' -type WorkflowValueSelector = string[] - -export type SubGraphModalProps = { +type BaseSubGraphModalProps = { isOpen: boolean onClose: () => void toolNodeId: string paramKey: string - sourceVariable: WorkflowValueSelector +} + +type AgentSubGraphModalProps = BaseSubGraphModalProps & { + variant: 'agent' + sourceVariable: ValueSelector agentName: string agentNodeId: string } -export type SubGraphCanvasProps = { - toolNodeId: string - paramKey: string - sourceVariable: WorkflowValueSelector - agentNodeId: string - agentName: string - configsMap?: HooksStoreShape['configsMap'] - mentionConfig: MentionConfig - onMentionConfigChange: (config: MentionConfig) => void - extractorNode?: WorkflowNode - toolParamValue?: string - parentAvailableNodes?: WorkflowNode[] - parentAvailableVars?: NodeOutPutVar[] - onSave?: (nodes: WorkflowNode[], edges: WorkflowEdge[]) => void - onSyncWorkflowDraft?: SyncWorkflowDraft +type AssembleSubGraphModalProps = BaseSubGraphModalProps & { + variant: 'assemble' + title: string } + +export type SubGraphModalProps = AgentSubGraphModalProps | AssembleSubGraphModalProps + +export type SubGraphCanvasProps = SubGraphProps diff --git a/web/app/components/workflow/nodes/tool/node.tsx b/web/app/components/workflow/nodes/tool/node.tsx index 555bc1218f..0b20f30333 100644 --- a/web/app/components/workflow/nodes/tool/node.tsx +++ b/web/app/components/workflow/nodes/tool/node.tsx @@ -20,7 +20,14 @@ import { useStrategyProviders } from '@/service/use-strategy' import { cn } from '@/utils/classnames' import { VarType } from './types' -const AGENT_CONTEXT_VAR_PATTERN = /\{\{@([^.@#]+)\.context@\}\}/g +const AGENT_CONTEXT_VAR_PATTERN = /\{\{@[^.@#]+\.context@\}\}/g +const AGENT_CONTEXT_VAR_PREFIX = '{{@' +const AGENT_CONTEXT_VAR_SUFFIX = '.context@}}' +const getAgentNodeIdFromContextVar = (placeholder: string) => { + if (!placeholder.startsWith(AGENT_CONTEXT_VAR_PREFIX) || !placeholder.endsWith(AGENT_CONTEXT_VAR_SUFFIX)) + return '' + return placeholder.slice(AGENT_CONTEXT_VAR_PREFIX.length, -AGENT_CONTEXT_VAR_SUFFIX.length) +} type AgentCheckValidContext = { provider?: StrategyPluginDetail strategy?: StrategyDetail @@ -80,7 +87,7 @@ const Node: FC> = ({ return const matches = value.matchAll(AGENT_CONTEXT_VAR_PATTERN) for (const match of matches) { - const agentNodeId = match[1] + const agentNodeId = getAgentNodeIdFromContextVar(match[0]) if (!agentNodeId) continue const entryKey = `${paramKey}:${agentNodeId}` diff --git a/web/app/components/workflow/utils/elk-layout.ts b/web/app/components/workflow/utils/elk-layout.ts index c3b37c8f16..c0cf8543df 100644 --- a/web/app/components/workflow/utils/elk-layout.ts +++ b/web/app/components/workflow/utils/elk-layout.ts @@ -13,6 +13,7 @@ import { } from '@/app/components/workflow/constants' import { CUSTOM_ITERATION_START_NODE } from '@/app/components/workflow/nodes/iteration-start/constants' import { CUSTOM_LOOP_START_NODE } from '@/app/components/workflow/nodes/loop-start/constants' +import { CUSTOM_SUB_GRAPH_START_NODE } from '@/app/components/workflow/nodes/sub-graph-start/constants' import { BlockEnum, } from '@/app/components/workflow/types' @@ -442,6 +443,7 @@ const normaliseChildLayout = ( const startNode = nodes.find(node => node.type === CUSTOM_ITERATION_START_NODE || node.type === CUSTOM_LOOP_START_NODE + || node.type === CUSTOM_SUB_GRAPH_START_NODE || node.data?.type === BlockEnum.LoopStart || node.data?.type === BlockEnum.IterationStart, ) diff --git a/web/app/components/workflow/workflow-preview/components/nodes/sub-graph-start/index.tsx b/web/app/components/workflow/workflow-preview/components/nodes/sub-graph-start/index.tsx new file mode 100644 index 0000000000..aa0082b8c1 --- /dev/null +++ b/web/app/components/workflow/workflow-preview/components/nodes/sub-graph-start/index.tsx @@ -0,0 +1,60 @@ +import type { NodeProps } from 'reactflow' +import type { CommonNodeType } from '@/app/components/workflow/types' +import { memo } from 'react' +import { useTranslation } from 'react-i18next' +import { AssembleVariablesAlt } from '@/app/components/base/icons/src/vender/line/general' +import { Agent } from '@/app/components/base/icons/src/vender/workflow' +import Tooltip from '@/app/components/base/tooltip' +import { cn } from '@/utils/classnames' +import { NodeSourceHandle } from '../../node-handle' + +type SubGraphStartNodeData = CommonNodeType<{ + tooltip?: string + iconType?: string +}> + +type IconComponent = typeof Agent + +const iconMap: Record = { + agent: Agent, + assemble: AssembleVariablesAlt, +} + +const SubGraphStartNode = ({ id, data }: NodeProps) => { + const { t } = useTranslation() + const iconType = data?.iconType || 'agent' + const Icon = iconMap[iconType] || Agent + const rawTitle = data?.title?.trim() || '' + const showTitle = iconType === 'agent' && !!rawTitle + const displayTitle = showTitle && (rawTitle.startsWith('@') ? rawTitle : `@${rawTitle}`) + const tooltip = data?.tooltip + || (iconType === 'assemble' ? t('blocks.start', { ns: 'workflow' }) : (data?.title || t('blocks.start', { ns: 'workflow' }))) + + return ( +
+ +
+ +
+
+ {showTitle && ( + + {displayTitle} + + )} + +
+ ) +} + +export default memo(SubGraphStartNode) diff --git a/web/app/components/workflow/workflow-preview/index.tsx b/web/app/components/workflow/workflow-preview/index.tsx index 8f61c2cfb6..7e7b2c271c 100644 --- a/web/app/components/workflow/workflow-preview/index.tsx +++ b/web/app/components/workflow/workflow-preview/index.tsx @@ -29,6 +29,7 @@ import { import CustomConnectionLine from '@/app/components/workflow/custom-connection-line' import { CUSTOM_ITERATION_START_NODE } from '@/app/components/workflow/nodes/iteration-start/constants' import { CUSTOM_LOOP_START_NODE } from '@/app/components/workflow/nodes/loop-start/constants' +import { CUSTOM_SUB_GRAPH_START_NODE } from '@/app/components/workflow/nodes/sub-graph-start/constants' import { CUSTOM_NOTE_NODE } from '@/app/components/workflow/note-node/constants' import { CUSTOM_SIMPLE_NODE } from '@/app/components/workflow/simple-node/constants' import { @@ -40,6 +41,7 @@ import CustomEdge from './components/custom-edge' import CustomNode from './components/nodes' import IterationStartNode from './components/nodes/iteration-start' import LoopStartNode from './components/nodes/loop-start' +import SubGraphStartNode from './components/nodes/sub-graph-start' import CustomNoteNode from './components/note-node' import ZoomInOut from './components/zoom-in-out' import 'reactflow/dist/style.css' @@ -49,6 +51,7 @@ const nodeTypes = { [CUSTOM_NODE]: CustomNode, [CUSTOM_NOTE_NODE]: CustomNoteNode, [CUSTOM_SIMPLE_NODE]: CustomNode, + [CUSTOM_SUB_GRAPH_START_NODE]: SubGraphStartNode, [CUSTOM_ITERATION_START_NODE]: IterationStartNode, [CUSTOM_LOOP_START_NODE]: LoopStartNode, } From 6e9a5139b49794c63e9106e6ea0acac4b6361524 Mon Sep 17 00:00:00 2001 From: zhsama Date: Mon, 19 Jan 2026 22:30:43 +0800 Subject: [PATCH 75/82] chore: Remove sonarjs ESLint suppressions and reformat code --- web/app/components/app/annotation/index.tsx | 2 +- .../config/automatic/get-automatic-res.tsx | 1 - .../app/overview/settings/index.spec.tsx | 2 +- .../base/markdown-blocks/code-block.tsx | 4 +-- .../embedding-process/rule-detail.tsx | 6 ++-- .../datasets/settings/form/index.tsx | 2 +- .../get-schema.spec.tsx | 2 +- .../nodes/_base/components/variable/utils.ts | 3 +- .../workflow/nodes/data-source/panel.tsx | 30 ++++++++++--------- .../components/retrieval-config.tsx | 2 +- .../nodes/knowledge-retrieval/utils.ts | 5 ++-- .../components/workflow/nodes/tool/panel.tsx | 30 ++++++++++--------- .../workflow/note-node/note-editor/utils.ts | 1 - web/eslint-suppressions.json | 11 ------- web/i18n/en-US/workflow.json | 2 +- web/i18n/ja-JP/workflow.json | 2 +- web/i18n/zh-Hans/workflow.json | 2 +- web/i18n/zh-Hant/workflow.json | 2 +- web/scripts/analyze-i18n-diff.ts | 8 ++--- 19 files changed, 53 insertions(+), 64 deletions(-) diff --git a/web/app/components/app/annotation/index.tsx b/web/app/components/app/annotation/index.tsx index 19977c8c50..553836d73c 100644 --- a/web/app/components/app/annotation/index.tsx +++ b/web/app/components/app/annotation/index.tsx @@ -203,7 +203,7 @@ const Annotation: FC = (props) => { {isLoading ? - // eslint-disable-next-line sonarjs/no-nested-conditional + : total > 0 ? ( = ({ }, ] as const - // eslint-disable-next-line sonarjs/no-nested-template-literals, sonarjs/no-nested-conditional const [instructionFromSessionStorage, setInstruction] = useSessionStorageState(`improve-instruction-${flowId}${isBasicMode ? '' : `-${nodeId}${editorId ? `-${editorId}` : ''}`}`) const instruction = instructionFromSessionStorage || '' const [ideaOutput, setIdeaOutput] = useState('') diff --git a/web/app/components/app/overview/settings/index.spec.tsx b/web/app/components/app/overview/settings/index.spec.tsx index 41d86047f0..776c55d149 100644 --- a/web/app/components/app/overview/settings/index.spec.tsx +++ b/web/app/components/app/overview/settings/index.spec.tsx @@ -175,7 +175,7 @@ describe('SettingsModal', () => { renderSettingsModal() fireEvent.click(screen.getByText('appOverview.overview.appInfo.settings.more.entry')) const privacyInput = screen.getByPlaceholderText('appOverview.overview.appInfo.settings.more.privacyPolicyPlaceholder') - // eslint-disable-next-line sonarjs/no-clear-text-protocols + fireEvent.change(privacyInput, { target: { value: 'ftp://invalid-url' } }) fireEvent.click(screen.getByText('common.operation.save')) diff --git a/web/app/components/base/markdown-blocks/code-block.tsx b/web/app/components/base/markdown-blocks/code-block.tsx index 79a374e291..b9b3074351 100644 --- a/web/app/components/base/markdown-blocks/code-block.tsx +++ b/web/app/components/base/markdown-blocks/code-block.tsx @@ -205,7 +205,7 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any } catch { try { - // eslint-disable-next-line no-new-func, sonarjs/code-eval + // eslint-disable-next-line no-new-func const result = new Function(`return ${trimmedContent}`)() if (typeof result === 'object' && result !== null) { setFinalChartOption(result) @@ -250,7 +250,7 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any } catch { try { - // eslint-disable-next-line no-new-func, sonarjs/code-eval + // eslint-disable-next-line no-new-func const result = new Function(`return ${trimmedContent}`)() if (typeof result === 'object' && result !== null) { setFinalChartOption(result) diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/rule-detail.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/rule-detail.tsx index df41e8c1e7..8fe6af6170 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/rule-detail.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/rule-detail.tsx @@ -28,10 +28,10 @@ const RuleDetail = ({ case 'mode': value = !sourceData?.mode ? value - // eslint-disable-next-line sonarjs/no-nested-conditional + : sourceData.mode === ProcessMode.general ? (t('embedding.custom', { ns: 'datasetDocuments' }) as string) - // eslint-disable-next-line sonarjs/no-nested-conditional + : `${t('embedding.hierarchical', { ns: 'datasetDocuments' })} · ${sourceData?.rules?.parent_mode === 'paragraph' ? t('parentMode.paragraph', { ns: 'dataset' }) : t('parentMode.fullDoc', { ns: 'dataset' })}` @@ -70,7 +70,7 @@ const RuleDetail = ({ src={ retrievalMethod === RETRIEVE_METHOD.fullText ? retrievalIcon.fullText - // eslint-disable-next-line sonarjs/no-nested-conditional + : retrievalMethod === RETRIEVE_METHOD.hybrid ? retrievalIcon.hybrid : retrievalIcon.vector diff --git a/web/app/components/datasets/settings/form/index.tsx b/web/app/components/datasets/settings/form/index.tsx index 206c264a88..6081a36fcc 100644 --- a/web/app/components/datasets/settings/form/index.tsx +++ b/web/app/components/datasets/settings/form/index.tsx @@ -403,7 +403,7 @@ const Form = () => {
) - // eslint-disable-next-line sonarjs/no-nested-conditional + : indexMethod ? ( <> diff --git a/web/app/components/tools/edit-custom-collection-modal/get-schema.spec.tsx b/web/app/components/tools/edit-custom-collection-modal/get-schema.spec.tsx index de156ce68a..fa316c4aab 100644 --- a/web/app/components/tools/edit-custom-collection-modal/get-schema.spec.tsx +++ b/web/app/components/tools/edit-custom-collection-modal/get-schema.spec.tsx @@ -23,7 +23,7 @@ describe('GetSchema', () => { it('shows an error when the URL is not http', () => { fireEvent.click(screen.getByText('tools.createTool.importFromUrl')) const input = screen.getByPlaceholderText('tools.createTool.importFromUrlPlaceHolder') - // eslint-disable-next-line sonarjs/no-clear-text-protocols + fireEvent.change(input, { target: { value: 'ftp://invalid' } }) fireEvent.click(screen.getByText('common.operation.ok')) diff --git a/web/app/components/workflow/nodes/_base/components/variable/utils.ts b/web/app/components/workflow/nodes/_base/components/variable/utils.ts index e5e8174456..2f83945dc2 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/utils.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/utils.ts @@ -474,7 +474,6 @@ const formatItem = ( break } - // eslint-disable-next-line sonarjs/no-duplicated-branches case BlockEnum.VariableAggregator: { const { output_type, advanced_settings } = data as VariableAssignerNodeType @@ -1875,7 +1874,7 @@ export const updateNodeVars = ( } break } - // eslint-disable-next-line sonarjs/no-duplicated-branches + case BlockEnum.VariableAggregator: { const payload = data as VariableAssignerNodeType if (payload.variables) { diff --git a/web/app/components/workflow/nodes/data-source/panel.tsx b/web/app/components/workflow/nodes/data-source/panel.tsx index f29c37d16d..eb233c01c8 100644 --- a/web/app/components/workflow/nodes/data-source/panel.tsx +++ b/web/app/components/workflow/nodes/data-source/panel.tsx @@ -139,20 +139,22 @@ const Panel: FC> = ({ id, data }) => { return (
- {outputItem.value?.type === 'object' ? ( - - ) : ( - - )} + {outputItem.value?.type === 'object' + ? ( + + ) + : ( + + )}
) })} diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/retrieval-config.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/retrieval-config.tsx index e7860d2fb2..c3eef42a39 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/retrieval-config.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/retrieval-config.tsx @@ -100,7 +100,7 @@ const RetrievalConfig: FC = ({ score_threshold: configs.score_threshold_enabled ? (configs.score_threshold ?? DATASET_DEFAULT.score_threshold) : null, reranking_model: retrieval_mode === RETRIEVE_TYPE.oneWay ? undefined - // eslint-disable-next-line sonarjs/no-nested-conditional + : (!configs.reranking_model?.reranking_provider_name ? undefined : { diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/utils.ts b/web/app/components/workflow/nodes/knowledge-retrieval/utils.ts index a30ec8e735..2ce57bc270 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/utils.ts +++ b/web/app/components/workflow/nodes/knowledge-retrieval/utils.ts @@ -135,7 +135,7 @@ export const getMultipleRetrievalConfig = ( vector_setting: { vector_weight: allHighQualityVectorSearch ? DEFAULT_WEIGHTED_SCORE.allHighQualityVectorSearch.semantic - // eslint-disable-next-line sonarjs/no-nested-conditional + : allHighQualityFullTextSearch ? DEFAULT_WEIGHTED_SCORE.allHighQualityFullTextSearch.semantic : DEFAULT_WEIGHTED_SCORE.other.semantic, @@ -145,7 +145,7 @@ export const getMultipleRetrievalConfig = ( keyword_setting: { keyword_weight: allHighQualityVectorSearch ? DEFAULT_WEIGHTED_SCORE.allHighQualityVectorSearch.keyword - // eslint-disable-next-line sonarjs/no-nested-conditional + : allHighQualityFullTextSearch ? DEFAULT_WEIGHTED_SCORE.allHighQualityFullTextSearch.keyword : DEFAULT_WEIGHTED_SCORE.other.keyword, @@ -232,7 +232,6 @@ export const getMultipleRetrievalConfig = ( result.reranking_mode = RerankingModeEnum.RerankingModel result.reranking_enable = true - // eslint-disable-next-line sonarjs/nested-control-flow if ((!result.reranking_model?.provider || !result.reranking_model?.model) && isFallbackRerankModelValid) { result.reranking_model = { provider: fallbackRerankModel.provider || '', diff --git a/web/app/components/workflow/nodes/tool/panel.tsx b/web/app/components/workflow/nodes/tool/panel.tsx index 9ff649b207..899a26ff4f 100644 --- a/web/app/components/workflow/nodes/tool/panel.tsx +++ b/web/app/components/workflow/nodes/tool/panel.tsx @@ -124,20 +124,22 @@ const Panel: FC> = ({ // TODO empty object type always match `qa_structured` schema type return (
- {outputItem.value?.type === 'object' ? ( - - ) : ( - - )} + {outputItem.value?.type === 'object' + ? ( + + ) + : ( + + )}
) })} diff --git a/web/app/components/workflow/note-node/note-editor/utils.ts b/web/app/components/workflow/note-node/note-editor/utils.ts index dff98a8301..22cfc3726a 100644 --- a/web/app/components/workflow/note-node/note-editor/utils.ts +++ b/web/app/components/workflow/note-node/note-editor/utils.ts @@ -18,5 +18,4 @@ export function getSelectedNode( return $isAtNodeEnd(anchor) ? anchorNode : focusNode } -// eslint-disable-next-line sonarjs/empty-string-repetition export const urlRegExp = /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+|(?:www.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w-]*)?\??[-+=&;%@.\w]*#?\w*)?)/ diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 125e94ece9..72b07644a9 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -3280,9 +3280,6 @@ } }, "app/components/workflow/nodes/data-source/panel.tsx": { - "style/multiline-ternary": { - "count": 2 - }, "ts/no-explicit-any": { "count": 3 } @@ -3634,11 +3631,6 @@ "count": 7 } }, - "app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder.tsx": { "ts/no-explicit-any": { "count": 1 @@ -3665,9 +3657,6 @@ } }, "app/components/workflow/nodes/tool/panel.tsx": { - "style/multiline-ternary": { - "count": 2 - }, "ts/no-explicit-any": { "count": 2 } diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index d31fe052cf..25812b9c6e 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -766,8 +766,8 @@ "nodes.templateTransform.codeSupportTip": "Only supports Jinja2", "nodes.templateTransform.inputVars": "Input Variables", "nodes.templateTransform.outputVars.output": "Transformed content", - "nodes.tool.authorize": "Authorize", "nodes.tool.assembleVariables": "Assemble variables", + "nodes.tool.authorize": "Authorize", "nodes.tool.inputVars": "Input Variables", "nodes.tool.insertPlaceholder1": "Type or press", "nodes.tool.insertPlaceholder2": "insert variable", diff --git a/web/i18n/ja-JP/workflow.json b/web/i18n/ja-JP/workflow.json index 02958405d6..f1d3a02ca8 100644 --- a/web/i18n/ja-JP/workflow.json +++ b/web/i18n/ja-JP/workflow.json @@ -764,8 +764,8 @@ "nodes.templateTransform.codeSupportTip": "Jinja2 のみをサポートしています", "nodes.templateTransform.inputVars": "入力変数", "nodes.templateTransform.outputVars.output": "変換されたコンテンツ", - "nodes.tool.authorize": "認証する", "nodes.tool.assembleVariables": "変数を組み立てる", + "nodes.tool.authorize": "認証する", "nodes.tool.inputVars": "入力変数", "nodes.tool.insertPlaceholder1": "タイプするか押してください", "nodes.tool.insertPlaceholder2": "変数を挿入する", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index 25c159f446..f943562ea6 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -764,8 +764,8 @@ "nodes.templateTransform.codeSupportTip": "只支持 Jinja2", "nodes.templateTransform.inputVars": "输入变量", "nodes.templateTransform.outputVars.output": "转换后内容", - "nodes.tool.authorize": "授权", "nodes.tool.assembleVariables": "组装变量", + "nodes.tool.authorize": "授权", "nodes.tool.inputVars": "输入变量", "nodes.tool.insertPlaceholder1": "键入", "nodes.tool.insertPlaceholder2": "插入变量", diff --git a/web/i18n/zh-Hant/workflow.json b/web/i18n/zh-Hant/workflow.json index 62162aaae3..b86da8e4db 100644 --- a/web/i18n/zh-Hant/workflow.json +++ b/web/i18n/zh-Hant/workflow.json @@ -764,8 +764,8 @@ "nodes.templateTransform.codeSupportTip": "只支持 Jinja2", "nodes.templateTransform.inputVars": "輸入變數", "nodes.templateTransform.outputVars.output": "轉換後內容", - "nodes.tool.authorize": "授權", "nodes.tool.assembleVariables": "組裝變數", + "nodes.tool.authorize": "授權", "nodes.tool.inputVars": "輸入變數", "nodes.tool.insertPlaceholder1": "輸入或按壓", "nodes.tool.insertPlaceholder2": "插入變數", diff --git a/web/scripts/analyze-i18n-diff.ts b/web/scripts/analyze-i18n-diff.ts index 9fd5f3b4a7..bd830c5523 100644 --- a/web/scripts/analyze-i18n-diff.ts +++ b/web/scripts/analyze-i18n-diff.ts @@ -106,7 +106,7 @@ function parseTsContent(content: string): NestedTranslation { // Use Function constructor to safely evaluate the object literal // This handles JS object syntax like unquoted keys, template literals, etc. try { - // eslint-disable-next-line no-new-func, sonarjs/code-eval + // eslint-disable-next-line no-new-func const fn = new Function(`return (${cleaned})`) return fn() as NestedTranslation } @@ -123,7 +123,7 @@ function parseTsContent(content: string): NestedTranslation { function getMainBranchFile(filePath: string): string | null { try { const relativePath = `./i18n/${LOCALE}/${filePath}` - // eslint-disable-next-line sonarjs/os-command + return execSync(`git show main:${relativePath}`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], @@ -148,12 +148,12 @@ function getTranslationFiles(): string[] { function getMainBranchNamespaces(): string[] { try { const relativePath = `./i18n/${LOCALE}` - // eslint-disable-next-line sonarjs/os-command + const output = execSync(`git ls-tree --name-only main ${relativePath}/`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], }) - // eslint-disable-next-line sonarjs/os-command + return output .trim() .split('\n') From e1e64ae430446fa4696304d1da3069ef50054803 Mon Sep 17 00:00:00 2001 From: zhsama Date: Mon, 19 Jan 2026 23:06:08 +0800 Subject: [PATCH 76/82] feat: code node output initialization and agent placeholder1 --- .../workflow/nodes/code/use-config.ts | 16 ++-- .../mixed-variable-text-input/index.tsx | 83 +++++++++++-------- web/i18n/en-US/workflow.json | 1 + web/i18n/ja-JP/workflow.json | 1 + web/i18n/zh-Hans/workflow.json | 1 + web/i18n/zh-Hant/workflow.json | 1 + 6 files changed, 64 insertions(+), 39 deletions(-) diff --git a/web/app/components/workflow/nodes/code/use-config.ts b/web/app/components/workflow/nodes/code/use-config.ts index fdb1c8ce51..2655dd1d43 100644 --- a/web/app/components/workflow/nodes/code/use-config.ts +++ b/web/app/components/workflow/nodes/code/use-config.ts @@ -56,17 +56,21 @@ const useConfig = (id: string, payload: CodeNodeType) => { setInputs, }) - const [outputKeyOrders, setOutputKeyOrders] = useState([]) + const [outputKeyOrders, setOutputKeyOrders] = useState(() => Object.keys(payload.outputs || {})) const syncOutputKeyOrders = useCallback((outputs: OutputVar) => { setOutputKeyOrders(Object.keys(outputs)) }, []) useEffect(() => { - if (inputs.code) { - if (inputs.outputs && Object.keys(inputs.outputs).length > 0) - syncOutputKeyOrders(inputs.outputs) + const outputKeys = inputs.outputs ? Object.keys(inputs.outputs) : [] + if (outputKeys.length > 0 && outputKeyOrders.length === 0) + syncOutputKeyOrders(inputs.outputs) + const hasExistingConfig = Boolean(inputs.code) + || (inputs.variables?.length ?? 0) > 0 + || outputKeys.length > 0 + + if (hasExistingConfig) return - } const isReady = defaultConfig && Object.keys(defaultConfig).length > 0 if (isReady) { @@ -76,7 +80,7 @@ const useConfig = (id: string, payload: CodeNodeType) => { }) syncOutputKeyOrders(defaultConfig.outputs) } - }, [defaultConfig]) + }, [defaultConfig, inputs.code, inputs.outputs, inputs.variables, outputKeyOrders.length, setInputs, syncOutputKeyOrders]) const handleCodeChange = useCallback((code: string) => { const newInputs = produce(inputs, (draft) => { diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx index 1527a27178..703559a2ee 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx @@ -334,6 +334,14 @@ const MixedVariableTextInput = ({ return detectAgentFromText(value) }, [detectAgentFromText, value]) + // Check if value only contains agent context variable without other user input + const isOnlyAgentContext = useMemo(() => { + if (!detectedAgentFromValue || !value) + return false + const valueWithoutAgentContext = value.replace(AGENT_CONTEXT_VAR_PATTERN, '').trim() + return valueWithoutAgentContext === '' + }, [detectedAgentFromValue, value]) + const agentNodes = useMemo(() => { if (!contextNodeIds.size) return [] @@ -574,39 +582,48 @@ const MixedVariableTextInput = ({ /> )} {!isAssembleValue && ( - 0 && !detectedAgentFromValue, - agentNodes, - onSelect: handleAgentSelect, - }} - placeholder={} - onChange={(text) => { - const hasPlaceholder = new RegExp(AGENT_CONTEXT_VAR_PATTERN.source).test(text) - if (hasPlaceholder) - syncExtractorPromptFromText(text) - if (detectedAgentFromValue && !hasPlaceholder) { - removeExtractorNode() - onChange?.(text, VarKindTypeEnum.mixed, null) - return - } - onChange?.(text) - }} - /> +
+ 0 && !detectedAgentFromValue, + agentNodes, + onSelect: handleAgentSelect, + }} + placeholder={} + onChange={(text) => { + const hasPlaceholder = new RegExp(AGENT_CONTEXT_VAR_PATTERN.source).test(text) + if (hasPlaceholder) + syncExtractorPromptFromText(text) + if (detectedAgentFromValue && !hasPlaceholder) { + removeExtractorNode() + onChange?.(text, VarKindTypeEnum.mixed, null) + return + } + onChange?.(text) + }} + /> + {isOnlyAgentContext && paramKey && ( +
+ + {t('nodes.tool.agentPlaceholder', { ns: 'workflow', paramKey })} + +
+ )} +
)} {toolNodeId && paramKey && isAssembleValue && ( Date: Mon, 19 Jan 2026 23:07:32 +0800 Subject: [PATCH 77/82] feat: Remove allowGraphActions check from retry and error panels --- .../workflow/nodes/_base/components/workflow-panel/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx index 60568018e5..0191c1e144 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx @@ -625,7 +625,7 @@ const BasePanel: FC = ({
{ - allowGraphActions && hasRetryNode(data.type) && ( + hasRetryNode(data.type) && ( = ({ ) } { - allowGraphActions && hasErrorHandleNode(data.type) && ( + hasErrorHandleNode(data.type) && ( Date: Mon, 19 Jan 2026 23:10:27 +0800 Subject: [PATCH 78/82] feat: enable typeahead filtering and keyboard navigation --- .../components/base/prompt-editor/hooks.ts | 3 +- .../plugins/component-picker-block/hooks.tsx | 6 +- .../plugins/component-picker-block/index.tsx | 12 +- .../components/agent-node-list/index.tsx | 103 +++++++- .../variable/var-reference-vars.tsx | 240 ++++++++++++++---- 5 files changed, 295 insertions(+), 69 deletions(-) diff --git a/web/app/components/base/prompt-editor/hooks.ts b/web/app/components/base/prompt-editor/hooks.ts index 10578e0004..2e69f0669c 100644 --- a/web/app/components/base/prompt-editor/hooks.ts +++ b/web/app/components/base/prompt-editor/hooks.ts @@ -155,14 +155,13 @@ export type TriggerFn = ( text: string, editor: LexicalEditor, ) => MenuTextMatch | null -export const PUNCTUATION = '\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;' export function useBasicTypeaheadTriggerMatch( trigger: string, { minLength = 1, maxLength = 75 }: { minLength?: number, maxLength?: number }, ): TriggerFn { return useCallback( (text: string) => { - const validChars = `[${PUNCTUATION}\\s]` + const validChars = '[^\\n]' const TypeaheadTriggerRegex = new RegExp( '(.*)(' + `[${trigger}]` diff --git a/web/app/components/base/prompt-editor/plugins/component-picker-block/hooks.tsx b/web/app/components/base/prompt-editor/plugins/component-picker-block/hooks.tsx index 8855d948df..f2d6f3c0cf 100644 --- a/web/app/components/base/prompt-editor/plugins/component-picker-block/hooks.tsx +++ b/web/app/components/base/prompt-editor/plugins/component-picker-block/hooks.tsx @@ -32,6 +32,8 @@ import { PickerBlockMenuOption } from './menu' import { PromptMenuItem } from './prompt-option' import { VariableMenuItem } from './variable-option' +const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + export const usePromptOptions = ( contextBlock?: ContextBlockType, queryBlock?: QueryBlockType, @@ -154,7 +156,7 @@ export const useVariableOptions = ( if (!queryString) return baseOptions - const regex = new RegExp(queryString, 'i') + const regex = new RegExp(escapeRegExp(queryString), 'i') return baseOptions.filter(option => regex.test(option.key)) }, [editor, queryString, variableBlock]) @@ -232,7 +234,7 @@ export const useExternalToolOptions = ( if (!queryString) return baseToolOptions - const regex = new RegExp(queryString, 'i') + const regex = new RegExp(escapeRegExp(queryString), 'i') return baseToolOptions.filter(option => regex.test(option.key)) }, [editor, queryString, externalToolBlockType]) diff --git a/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx b/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx index 8ff0ed5a02..d36c8e571f 100644 --- a/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx +++ b/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx @@ -91,9 +91,10 @@ const ComponentPicker = ({ ], }) const [editor] = useLexicalComposerContext() + const useExternalSearch = triggerString === '/' || triggerString === '@' const checkForTriggerMatch = useBasicTypeaheadTriggerMatch(triggerString, { minLength: 0, - maxLength: 0, + maxLength: useExternalSearch ? 75 : 0, }) const [queryString, setQueryString] = useState(null) @@ -116,6 +117,7 @@ const ComponentPicker = ({ currentBlock, errorMessageBlock, lastRunBlock, + useExternalSearch ? (queryString ?? undefined) : undefined, ) const onSelectOption = useCallback( @@ -247,6 +249,9 @@ const ComponentPicker = ({ onBlur={handleClose} maxHeightClass="max-h-[34vh]" autoFocus={false} + hideSearch={useExternalSearch} + externalSearchText={useExternalSearch ? (queryString ?? '') : undefined} + enableKeyboardNavigation={useExternalSearch} /> ) : ( @@ -270,6 +275,9 @@ const ComponentPicker = ({ onAssembleVariables={showAssembleVariables ? handleSelectAssembleVariables : undefined} autoFocus={false} isInCodeGeneratorInstructionEditor={currentBlock?.generatorType === GeneratorType.code} + hideSearch={useExternalSearch} + externalSearchText={useExternalSearch ? (queryString ?? '') : undefined} + enableKeyboardNavigation={useExternalSearch} />
) @@ -311,7 +319,7 @@ const ComponentPicker = ({ } ) - }, [isAgentTrigger, agentNodes, allFlattenOptions.length, workflowVariableBlock?.show, floatingStyles, isPositioned, refs, handleSelectAgent, handleClose, workflowVariableOptions, isSupportFileVar, currentBlock?.generatorType, handleSelectWorkflowVariable, queryString, workflowVariableBlock?.showManageInputField, workflowVariableBlock?.onManageInputField, showAssembleVariables, handleSelectAssembleVariables]) + }, [isAgentTrigger, agentNodes, allFlattenOptions.length, workflowVariableBlock?.show, floatingStyles, isPositioned, refs, handleSelectAgent, handleClose, workflowVariableOptions, isSupportFileVar, currentBlock?.generatorType, handleSelectWorkflowVariable, queryString, workflowVariableBlock?.showManageInputField, workflowVariableBlock?.onManageInputField, showAssembleVariables, handleSelectAssembleVariables, useExternalSearch]) return ( void + isHighlighted?: boolean + onSetHighlight?: () => void + registerRef?: (element: HTMLButtonElement | null) => void } -const Item: FC = ({ node, onSelect }) => { +const Item: FC = ({ node, onSelect, isHighlighted, onSetHighlight, registerRef }) => { const [isHovering, setIsHovering] = useState(false) return ( @@ -27,10 +30,15 @@ const Item: FC = ({ node, onSelect }) => { type="button" className={cn( 'relative flex h-6 w-full cursor-pointer items-center rounded-md border-none bg-transparent px-3 text-left', - isHovering && 'bg-state-base-hover', + (isHovering || isHighlighted) && 'bg-state-base-hover', )} - onMouseEnter={() => setIsHovering(true)} + ref={registerRef} + onMouseEnter={() => { + setIsHovering(true) + onSetHighlight?.() + }} onMouseLeave={() => setIsHovering(false)} + onFocus={onSetHighlight} onClick={() => onSelect(node)} onMouseDown={e => e.preventDefault()} > @@ -58,6 +66,8 @@ type Props = { searchBoxClassName?: string maxHeightClass?: string autoFocus?: boolean + externalSearchText?: string + enableKeyboardNavigation?: boolean } const AgentNodeList: FC = ({ @@ -69,9 +79,13 @@ const AgentNodeList: FC = ({ searchBoxClassName, maxHeightClass, autoFocus = true, + externalSearchText, + enableKeyboardNavigation = false, }) => { const { t } = useTranslation() const [searchText, setSearchText] = useState('') + const normalizedSearchText = externalSearchText === undefined ? searchText : externalSearchText.trim() + const shouldShowSearchInput = !hideSearch && externalSearchText === undefined const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Escape') { @@ -80,15 +94,79 @@ const AgentNodeList: FC = ({ } } - const filteredNodes = nodes.filter((node) => { - if (!searchText) + const filteredNodes = useMemo(() => nodes.filter((node) => { + if (!normalizedSearchText) return true - return node.title.toLowerCase().includes(searchText.toLowerCase()) - }) + return node.title.toLowerCase().includes(normalizedSearchText.toLowerCase()) + }), [nodes, normalizedSearchText]) + + const [activeIndex, setActiveIndex] = useState(-1) + const itemRefs = useRef>([]) + + useEffect(() => { + itemRefs.current = [] + }, [filteredNodes.length]) + + useEffect(() => { + if (!enableKeyboardNavigation) { + setActiveIndex(-1) + return + } + if (filteredNodes.length === 0) { + setActiveIndex(-1) + return + } + setActiveIndex(0) + }, [enableKeyboardNavigation, filteredNodes.length, normalizedSearchText]) + + useEffect(() => { + if (!enableKeyboardNavigation || activeIndex < 0) + return + const target = itemRefs.current[activeIndex] + if (target) + target.scrollIntoView({ block: 'nearest' }) + }, [activeIndex, enableKeyboardNavigation, filteredNodes.length]) + + const handleSelectItem = useCallback((node: AgentNode) => { + onSelect(node) + }, [onSelect]) + + useEffect(() => { + if (!enableKeyboardNavigation) + return + const handleKeyDown = (event: KeyboardEvent) => { + if (filteredNodes.length === 0) + return + if (!['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(event.key)) + return + event.preventDefault() + event.stopPropagation() + if (event.key === 'Escape') { + onClose?.() + return + } + if (event.key === 'Enter') { + if (activeIndex < 0 || activeIndex >= filteredNodes.length) + return + handleSelectItem(filteredNodes[activeIndex]) + return + } + const delta = event.key === 'ArrowDown' ? 1 : -1 + setActiveIndex((prev) => { + const baseIndex = prev < 0 ? 0 : prev + const nextIndex = Math.min(Math.max(baseIndex + delta, 0), filteredNodes.length - 1) + return nextIndex + }) + } + document.addEventListener('keydown', handleKeyDown, true) + return () => { + document.removeEventListener('keydown', handleKeyDown, true) + } + }, [activeIndex, enableKeyboardNavigation, filteredNodes, handleSelectItem, onClose]) return ( <> - {!hideSearch && ( + {shouldShowSearchInput && ( <>
= ({ {filteredNodes.length > 0 ? (
- {filteredNodes.map(node => ( + {filteredNodes.map((node, index) => ( setActiveIndex(index) : undefined} + registerRef={enableKeyboardNavigation ? (element) => { + itemRefs.current[index] = element + } : undefined} /> ))}
diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx index 708ef3a77b..066ca7c67b 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx @@ -6,7 +6,7 @@ import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflo import { useHover } from 'ahooks' import { noop } from 'es-toolkit/function' import * as React from 'react' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows' import { AssembleVariables, CodeAssistant, MagicEdit } from '@/app/components/base/icons/src/vender/line/general' @@ -43,6 +43,31 @@ type ItemProps = { zIndex?: number className?: string preferSchemaType?: boolean + isHighlighted?: boolean + onSetHighlight?: () => void + registerRef?: (element: HTMLDivElement | null) => void +} + +const buildValueSelector = ({ + nodeId, + objPath, + itemData, + isFlat, +}: { + nodeId: string + objPath: string[] + itemData: Var + isFlat?: boolean +}): ValueSelector => { + if (isFlat) + return [itemData.variable] + const isSys = itemData.variable.startsWith('sys.') + const isEnv = itemData.variable.startsWith('env.') + const isChatVar = itemData.variable.startsWith('conversation.') + const isRagVariable = itemData.isRagVariable + if (isSys || isEnv || isChatVar || isRagVariable) + return [...objPath, ...itemData.variable.split('.')] + return [nodeId, ...objPath, itemData.variable] } const Item: FC = ({ @@ -60,6 +85,9 @@ const Item: FC = ({ zIndex, className, preferSchemaType, + isHighlighted, + onSetHighlight, + registerRef, }) => { const isStructureOutput = itemData.type === VarType.object && (itemData.children as StructuredOutput)?.schema?.properties const isFile = itemData.type === VarType.file && !isStructureOutput @@ -123,6 +151,10 @@ const Item: FC = ({ })() const itemRef = useRef(null) + const setItemRef = useCallback((element: HTMLDivElement | null) => { + itemRef.current = element + registerRef?.(element) + }, [registerRef]) const [isItemHovering, setIsItemHovering] = useState(false) useHover(itemRef, { onChange: (hovering) => { @@ -152,15 +184,12 @@ const Item: FC = ({ if (!isSupportFileVar && isFile) return - if (isFlat) { - onChange([itemData.variable], itemData) - } - else if (isSys || isEnv || isChatVar || isRagVariable) { // system variable | environment variable | conversation variable - onChange([...objPath, ...itemData.variable.split('.')], itemData) - } - else { - onChange([nodeId, ...objPath, itemData.variable], itemData) - } + onChange(buildValueSelector({ + nodeId, + objPath, + itemData, + isFlat, + }), itemData) } const variableCategory = useMemo(() => { if (isEnv) @@ -181,14 +210,15 @@ const Item: FC = ({ >
e.preventDefault()} >
@@ -259,6 +289,8 @@ type Props = { onAssembleVariables?: () => ValueSelector | null autoFocus?: boolean preferSchemaType?: boolean + externalSearchText?: string + enableKeyboardNavigation?: boolean } const VarReferenceVars: FC = ({ hideSearch, @@ -278,9 +310,13 @@ const VarReferenceVars: FC = ({ onAssembleVariables, autoFocus = true, preferSchemaType, + externalSearchText, + enableKeyboardNavigation = false, }) => { const { t } = useTranslation() const [searchText, setSearchText] = useState('') + const normalizedSearchText = externalSearchText === undefined ? searchText : externalSearchText.trim() + const shouldShowSearchInput = !hideSearch && externalSearchText === undefined const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Escape') { @@ -296,35 +332,124 @@ const VarReferenceVars: FC = ({ onClose?.() } - const filteredVars = vars.filter((v) => { - const children = v.vars.filter(v => checkKeys([v.variable], false).isValid || isSpecialVar(v.variable.split('.')[0])) - return children.length > 0 - }).filter((node) => { - if (!searchText) - return node - const children = node.vars.filter((v) => { - const searchTextLower = searchText.toLowerCase() - return v.variable.toLowerCase().includes(searchTextLower) || node.title.toLowerCase().includes(searchTextLower) - }) - return children.length > 0 - }).map((node) => { - let vars = node.vars.filter(v => checkKeys([v.variable], false).isValid || isSpecialVar(v.variable.split('.')[0])) - if (searchText) { - const searchTextLower = searchText.toLowerCase() - if (!node.title.toLowerCase().includes(searchTextLower)) - vars = vars.filter(v => v.variable.toLowerCase().includes(searchText.toLowerCase())) - } + const filteredVars = useMemo(() => { + return vars.filter((v) => { + const children = v.vars.filter(v => checkKeys([v.variable], false).isValid || isSpecialVar(v.variable.split('.')[0])) + return children.length > 0 + }).filter((node) => { + if (!normalizedSearchText) + return node + const searchTextLower = normalizedSearchText.toLowerCase() + const children = node.vars.filter((v) => { + return v.variable.toLowerCase().includes(searchTextLower) || node.title.toLowerCase().includes(searchTextLower) + }) + return children.length > 0 + }).map((node) => { + let vars = node.vars.filter(v => checkKeys([v.variable], false).isValid || isSpecialVar(v.variable.split('.')[0])) + if (normalizedSearchText) { + const searchTextLower = normalizedSearchText.toLowerCase() + if (!node.title.toLowerCase().includes(searchTextLower)) + vars = vars.filter(v => v.variable.toLowerCase().includes(searchTextLower)) + } - return { - ...node, - vars, + return { + ...node, + vars, + } + }) + }, [normalizedSearchText, vars]) + + const flatItems = useMemo(() => { + const items: Array<{ node: NodeOutPutVar, itemData: Var }> = [] + filteredVars.forEach((node) => { + node.vars.forEach((itemData) => { + items.push({ node, itemData }) + }) + }) + return items + }, [filteredVars]) + const [activeIndex, setActiveIndex] = useState(-1) + const itemRefs = useRef>([]) + + useEffect(() => { + itemRefs.current = [] + }, [flatItems.length]) + + useEffect(() => { + if (!enableKeyboardNavigation) { + setActiveIndex(-1) + return } - }) + if (flatItems.length === 0) { + setActiveIndex(-1) + return + } + setActiveIndex(0) + }, [enableKeyboardNavigation, flatItems.length, normalizedSearchText]) + + useEffect(() => { + if (!enableKeyboardNavigation || activeIndex < 0) + return + const target = itemRefs.current[activeIndex] + if (target) + target.scrollIntoView({ block: 'nearest' }) + }, [activeIndex, enableKeyboardNavigation, flatItems.length]) + + const handleSelectItem = useCallback((item: { node: NodeOutPutVar, itemData: Var }) => { + const isStructureOutput = item.itemData.type === VarType.object + && (item.itemData.children as StructuredOutput | undefined)?.schema?.properties + const isFile = item.itemData.type === VarType.file && !isStructureOutput + if (!isSupportFileVar && isFile) + return + const valueSelector = buildValueSelector({ + nodeId: item.node.nodeId, + objPath: [], + itemData: item.itemData, + isFlat: item.node.isFlat, + }) + onChange(valueSelector, item.itemData) + onClose?.() + }, [onChange, onClose, isSupportFileVar]) + + useEffect(() => { + if (!enableKeyboardNavigation) + return + const handleKeyDown = (event: KeyboardEvent) => { + if (flatItems.length === 0) + return + if (!['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(event.key)) + return + event.preventDefault() + event.stopPropagation() + if (event.key === 'Escape') { + onClose?.() + return + } + if (event.key === 'Enter') { + if (activeIndex < 0 || activeIndex >= flatItems.length) + return + handleSelectItem(flatItems[activeIndex]) + return + } + const delta = event.key === 'ArrowDown' ? 1 : -1 + setActiveIndex((prev) => { + const baseIndex = prev < 0 ? 0 : prev + const nextIndex = Math.min(Math.max(baseIndex + delta, 0), flatItems.length - 1) + return nextIndex + }) + } + document.addEventListener('keydown', handleKeyDown, true) + return () => { + document.removeEventListener('keydown', handleKeyDown, true) + } + }, [activeIndex, enableKeyboardNavigation, flatItems, handleSelectItem, onClose]) + + let runningIndex = -1 return ( <> { - !hideSearch && ( + shouldShowSearchInput && ( <>
e.stopPropagation()}> = ({ {item.title}
)} - {item.vars.map((v, j) => ( - - ))} + {item.vars.map((v, j) => { + runningIndex += 1 + const itemIndex = runningIndex + return ( + setActiveIndex(itemIndex) : undefined} + registerRef={enableKeyboardNavigation ? (element) => { + itemRefs.current[itemIndex] = element + } : undefined} + /> + ) + })} {item.isFlat && !filteredVars[i + 1]?.isFlat && !!filteredVars.find(item => !item.isFlat) && (
From 267de1861d76e4bf5daeed8b173d2fc4e0b3d9a0 Mon Sep 17 00:00:00 2001 From: zhsama Date: Mon, 19 Jan 2026 23:35:45 +0800 Subject: [PATCH 79/82] perf: reduce input lag in variable pickers --- .../components/agent-node-list/index.tsx | 67 +++++++--- .../variable/var-reference-vars.tsx | 116 ++++++++++++------ 2 files changed, 124 insertions(+), 59 deletions(-) diff --git a/web/app/components/workflow/nodes/_base/components/agent-node-list/index.tsx b/web/app/components/workflow/nodes/_base/components/agent-node-list/index.tsx index c21510483f..a2f050bf7d 100644 --- a/web/app/components/workflow/nodes/_base/components/agent-node-list/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/agent-node-list/index.tsx @@ -84,7 +84,9 @@ const AgentNodeList: FC = ({ }) => { const { t } = useTranslation() const [searchText, setSearchText] = useState('') - const normalizedSearchText = externalSearchText === undefined ? searchText : externalSearchText.trim() + const normalizedSearchText = externalSearchText === undefined ? searchText : externalSearchText + const normalizedSearchTextTrimmed = normalizedSearchText.trim() + const normalizedSearchTextLower = normalizedSearchTextTrimmed.toLowerCase() const shouldShowSearchInput = !hideSearch && externalSearchText === undefined const handleKeyDown = (e: React.KeyboardEvent) => { @@ -95,36 +97,63 @@ const AgentNodeList: FC = ({ } const filteredNodes = useMemo(() => nodes.filter((node) => { - if (!normalizedSearchText) + if (!normalizedSearchTextTrimmed) return true - return node.title.toLowerCase().includes(normalizedSearchText.toLowerCase()) - }), [nodes, normalizedSearchText]) + return node.title.toLowerCase().includes(normalizedSearchTextLower) + }), [nodes, normalizedSearchTextLower, normalizedSearchTextTrimmed]) const [activeIndex, setActiveIndex] = useState(-1) const itemRefs = useRef>([]) + const lastInteractionRef = useRef<'keyboard' | 'mouse' | 'filter' | null>(null) + const filteredNodesRef = useRef(filteredNodes) + const activeIndexRef = useRef(activeIndex) + const onCloseRef = useRef(onClose) useEffect(() => { itemRefs.current = [] }, [filteredNodes.length]) + useEffect(() => { + filteredNodesRef.current = filteredNodes + }, [filteredNodes]) + + useEffect(() => { + activeIndexRef.current = activeIndex + }, [activeIndex]) + + useEffect(() => { + onCloseRef.current = onClose + }, [onClose]) + + const handleHighlightIndex = useCallback((index: number, source: 'keyboard' | 'mouse' | 'filter') => { + lastInteractionRef.current = source + setActiveIndex(index) + }, []) + useEffect(() => { if (!enableKeyboardNavigation) { - setActiveIndex(-1) + if (activeIndex !== -1) + setActiveIndex(-1) return } if (filteredNodes.length === 0) { - setActiveIndex(-1) + if (activeIndex !== -1) + setActiveIndex(-1) return } - setActiveIndex(0) - }, [enableKeyboardNavigation, filteredNodes.length, normalizedSearchText]) + if (activeIndex < 0 || activeIndex >= filteredNodes.length) + handleHighlightIndex(0, 'filter') + }, [enableKeyboardNavigation, filteredNodes.length, activeIndex, handleHighlightIndex]) useEffect(() => { if (!enableKeyboardNavigation || activeIndex < 0) return + if (lastInteractionRef.current !== 'keyboard') + return const target = itemRefs.current[activeIndex] if (target) target.scrollIntoView({ block: 'nearest' }) + lastInteractionRef.current = null }, [activeIndex, enableKeyboardNavigation, filteredNodes.length]) const handleSelectItem = useCallback((node: AgentNode) => { @@ -135,34 +164,34 @@ const AgentNodeList: FC = ({ if (!enableKeyboardNavigation) return const handleKeyDown = (event: KeyboardEvent) => { - if (filteredNodes.length === 0) + const nodes = filteredNodesRef.current + if (nodes.length === 0) return if (!['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(event.key)) return event.preventDefault() event.stopPropagation() if (event.key === 'Escape') { - onClose?.() + onCloseRef.current?.() return } if (event.key === 'Enter') { - if (activeIndex < 0 || activeIndex >= filteredNodes.length) + const index = activeIndexRef.current + if (index < 0 || index >= nodes.length) return - handleSelectItem(filteredNodes[activeIndex]) + handleSelectItem(nodes[index]) return } const delta = event.key === 'ArrowDown' ? 1 : -1 - setActiveIndex((prev) => { - const baseIndex = prev < 0 ? 0 : prev - const nextIndex = Math.min(Math.max(baseIndex + delta, 0), filteredNodes.length - 1) - return nextIndex - }) + const baseIndex = activeIndexRef.current < 0 ? 0 : activeIndexRef.current + const nextIndex = Math.min(Math.max(baseIndex + delta, 0), nodes.length - 1) + handleHighlightIndex(nextIndex, 'keyboard') } document.addEventListener('keydown', handleKeyDown, true) return () => { document.removeEventListener('keydown', handleKeyDown, true) } - }, [activeIndex, enableKeyboardNavigation, filteredNodes, handleSelectItem, onClose]) + }, [enableKeyboardNavigation, handleHighlightIndex, handleSelectItem]) return ( <> @@ -198,7 +227,7 @@ const AgentNodeList: FC = ({ node={node} onSelect={onSelect} isHighlighted={enableKeyboardNavigation && index === activeIndex} - onSetHighlight={enableKeyboardNavigation ? () => setActiveIndex(index) : undefined} + onSetHighlight={enableKeyboardNavigation ? () => handleHighlightIndex(index, 'mouse') : undefined} registerRef={enableKeyboardNavigation ? (element) => { itemRefs.current[index] = element } : undefined} diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx index 066ca7c67b..5e6bcc16d2 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx @@ -315,7 +315,9 @@ const VarReferenceVars: FC = ({ }) => { const { t } = useTranslation() const [searchText, setSearchText] = useState('') - const normalizedSearchText = externalSearchText === undefined ? searchText : externalSearchText.trim() + const normalizedSearchText = externalSearchText === undefined ? searchText : externalSearchText + const normalizedSearchTextTrimmed = normalizedSearchText.trim() + const normalizedSearchTextLower = normalizedSearchTextTrimmed.toLowerCase() const shouldShowSearchInput = !hideSearch && externalSearchText === undefined const handleKeyDown = (e: React.KeyboardEvent) => { @@ -332,32 +334,39 @@ const VarReferenceVars: FC = ({ onClose?.() } - const filteredVars = useMemo(() => { - return vars.filter((v) => { - const children = v.vars.filter(v => checkKeys([v.variable], false).isValid || isSpecialVar(v.variable.split('.')[0])) - return children.length > 0 - }).filter((node) => { - if (!normalizedSearchText) - return node - const searchTextLower = normalizedSearchText.toLowerCase() - const children = node.vars.filter((v) => { - return v.variable.toLowerCase().includes(searchTextLower) || node.title.toLowerCase().includes(searchTextLower) - }) - return children.length > 0 - }).map((node) => { - let vars = node.vars.filter(v => checkKeys([v.variable], false).isValid || isSpecialVar(v.variable.split('.')[0])) - if (normalizedSearchText) { - const searchTextLower = normalizedSearchText.toLowerCase() - if (!node.title.toLowerCase().includes(searchTextLower)) - vars = vars.filter(v => v.variable.toLowerCase().includes(searchTextLower)) - } - - return { + const validatedVars = useMemo(() => { + const res: NodeOutPutVar[] = [] + vars.forEach((node) => { + const nodeVars = node.vars.filter(v => checkKeys([v.variable], false).isValid || isSpecialVar(v.variable.split('.')[0])) + if (nodeVars.length === 0) + return + res.push({ ...node, - vars, - } + vars: nodeVars, + }) }) - }, [normalizedSearchText, vars]) + return res + }, [vars]) + + const filteredVars = useMemo(() => { + if (!normalizedSearchTextTrimmed) + return validatedVars + const res: NodeOutPutVar[] = [] + validatedVars.forEach((node) => { + const titleLower = node.title.toLowerCase() + const matchedByTitle = titleLower.includes(normalizedSearchTextLower) + const nodeVars = matchedByTitle + ? node.vars + : node.vars.filter(v => v.variable.toLowerCase().includes(normalizedSearchTextLower)) + if (nodeVars.length === 0) + return + res.push({ + ...node, + vars: nodeVars, + }) + }) + return res + }, [normalizedSearchTextLower, normalizedSearchTextTrimmed, validatedVars]) const flatItems = useMemo(() => { const items: Array<{ node: NodeOutPutVar, itemData: Var }> = [] @@ -370,29 +379,56 @@ const VarReferenceVars: FC = ({ }, [filteredVars]) const [activeIndex, setActiveIndex] = useState(-1) const itemRefs = useRef>([]) + const lastInteractionRef = useRef<'keyboard' | 'mouse' | 'filter' | null>(null) + const flatItemsRef = useRef(flatItems) + const activeIndexRef = useRef(activeIndex) + const onCloseRef = useRef(onClose) useEffect(() => { itemRefs.current = [] }, [flatItems.length]) + useEffect(() => { + flatItemsRef.current = flatItems + }, [flatItems]) + + useEffect(() => { + activeIndexRef.current = activeIndex + }, [activeIndex]) + + useEffect(() => { + onCloseRef.current = onClose + }, [onClose]) + + const handleHighlightIndex = useCallback((index: number, source: 'keyboard' | 'mouse' | 'filter') => { + lastInteractionRef.current = source + setActiveIndex(index) + }, []) + useEffect(() => { if (!enableKeyboardNavigation) { - setActiveIndex(-1) + if (activeIndex !== -1) + setActiveIndex(-1) return } if (flatItems.length === 0) { - setActiveIndex(-1) + if (activeIndex !== -1) + setActiveIndex(-1) return } - setActiveIndex(0) - }, [enableKeyboardNavigation, flatItems.length, normalizedSearchText]) + if (activeIndex < 0 || activeIndex >= flatItems.length) + handleHighlightIndex(0, 'filter') + }, [enableKeyboardNavigation, flatItems.length, activeIndex, handleHighlightIndex]) useEffect(() => { if (!enableKeyboardNavigation || activeIndex < 0) return + if (lastInteractionRef.current !== 'keyboard') + return const target = itemRefs.current[activeIndex] if (target) target.scrollIntoView({ block: 'nearest' }) + lastInteractionRef.current = null }, [activeIndex, enableKeyboardNavigation, flatItems.length]) const handleSelectItem = useCallback((item: { node: NodeOutPutVar, itemData: Var }) => { @@ -415,34 +451,34 @@ const VarReferenceVars: FC = ({ if (!enableKeyboardNavigation) return const handleKeyDown = (event: KeyboardEvent) => { - if (flatItems.length === 0) + const items = flatItemsRef.current + if (items.length === 0) return if (!['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(event.key)) return event.preventDefault() event.stopPropagation() if (event.key === 'Escape') { - onClose?.() + onCloseRef.current?.() return } if (event.key === 'Enter') { - if (activeIndex < 0 || activeIndex >= flatItems.length) + const index = activeIndexRef.current + if (index < 0 || index >= items.length) return - handleSelectItem(flatItems[activeIndex]) + handleSelectItem(items[index]) return } const delta = event.key === 'ArrowDown' ? 1 : -1 - setActiveIndex((prev) => { - const baseIndex = prev < 0 ? 0 : prev - const nextIndex = Math.min(Math.max(baseIndex + delta, 0), flatItems.length - 1) - return nextIndex - }) + const baseIndex = activeIndexRef.current < 0 ? 0 : activeIndexRef.current + const nextIndex = Math.min(Math.max(baseIndex + delta, 0), items.length - 1) + handleHighlightIndex(nextIndex, 'keyboard') } document.addEventListener('keydown', handleKeyDown, true) return () => { document.removeEventListener('keydown', handleKeyDown, true) } - }, [activeIndex, enableKeyboardNavigation, flatItems, handleSelectItem, onClose]) + }, [enableKeyboardNavigation, handleHighlightIndex, handleSelectItem]) let runningIndex = -1 @@ -511,7 +547,7 @@ const VarReferenceVars: FC = ({ zIndex={zIndex} preferSchemaType={preferSchemaType} isHighlighted={enableKeyboardNavigation && itemIndex === activeIndex} - onSetHighlight={enableKeyboardNavigation ? () => setActiveIndex(itemIndex) : undefined} + onSetHighlight={enableKeyboardNavigation ? () => handleHighlightIndex(itemIndex, 'mouse') : undefined} registerRef={enableKeyboardNavigation ? (element) => { itemRefs.current[itemIndex] = element } : undefined} From 92c54d3c9db81f7ac4c42f623e8e84cffabb2c5b Mon Sep 17 00:00:00 2001 From: zhsama Date: Mon, 19 Jan 2026 23:56:15 +0800 Subject: [PATCH 80/82] feat: merge app and meta defaults when creating workflow nodes --- .../components/agent-node-list/index.tsx | 8 ++- .../variable/var-reference-vars.tsx | 8 ++- .../mixed-variable-text-input/index.tsx | 25 ++++++--- web/app/components/workflow/utils/node.ts | 56 +++++++++++++++++++ web/eslint-suppressions.json | 2 +- 5 files changed, 84 insertions(+), 15 deletions(-) diff --git a/web/app/components/workflow/nodes/_base/components/agent-node-list/index.tsx b/web/app/components/workflow/nodes/_base/components/agent-node-list/index.tsx index c21510483f..b01fab0aa2 100644 --- a/web/app/components/workflow/nodes/_base/components/agent-node-list/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/agent-node-list/index.tsx @@ -199,9 +199,11 @@ const AgentNodeList: FC = ({ onSelect={onSelect} isHighlighted={enableKeyboardNavigation && index === activeIndex} onSetHighlight={enableKeyboardNavigation ? () => setActiveIndex(index) : undefined} - registerRef={enableKeyboardNavigation ? (element) => { - itemRefs.current[index] = element - } : undefined} + registerRef={enableKeyboardNavigation + ? (element) => { + itemRefs.current[index] = element + } + : undefined} /> ))}
diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx index 57b31094bd..9b257518a0 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx @@ -531,9 +531,11 @@ const VarReferenceVars: FC = ({ preferSchemaType={preferSchemaType} isHighlighted={enableKeyboardNavigation && itemIndex === activeIndex} onSetHighlight={enableKeyboardNavigation ? () => setActiveIndex(itemIndex) : undefined} - registerRef={enableKeyboardNavigation ? (element) => { - itemRefs.current[itemIndex] = element - } : undefined} + registerRef={enableKeyboardNavigation + ? (element) => { + itemRefs.current[itemIndex] = element + } + : undefined} /> ) })} diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx index 703559a2ee..cf3dcdef18 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx @@ -26,7 +26,7 @@ import { VarKindType as VarKindTypeEnum } from '@/app/components/workflow/nodes/ import { Type } from '@/app/components/workflow/nodes/llm/types' import { useStore } from '@/app/components/workflow/store' import { BlockEnum, EditionType, isPromptMessageContext, PromptRole, VarType } from '@/app/components/workflow/types' -import { generateNewNode, getNodeCustomTypeByNodeDataType } from '@/app/components/workflow/utils' +import { generateNewNode, getNodeCustomTypeByNodeDataType, mergeNodeDefaultData } from '@/app/components/workflow/utils' import { useGetLanguage } from '@/context/i18n' import { useStrategyProviders } from '@/service/use-strategy' import { cn } from '@/utils/classnames' @@ -169,6 +169,7 @@ const MixedVariableTextInput = ({ const nodes = useNodes() const controlPromptEditorRerenderKey = useStore(s => s.controlPromptEditorRerenderKey) const setControlPromptEditorRerenderKey = useStore(s => s.setControlPromptEditorRerenderKey) + const nodesDefaultConfigs = useStore(s => s.nodesDefaultConfigs) const { nodesMap: nodesMetaDataMap } = useNodesMetaData() const { handleSyncWorkflowDraft } = useNodesSyncDraft() const [isSubGraphModalOpen, setIsSubGraphModalOpen] = useState(false) @@ -219,8 +220,9 @@ const MixedVariableTextInput = ({ }) => { if (!toolNodeId) return null - const defaultValue = nodesMetaDataMap?.[payload.nodeType]?.defaultValue as Partial | undefined - if (!defaultValue) + const metaDefault = nodesMetaDataMap?.[payload.nodeType]?.defaultValue as Partial | undefined + const appDefault = nodesDefaultConfigs?.[payload.nodeType] as Partial | undefined + if (!metaDefault && !appDefault) return null const { getNodes, setNodes } = reactFlowStore.getState() @@ -231,15 +233,22 @@ const MixedVariableTextInput = ({ const nextNodes = shouldReplace ? currentNodes.filter(node => node.id !== payload.extractorNodeId) : currentNodes + const mergedData = mergeNodeDefaultData({ + nodeType: payload.nodeType, + metaDefault, + appDefault, + overrideData: payload.data, + }) + const resolvedTitle = mergedData.title ?? metaDefault?.title ?? appDefault?.title ?? '' + const resolvedDesc = mergedData.desc ?? metaDefault?.desc ?? appDefault?.desc ?? '' const { newNode } = generateNewNode({ id: payload.extractorNodeId, type: getNodeCustomTypeByNodeDataType(payload.nodeType), data: { - ...defaultValue, - ...payload.data, + ...mergedData, type: payload.nodeType, - title: defaultValue?.title ?? '', - desc: defaultValue.desc || '', + title: resolvedTitle, + desc: resolvedDesc, parent_node_id: toolNodeId, }, position: { @@ -254,7 +263,7 @@ const MixedVariableTextInput = ({ } return existingNode - }, [handleSyncWorkflowDraft, nodesMetaDataMap, reactFlowStore, toolNodeId]) + }, [handleSyncWorkflowDraft, nodesDefaultConfigs, nodesMetaDataMap, reactFlowStore, toolNodeId]) const ensureAssembleExtractorNode = useCallback(() => { if (!assembleExtractorNodeId) diff --git a/web/app/components/workflow/utils/node.ts b/web/app/components/workflow/utils/node.ts index 08ffb1ab1f..72f559835c 100644 --- a/web/app/components/workflow/utils/node.ts +++ b/web/app/components/workflow/utils/node.ts @@ -1,6 +1,8 @@ +import type { CodeNodeType, OutputVar } from '../nodes/code/types' import type { IterationNodeType } from '../nodes/iteration/types' import type { LoopNodeType } from '../nodes/loop/types' import type { + CommonNodeType, Node, } from '../types' import { @@ -20,6 +22,60 @@ import { BlockEnum, } from '../types' +type MergeNodeDefaultDataParams>> = { + nodeType: BlockEnum + metaDefault?: Partial + appDefault?: Partial + baseData?: Partial + overrideData?: Partial +} + +const pickNonEmptyArray = (value?: T[]) => { + return Array.isArray(value) && value.length > 0 ? value : undefined +} + +export const mergeNodeDefaultData = >>({ + nodeType, + metaDefault, + appDefault, + baseData, + overrideData, +}: MergeNodeDefaultDataParams) => { + const merged = { + ...(metaDefault || {}), + ...(appDefault || {}), + ...(baseData || {}), + ...(overrideData || {}), + } as Partial + + if (nodeType === BlockEnum.Code) { + const codeMetaDefault = (metaDefault || {}) as Partial + const codeAppDefault = (appDefault || {}) as Partial + const codeBase = (baseData || {}) as Partial + const codeOverride = (overrideData || {}) as Partial + const codeDefaults = { + ...codeMetaDefault, + ...codeAppDefault, + } + + const outputs: OutputVar = { + ...(codeDefaults.outputs || {}), + ...(codeBase.outputs || {}), + ...(codeOverride.outputs || {}), + } + if (Object.keys(outputs).length > 0) + (merged as Partial).outputs = outputs + + const resolvedVariables = pickNonEmptyArray(codeBase.variables) + ?? pickNonEmptyArray(codeOverride.variables) + ?? pickNonEmptyArray(codeDefaults.variables) + if (resolvedVariables) + (merged as Partial).variables = resolvedVariables + } + + return merged +} + export function generateNewNode({ data, position, id, zIndex, type, ...rest }: Omit, 'id'> & { id?: string }): { newNode: Node newIterationStartNode?: Node diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 72b07644a9..6d8a20de38 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -4538,4 +4538,4 @@ "count": 2 } } -} +} \ No newline at end of file From c44aaf18835a61d837131efa4e8c5f6f8b86cf7a Mon Sep 17 00:00:00 2001 From: zhsama Date: Tue, 20 Jan 2026 00:42:19 +0800 Subject: [PATCH 81/82] fix: Fix prompt editor trigger match to use current selection --- .../plugins/component-picker-block/index.tsx | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx b/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx index d36c8e571f..78b7f1e753 100644 --- a/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx +++ b/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx @@ -25,7 +25,9 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext import { LexicalTypeaheadMenuPlugin } from '@lexical/react/LexicalTypeaheadMenuPlugin' import { $getRoot, + $getSelection, $insertNodes, + $isRangeSelection, KEY_ESCAPE_COMMAND, } from 'lexical' import { @@ -97,6 +99,20 @@ const ComponentPicker = ({ maxLength: useExternalSearch ? 75 : 0, }) + const getMatchFromSelection = useCallback(() => { + const selection = $getSelection() + if (!$isRangeSelection(selection) || !selection.isCollapsed()) + return null + const anchor = selection.anchor + if (anchor.type !== 'text') + return null + const anchorNode = anchor.getNode() + if (!anchorNode.isSimpleText()) + return null + const text = anchorNode.getTextContent().slice(0, anchor.offset) + return checkForTriggerMatch(text, editor) + }, [checkForTriggerMatch, editor]) + const [queryString, setQueryString] = useState(null) eventEmitter?.useSubscription((v: any) => { @@ -139,7 +155,10 @@ const ComponentPicker = ({ const handleSelectWorkflowVariable = useCallback((variables: string[]) => { editor.update(() => { - const needRemove = $splitNodeContainingQuery(checkForTriggerMatch(triggerString, editor)!) + const match = getMatchFromSelection() + if (!match) + return + const needRemove = $splitNodeContainingQuery(match) if (needRemove) needRemove.remove() }) @@ -159,7 +178,7 @@ const ComponentPicker = ({ else { editor.dispatchCommand(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, variables) } - }, [editor, currentBlock?.generatorType, checkForTriggerMatch, triggerString]) + }, [editor, currentBlock?.generatorType, getMatchFromSelection]) const handleClose = useCallback(() => { const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape' }) @@ -168,7 +187,7 @@ const ComponentPicker = ({ const handleSelectAssembleVariables = useCallback((): ValueSelector | null => { editor.update(() => { - const match = checkForTriggerMatch(triggerString, editor) + const match = getMatchFromSelection() if (!match) return const needRemove = $splitNodeContainingQuery(match) @@ -180,11 +199,14 @@ const ComponentPicker = ({ editor.dispatchCommand(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, assembleVariables) handleClose() return assembleVariables ?? null - }, [editor, checkForTriggerMatch, triggerString, workflowVariableBlock, handleClose]) + }, [editor, getMatchFromSelection, workflowVariableBlock, handleClose]) const handleSelectAgent = useCallback((agent: { id: string, title: string }) => { editor.update(() => { - const needRemove = $splitNodeContainingQuery(checkForTriggerMatch(triggerString, editor)!) + const match = getMatchFromSelection() + if (!match) + return + const needRemove = $splitNodeContainingQuery(match) if (needRemove) needRemove.remove() @@ -200,7 +222,7 @@ const ComponentPicker = ({ }) agentBlock?.onSelect?.(agent) handleClose() - }, [editor, checkForTriggerMatch, triggerString, agentBlock, handleClose]) + }, [editor, getMatchFromSelection, agentBlock, handleClose]) const isAgentTrigger = triggerString === '@' && agentBlock?.show const showAssembleVariables = triggerString === '/' From d69e7eb12ad8840e300bb51c1f9a852dab8c4ddb Mon Sep 17 00:00:00 2001 From: zhsama Date: Tue, 20 Jan 2026 01:32:42 +0800 Subject: [PATCH 82/82] fix: Fix variable insertion to only remove @ trigger on current line --- .../nodes/tool/components/mixed-variable-text-input/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx index cf3dcdef18..a0dbcd1070 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx @@ -500,7 +500,8 @@ const MixedVariableTextInput = ({ if (!onChange) return - const valueWithoutTrigger = value.replace(/@$/, '') + // compute words after the latest '@' and delete them + const valueWithoutTrigger = value.replace(/@[^@\n]*$/, '') const newValue = `{{@${agent.id}.context@}}${valueWithoutTrigger}` if (toolNodeId && paramKey) {