diff --git a/web/app/components/sub-graph/components/config-panel.tsx b/web/app/components/sub-graph/components/config-panel.tsx index c466de66ea..094f48e656 100644 --- a/web/app/components/sub-graph/components/config-panel.tsx +++ b/web/app/components/sub-graph/components/config-panel.tsx @@ -1,12 +1,16 @@ 'use client' import type { FC } from 'react' -import type { Item } from '@/app/components/base/select' import type { NestedNodeConfig } from '@/app/components/workflow/nodes/_base/types' import type { Node, NodeOutPutVar, ValueSelector } from '@/app/components/workflow/types' -import { RiCheckLine } from '@remixicon/react' +import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react' import { memo, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { SimpleSelect } from '@/app/components/base/select' +import Button from '@/app/components/base/button' +import { + PortalToFollowElem, + PortalToFollowElemContent, + PortalToFollowElemTrigger, +} from '@/app/components/base/portal-to-follow-elem' 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' @@ -55,15 +59,16 @@ const ConfigPanel: FC = ({ }) }, [nestedNodeConfig, onNestedNodeConfigChange, resolvedExtractorId]) + const [nullStrategyOpen, setNullStrategyOpen] = useState(false) const whenOutputNoneOptions = useMemo(() => ([ { - value: 'raise_error', - name: t('subGraphModal.whenOutputNone.error', { ns: 'workflow' }), + value: 'raise_error' as const, + label: t('subGraphModal.whenOutputNone.error', { ns: 'workflow' }), description: t('subGraphModal.whenOutputNone.errorDesc', { ns: 'workflow' }), }, { - value: 'use_default', - name: t('subGraphModal.whenOutputNone.default', { ns: 'workflow' }), + value: 'use_default' as const, + label: t('subGraphModal.whenOutputNone.default', { ns: 'workflow' }), description: t('subGraphModal.whenOutputNone.defaultDesc', { ns: 'workflow' }), }, ]), [t]) @@ -71,12 +76,10 @@ const ConfigPanel: FC = ({ whenOutputNoneOptions.find(item => item.value === nestedNodeConfig.null_strategy) ?? whenOutputNoneOptions[0] ), [nestedNodeConfig.null_strategy, whenOutputNoneOptions]) - const handleNullStrategyChange = useCallback((item: Item) => { - if (typeof item.value !== 'string') - return + const handleNullStrategyChange = useCallback((value: NestedNodeConfig['null_strategy']) => { onNestedNodeConfigChange({ ...nestedNodeConfig, - null_strategy: item.value as NestedNodeConfig['null_strategy'], + null_strategy: value, }) }, [nestedNodeConfig, onNestedNodeConfigChange]) @@ -139,29 +142,50 @@ const ConfigPanel: FC = ({ - ( -
-
- {selected && ( - - )} + + { + e.stopPropagation() + e.nativeEvent.stopImmediatePropagation() + setNullStrategyOpen(v => !v) + }} + > + + + +
+ {whenOutputNoneOptions.map(option => ( +
{ + e.stopPropagation() + e.nativeEvent.stopImmediatePropagation() + handleNullStrategyChange(option.value) + setNullStrategyOpen(false) + }} + > +
+ {nestedNodeConfig.null_strategy === option.value && ( + + )} +
+
+
{option.label}
+
{option.description}
+
-
-
{item.name}
-
{item.description}
-
-
- )} - /> -
+ ))} +
+ + )} >
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 4dd020914b..8aab855d82 100644 --- a/web/app/components/sub-graph/components/sub-graph-children.tsx +++ b/web/app/components/sub-graph/components/sub-graph-children.tsx @@ -21,11 +21,12 @@ type SubGraphChildrenProps variant: 'assemble' title: string extractorNodeId: string + nestedNodeConfig: NestedNodeConfig + onNestedNodeConfigChange: (config: NestedNodeConfig) => void } const SubGraphChildren: FC = (props) => { const { - variant, title, extractorNodeId, } = props @@ -57,10 +58,8 @@ const SubGraphChildren: FC = (props) => { return vars.filter(item => item.nodeId === extractorNode.id) }, [extractorNode, getNodeAvailableVars, isChatMode]) - const agentProps = variant === 'agent' ? props : null - const panelRight = useMemo(() => { - if (!agentProps || selectedNode) + if (selectedNode) return null return ( @@ -72,23 +71,15 @@ const SubGraphChildren: FC = (props) => {
) - }, [agentProps, availableNodes, availableVars, extractorNodeId, nodePanelWidth, selectedNode, title]) - - if (variant === 'assemble') { - return ( - - ) - } + }, [availableNodes, availableVars, extractorNodeId, nodePanelWidth, props.nestedNodeConfig, props.onNestedNodeConfigChange, selectedNode, title]) return ( void }) const SubGraphMain: FC = (props) => { @@ -119,6 +121,8 @@ const SubGraphMain: FC = (props) => { variant="assemble" title={title} extractorNodeId={extractorNodeId} + nestedNodeConfig={props.nestedNodeConfig} + onNestedNodeConfigChange={props.onNestedNodeConfigChange} /> ) diff --git a/web/app/components/sub-graph/index.tsx b/web/app/components/sub-graph/index.tsx index 100dc3f576..9996d18064 100644 --- a/web/app/components/sub-graph/index.tsx +++ b/web/app/components/sub-graph/index.tsx @@ -253,6 +253,8 @@ const SubGraphContent: FC = (props) => { title={sourceTitle} extractorNodeId={`${toolNodeId}_ext_${paramKey}`} configsMap={configsMap} + nestedNodeConfig={props.nestedNodeConfig} + onNestedNodeConfigChange={props.onNestedNodeConfigChange} selectableNodeTypes={selectableNodeTypes} onSave={onSave} onSyncWorkflowDraft={onSyncWorkflowDraft} diff --git a/web/app/components/sub-graph/types.ts b/web/app/components/sub-graph/types.ts index cf2ef2f731..3a0d081b29 100644 --- a/web/app/components/sub-graph/types.ts +++ b/web/app/components/sub-graph/types.ts @@ -43,6 +43,8 @@ export type AgentSubGraphProps = BaseSubGraphProps & { export type AssembleSubGraphProps = BaseSubGraphProps & { variant: 'assemble' title: string + nestedNodeConfig: NestedNodeConfig + onNestedNodeConfigChange: (config: NestedNodeConfig) => void extractorNode?: Node } 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 4a3d0fe70b..0abfeb9bbd 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 @@ -328,12 +328,12 @@ const FormInputItem: FC = ({ && assemblePlaceholder && normalizedValue.includes(assemblePlaceholder) const resolvedType = isAssembleValue - ? VarKindType.mixed + ? VarKindType.nested_node : newType ?? (varInput?.type === VarKindType.nested_node ? VarKindType.nested_node : getVarKindType()) const resolvedNestedNodeConfig = resolvedType === VarKindType.nested_node ? (nestedNodeConfig ?? varInput?.nested_node_config ?? { - extractor_node_id: '', - output_selector: [], + extractor_node_id: nodeId && variable ? `${nodeId}_ext_${variable}` : '', + output_selector: ['result'], null_strategy: 'use_default', default_value: '', }) diff --git a/web/app/components/workflow/nodes/tool/components/context-generate-modal/use-context-gen-data.ts b/web/app/components/workflow/nodes/tool/components/context-generate-modal/hooks/use-context-gen-data.ts similarity index 100% rename from web/app/components/workflow/nodes/tool/components/context-generate-modal/use-context-gen-data.ts rename to web/app/components/workflow/nodes/tool/components/context-generate-modal/hooks/use-context-gen-data.ts diff --git a/web/app/components/workflow/nodes/tool/components/context-generate-modal/hooks/use-context-generate.ts b/web/app/components/workflow/nodes/tool/components/context-generate-modal/hooks/use-context-generate.ts index 8385312aef..cfed680402 100644 --- a/web/app/components/workflow/nodes/tool/components/context-generate-modal/hooks/use-context-generate.ts +++ b/web/app/components/workflow/nodes/tool/components/context-generate-modal/hooks/use-context-generate.ts @@ -13,7 +13,7 @@ import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' import { languages } from '@/i18n-config/language' import { fetchContextGenerateSuggestedQuestions, generateContext } from '@/service/debug' import { AppModeEnum } from '@/types/app' -import useContextGenData from '../use-context-gen-data' +import useContextGenData from './use-context-gen-data' export type ContextGenerateChatMessage = ContextGenerateMessage & { durationMs?: number @@ -68,6 +68,7 @@ type UseContextGenerateResult = { handleReset: () => void handleFetchSuggestedQuestions: () => Promise abortSuggestedQuestions: () => void + resetSuggestions: () => void defaultAssistantMessage: string versionOptions: VersionOption[] currentVersionLabel: string @@ -98,15 +99,8 @@ const useContextGenerate = ({ { defaultValue: [] }, ) - const [suggestedQuestions, setSuggestedQuestions] = useSessionStorageState( - `${storageKey}-suggested-questions`, - { defaultValue: [] }, - ) - - const [hasFetchedSuggestions, setHasFetchedSuggestions] = useSessionStorageState( - `${storageKey}-suggested-questions-fetched`, - { defaultValue: false }, - ) + const [suggestedQuestions, setSuggestedQuestions] = useState([]) + const [hasFetchedSuggestions, setHasFetchedSuggestions] = useState(false) const [isFetchingSuggestions, { setTrue: setFetchingSuggestionsTrue, setFalse: setFetchingSuggestionsFalse }] = useBoolean(false) const suggestedQuestionsAbortControllerRef = useRef(null) @@ -269,8 +263,6 @@ const useContextGenerate = ({ promptLanguage, setFetchingSuggestionsFalse, setFetchingSuggestionsTrue, - setHasFetchedSuggestions, - setSuggestedQuestions, t, toolNodeId, ]) @@ -279,6 +271,11 @@ const useContextGenerate = ({ suggestedQuestionsAbortControllerRef.current?.abort() }, []) + const resetSuggestions = useCallback(() => { + setSuggestedQuestions([]) + setHasFetchedSuggestions(false) + }, []) + const generateStartRef = useRef(null) const handleGenerate = useCallback(async () => { const trimmed = inputValue.trim() @@ -356,8 +353,8 @@ const useContextGenerate = ({ promptMessages: promptMessages ?? [], inputValue, setInputValue, - suggestedQuestions: suggestedQuestions ?? [], - hasFetchedSuggestions: hasFetchedSuggestions ?? false, + suggestedQuestions, + hasFetchedSuggestions, isGenerating, model, handleModelChange, @@ -366,6 +363,7 @@ const useContextGenerate = ({ handleReset, handleFetchSuggestedQuestions, abortSuggestedQuestions, + resetSuggestions, defaultAssistantMessage, versionOptions, currentVersionLabel, diff --git a/web/app/components/workflow/nodes/tool/components/context-generate-modal/index.tsx b/web/app/components/workflow/nodes/tool/components/context-generate-modal/index.tsx index 5e12ef4766..dd8e0e0c06 100644 --- a/web/app/components/workflow/nodes/tool/components/context-generate-modal/index.tsx +++ b/web/app/components/workflow/nodes/tool/components/context-generate-modal/index.tsx @@ -104,6 +104,7 @@ const ContextGenerateModal = forwardRef(({ handleReset, handleFetchSuggestedQuestions, abortSuggestedQuestions, + resetSuggestions, defaultAssistantMessage, versionOptions, currentVersionLabel, @@ -118,8 +119,9 @@ const ContextGenerateModal = forwardRef(({ const handleCloseModal = useCallback(() => { abortSuggestedQuestions() + resetSuggestions() onClose() - }, [abortSuggestedQuestions, onClose]) + }, [abortSuggestedQuestions, onClose, resetSuggestions]) useImperativeHandle(ref, () => ({ onOpen: () => { diff --git a/web/app/components/workflow/nodes/tool/components/context-generate-modal/utils/storage.ts b/web/app/components/workflow/nodes/tool/components/context-generate-modal/utils/storage.ts new file mode 100644 index 0000000000..270a9519e3 --- /dev/null +++ b/web/app/components/workflow/nodes/tool/components/context-generate-modal/utils/storage.ts @@ -0,0 +1,37 @@ +// Storage key prefix used by useContextGenData +const CONTEXT_GEN_PREFIX = 'context-gen-' + +/** + * Build storage key from flowId, toolNodeId, and paramKey. + * Mirrors the logic in context-generate-modal/index.tsx. + */ +export const buildContextGenStorageKey = ( + flowId: string | undefined, + toolNodeId: string, + paramKey: string, +): string => { + const segments = [flowId || 'unknown', toolNodeId, paramKey].filter(Boolean) + return segments.join('-') +} + +export const getContextGenStorageKeys = (storageKey: string): string[] => { + return [ + `${CONTEXT_GEN_PREFIX}${storageKey}-versions`, + `${CONTEXT_GEN_PREFIX}${storageKey}-version-index`, + `${storageKey}-messages`, + `${storageKey}-suggested-questions`, + `${storageKey}-suggested-questions-fetched`, + ] +} + +export const clearContextGenStorage = (storageKey: string): void => { + const keys = getContextGenStorageKeys(storageKey) + keys.forEach((key) => { + try { + sessionStorage.removeItem(key) + } + catch { + // Ignore errors (e.g., SSR or private browsing) + } + }) +} diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/hooks/index.ts b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/hooks/index.ts index 818b242598..6bfdbe2882 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/hooks/index.ts +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/hooks/index.ts @@ -1,7 +1,9 @@ export { AGENT_CONTEXT_VAR_PATTERN, + buildAssembleNestedNodeConfig, buildAssemblePlaceholder, getAgentNodeIdFromContextVar, + getDefaultOutputKey, useMixedVariableExtractor, } from './use-mixed-variable-extractor' export type { DetectedAgent } from './use-mixed-variable-extractor' diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/hooks/use-mixed-variable-extractor.ts b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/hooks/use-mixed-variable-extractor.ts index c38ac71a0b..f956a0d2f6 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/hooks/use-mixed-variable-extractor.ts +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/hooks/use-mixed-variable-extractor.ts @@ -1,6 +1,7 @@ import type { ReactFlowState } from 'reactflow' import type { ToolParameter } from '@/app/components/tools/types' -import type { CodeNodeType } from '@/app/components/workflow/nodes/code/types' +import type { NestedNodeConfig } from '@/app/components/workflow/nodes/_base/types' +import type { CodeNodeType, OutputVar } from '@/app/components/workflow/nodes/code/types' import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types' import type { CommonNodeType, @@ -33,6 +34,31 @@ export const buildAssemblePlaceholder = (toolNodeId?: string, paramKey?: string) return `{{#${toolNodeId}_ext_${paramKey}.result#}}` } +export const getDefaultOutputKey = (outputs?: OutputVar): string => { + if (!outputs) + return '' + const keys = Object.keys(outputs) + if (keys.length === 0) + return '' + // Reason: 'result' is the conventional default output key for code nodes + if (keys.includes('result')) + return 'result' + return keys[0] +} + +export const buildAssembleNestedNodeConfig = ( + extractorNodeId: string, + outputs?: OutputVar, +): NestedNodeConfig => { + const defaultOutputKey = getDefaultOutputKey(outputs) + return { + extractor_node_id: extractorNodeId, + output_selector: defaultOutputKey ? [defaultOutputKey] : [], + null_strategy: 'use_default', + default_value: '', + } +} + const resolvePromptText = (item?: PromptItem): string => { if (!item) return '' 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 a3349ad9fc..d602803922 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 @@ -30,10 +30,12 @@ import { useGetLanguage } from '@/context/i18n' import { useStrategyProviders } from '@/service/use-strategy' import { cn } from '@/utils/classnames' import ContextGenerateModal from '../context-generate-modal' +import { buildContextGenStorageKey, clearContextGenStorage } from '../context-generate-modal/utils/storage' import SubGraphModal from '../sub-graph-modal' import { AgentHeaderBar, Placeholder } from './components' import { AGENT_CONTEXT_VAR_PATTERN, + buildAssembleNestedNodeConfig, buildAssemblePlaceholder, getAgentNodeIdFromContextVar, useMixedVariableExtractor, @@ -319,23 +321,30 @@ const MixedVariableTextInput = ({ return null const extractorNodeId = assembleExtractorNodeId || `${toolNodeId}_ext_${paramKey}` ensureAssembleExtractorNode() - onChange?.(assemblePlaceholder, VarKindTypeEnum.mixed, null) + const { getNodes } = reactFlowStore.getState() + const extractorNode = getNodes().find(node => node.id === extractorNodeId) + const outputs = (extractorNode?.data as { outputs?: Record } | undefined)?.outputs + const nestedNodeConfig = buildAssembleNestedNodeConfig(extractorNodeId, outputs as import('@/app/components/workflow/nodes/code/types').OutputVar | undefined) + onChange?.(assemblePlaceholder, VarKindTypeEnum.nested_node, nestedNodeConfig) setControlPromptEditorRerenderKey(Date.now()) setIsContextGenerateModalOpen(true) setTimeout(() => { contextGenerateModalRef.current?.onOpen() }, 0) return [extractorNodeId, 'result'] - }, [assembleExtractorNodeId, assemblePlaceholder, ensureAssembleExtractorNode, onChange, paramKey, setControlPromptEditorRerenderKey, toolNodeId]) + }, [assembleExtractorNodeId, assemblePlaceholder, ensureAssembleExtractorNode, onChange, paramKey, reactFlowStore, setControlPromptEditorRerenderKey, toolNodeId]) const handleAssembleRemove = useCallback(() => { - if (!onChange || !assemblePlaceholder) + if (!onChange || !assemblePlaceholder || !toolNodeId) return removeExtractorNode() onChange('', VarKindTypeEnum.mixed, null) setControlPromptEditorRerenderKey(Date.now()) - }, [assemblePlaceholder, onChange, removeExtractorNode, setControlPromptEditorRerenderKey]) + + const storageKey = buildContextGenStorageKey(configsMap?.flowId, toolNodeId, paramKey) + clearContextGenStorage(storageKey) + }, [assemblePlaceholder, configsMap?.flowId, onChange, paramKey, removeExtractorNode, setControlPromptEditorRerenderKey, toolNodeId]) const handleOpenSubGraphModal = useCallback(() => { setIsSubGraphModalOpen(true) 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 f620f42f3f..7d5cab1f1f 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 @@ -92,7 +92,9 @@ const SubGraphModal: FC = (props) => { const current = toolParam?.nested_node_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] + const defaultOutputSelector = isAgentVariant + ? ['structured_output', paramKey] + : ['result'] return { extractor_node_id: current?.extractor_node_id || extractorNodeId, @@ -100,12 +102,9 @@ const SubGraphModal: FC = (props) => { null_strategy: current?.null_strategy || 'use_default', default_value: current?.default_value ?? '', } - }, [extractorNodeId, paramKey, toolParam?.nested_node_config]) + }, [extractorNodeId, isAgentVariant, paramKey, toolParam?.nested_node_config]) const handleNestedNodeConfigChange = useCallback((config: NestedNodeConfig) => { - if (!isAgentVariant) - return - const { getNodes, setNodes } = reactflowStore.getState() const nextNodes = getNodes().map((node) => { if (node.id !== toolNodeId) @@ -124,7 +123,7 @@ const SubGraphModal: FC = (props) => { ...toolData.tool_parameters, [paramKey]: { ...currentParam, - type: currentParam.type || VarKindType.nested_node, + type: VarKindType.nested_node, nested_node_config: config, }, }, @@ -133,10 +132,10 @@ const SubGraphModal: FC = (props) => { }) setNodes(nextNodes) handleSyncWorkflowDraft() - }, [handleSyncWorkflowDraft, isAgentVariant, paramKey, reactflowStore, toolNodeId]) + }, [handleSyncWorkflowDraft, paramKey, reactflowStore, toolNodeId]) useEffect(() => { - if (!isAgentVariant || !toolParam || (toolParam.type && toolParam.type !== VarKindType.nested_node)) + if (!toolParam || (toolParam.type && toolParam.type !== VarKindType.nested_node)) return const current = toolParam.nested_node_config @@ -147,7 +146,7 @@ const SubGraphModal: FC = (props) => { if (needsExtractor || needsNullStrategy || needsOutputSelector || needsDefaultValue) handleNestedNodeConfigChange(nestedNodeConfig) - }, [handleNestedNodeConfigChange, isAgentVariant, nestedNodeConfig, toolParam]) + }, [handleNestedNodeConfigChange, nestedNodeConfig, toolParam]) const getUserPromptText = useCallback((promptTemplate?: PromptTemplateItem[] | PromptItem) => { if (!promptTemplate) @@ -220,6 +219,7 @@ const SubGraphModal: FC = (props) => { if (!toolData.tool_parameters?.[paramKey]) return node + const currentParam = toolData.tool_parameters[paramKey] return { ...node, data: { @@ -227,8 +227,10 @@ const SubGraphModal: FC = (props) => { tool_parameters: { ...toolData.tool_parameters, [paramKey]: { - ...toolData.tool_parameters[paramKey], + ...currentParam, + type: VarKindType.nested_node, value: nextValue, + nested_node_config: currentParam.nested_node_config ?? nestedNodeConfig, }, }, }, @@ -238,7 +240,7 @@ const SubGraphModal: FC = (props) => { }) setNodes(nextNodes) setControlPromptEditorRerenderKey(Date.now()) - }, [assemblePlaceholder, extractorNodeId, getUserPromptText, isAgentVariant, paramKey, reactflowStore, resolvedAgentNodeId, setControlPromptEditorRerenderKey, toolNodeId]) + }, [assemblePlaceholder, extractorNodeId, getUserPromptText, isAgentVariant, nestedNodeConfig, paramKey, reactflowStore, resolvedAgentNodeId, setControlPromptEditorRerenderKey, toolNodeId]) return ( @@ -298,6 +300,8 @@ const SubGraphModal: FC = (props) => { paramKey={paramKey} title={props.title} configsMap={configsMap} + nestedNodeConfig={nestedNodeConfig} + onNestedNodeConfigChange={handleNestedNodeConfigChange} extractorNode={extractorNode as Node | undefined} toolParamValue={toolParamValue} parentAvailableNodes={parentAvailableNodes}