diff --git a/web/app/components/snippets/components/snippet-main.tsx b/web/app/components/snippets/components/snippet-main.tsx index bc1b2620ac8..ea3590b6560 100644 --- a/web/app/components/snippets/components/snippet-main.tsx +++ b/web/app/components/snippets/components/snippet-main.tsx @@ -349,7 +349,6 @@ const SnippetMain = ({ handleWorkflowStartRunInWorkflow, } = useSnippetStartRun({ handleRun, - inputFields: fields, }) const { getWorkflowRunAndTraceUrl } = useGetRunAndTraceUrl(snippetId) useEffect(() => { diff --git a/web/app/components/snippets/hooks/__tests__/use-create-snippet-from-selection.spec.tsx b/web/app/components/snippets/hooks/__tests__/use-create-snippet-from-selection.spec.tsx index 9570ecfdc64..ebd8b245ad8 100644 --- a/web/app/components/snippets/hooks/__tests__/use-create-snippet-from-selection.spec.tsx +++ b/web/app/components/snippets/hooks/__tests__/use-create-snippet-from-selection.spec.tsx @@ -122,4 +122,101 @@ describe('useCreateSnippetFromSelection', () => { ]) expect(onClose).toHaveBeenCalled() }) + + it('should convert system variables used by if-else and variable aggregator nodes', () => { + const selectedNodes = [ + createNode('llm', { + type: BlockEnum.LLM, + title: 'LLM', + }), + createNode('if-else', { + type: BlockEnum.IfElse, + cases: [{ + case_id: 'case-1', + conditions: [{ + id: 'condition-1', + variable_selector: ['sys', 'query'], + comparison_operator: 'contains', + value: 'hello', + }], + }], + }), + createNode('variable-aggregator', { + type: BlockEnum.VariableAggregator, + variables: [ + ['sys', 'files'], + ['llm', 'text'], + ], + advanced_settings: { + group_enabled: true, + groups: [{ + groupId: 'group-1', + group_name: 'Group1', + variables: [ + ['sys', 'workflow_id'], + ], + }], + }, + }), + ] + const onClose = vi.fn() + + const { result } = renderHook(() => useCreateSnippetFromSelection({ + edges: [], + selectedNodes, + onClose, + })) + + act(() => { + result.current.handleOpenCreateSnippet() + }) + + const dialogProps = (result.current.createSnippetDialog as ReactElement).props + + expect(dialogProps.inputFields).toEqual([ + { + label: 'query', + variable: 'query', + type: PipelineInputVarType.textInput, + required: true, + }, + { + label: 'files', + variable: 'files', + type: PipelineInputVarType.multiFiles, + required: true, + }, + { + label: 'workflow_id', + variable: 'workflow_id', + type: PipelineInputVarType.textInput, + required: true, + }, + ]) + + const ifElseNode = dialogProps.selectedGraph?.nodes.find(node => node.id === 'if-else') + const aggregatorNode = dialogProps.selectedGraph?.nodes.find(node => node.id === 'variable-aggregator') + const ifElseData = ifElseNode?.data as Record + const aggregatorData = aggregatorNode?.data as { + variables?: string[][] + advanced_settings?: { groups?: Array<{ variables?: string[][] }> } + } + + expect(ifElseData.cases).toEqual([{ + case_id: 'case-1', + conditions: [{ + id: 'condition-1', + variable_selector: [SNIPPET_INPUT_FIELD_NODE_ID, 'query'], + comparison_operator: 'contains', + value: 'hello', + }], + }]) + expect(aggregatorData.variables).toEqual([ + [SNIPPET_INPUT_FIELD_NODE_ID, 'files'], + ['llm', 'text'], + ]) + expect(aggregatorData.advanced_settings?.groups?.[0]?.variables).toEqual([ + [SNIPPET_INPUT_FIELD_NODE_ID, 'workflow_id'], + ]) + }) }) diff --git a/web/app/components/snippets/hooks/__tests__/use-snippet-start-run.spec.ts b/web/app/components/snippets/hooks/__tests__/use-snippet-start-run.spec.ts index 909110939ac..58edaccbbfb 100644 --- a/web/app/components/snippets/hooks/__tests__/use-snippet-start-run.spec.ts +++ b/web/app/components/snippets/hooks/__tests__/use-snippet-start-run.spec.ts @@ -4,6 +4,7 @@ import { act } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { WorkflowRunningStatus } from '@/app/components/workflow/types' import { PipelineInputVarType } from '@/models/pipeline' +import { useSnippetDetailStore } from '../../store' import { useSnippetStartRun } from '../use-snippet-start-run' const mockWorkflowStoreGetState = vi.fn() @@ -38,6 +39,7 @@ const inputFields: SnippetInputField[] = [ describe('useSnippetStartRun', () => { beforeEach(() => { vi.clearAllMocks() + useSnippetDetailStore.getState().reset() mockWorkflowStoreGetState.mockReturnValue({ workflowRunningData: undefined, showDebugAndPreviewPanel: false, @@ -49,9 +51,10 @@ describe('useSnippetStartRun', () => { }) it('should open the debug panel and input form when snippet has input fields', () => { + useSnippetDetailStore.setState({ fields: inputFields }) + const { result } = renderHook(() => useSnippetStartRun({ handleRun: mockHandleRun, - inputFields, })) act(() => { @@ -68,7 +71,6 @@ describe('useSnippetStartRun', () => { it('should run immediately when snippet has no input fields', () => { const { result } = renderHook(() => useSnippetStartRun({ handleRun: mockHandleRun, - inputFields: [], })) act(() => { @@ -80,7 +82,25 @@ describe('useSnippetStartRun', () => { expect(mockHandleRun).toHaveBeenCalledWith({ inputs: {} }) }) + it('should use current snippet input fields from the store before starting a run', () => { + useSnippetDetailStore.setState({ fields: inputFields }) + + const { result } = renderHook(() => useSnippetStartRun({ + handleRun: mockHandleRun, + })) + + act(() => { + result.current.handleWorkflowStartRunInWorkflow() + }) + + expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true) + expect(mockSetShowInputsPanel).toHaveBeenCalledWith(true) + expect(mockHandleRun).not.toHaveBeenCalled() + }) + it('should close the panel when debug panel is already open', () => { + useSnippetDetailStore.setState({ fields: inputFields }) + mockWorkflowStoreGetState.mockReturnValue({ workflowRunningData: undefined, showDebugAndPreviewPanel: true, @@ -92,7 +112,6 @@ describe('useSnippetStartRun', () => { const { result } = renderHook(() => useSnippetStartRun({ handleRun: mockHandleRun, - inputFields, })) act(() => { @@ -103,6 +122,8 @@ describe('useSnippetStartRun', () => { }) it('should do nothing when workflow is already running', () => { + useSnippetDetailStore.setState({ fields: inputFields }) + mockWorkflowStoreGetState.mockReturnValue({ workflowRunningData: { result: { @@ -118,7 +139,6 @@ describe('useSnippetStartRun', () => { const { result } = renderHook(() => useSnippetStartRun({ handleRun: mockHandleRun, - inputFields, })) act(() => { diff --git a/web/app/components/snippets/hooks/use-create-snippet-from-selection.tsx b/web/app/components/snippets/hooks/use-create-snippet-from-selection.tsx index 4832c2e2aea..955770f6580 100644 --- a/web/app/components/snippets/hooks/use-create-snippet-from-selection.tsx +++ b/web/app/components/snippets/hooks/use-create-snippet-from-selection.tsx @@ -28,6 +28,14 @@ const isSelectorKey = (key?: string) => { return key === 'selector' || !!key?.endsWith('_selector') } +const isValueSelectorListKey = (key?: string) => { + return key === 'variables' +} + +const isValueSelectorList = (value: unknown[]) => { + return value.length > 0 && value.every(isValueSelector) +} + const getCenteredViewport = (nodes: Node[]) => { if (!nodes.length) return DEFAULT_SNIPPET_VIEWPORT @@ -77,6 +85,11 @@ const collectVariableSelectors = ( if (isSelectorKey(key) && isValueSelector(value)) selectors.push(value) + if (isValueSelectorListKey(key) && isValueSelectorList(value)) { + value.forEach(selector => selectors.push(selector)) + return + } + value.forEach(item => collectVariableSelectors(item, selectors)) return } @@ -202,6 +215,13 @@ const rewriteVariableReferences = ( return nextSelector } + if (isValueSelectorListKey(key) && isValueSelectorList(value)) { + return value.map((selector) => { + const nextSelector = selectorMap.get(selector.join('.')) + return nextSelector || selector + }) + } + return value.map(item => rewriteVariableReferences(item, selectorMap)) } diff --git a/web/app/components/snippets/hooks/use-snippet-start-run.ts b/web/app/components/snippets/hooks/use-snippet-start-run.ts index 27051510b99..9657391bf2a 100644 --- a/web/app/components/snippets/hooks/use-snippet-start-run.ts +++ b/web/app/components/snippets/hooks/use-snippet-start-run.ts @@ -1,18 +1,16 @@ -import type { SnippetInputField } from '@/models/snippet' import type { SnippetDraftRunPayload } from '@/types/snippet' import { useCallback } from 'react' import { useWorkflowInteractions } from '@/app/components/workflow/hooks' import { useWorkflowStore } from '@/app/components/workflow/store' import { WorkflowRunningStatus } from '@/app/components/workflow/types' +import { useSnippetDetailStore } from '../store' type UseSnippetStartRunOptions = { handleRun: (params: SnippetDraftRunPayload) => void - inputFields: SnippetInputField[] } export const useSnippetStartRun = ({ handleRun, - inputFields, }: UseSnippetStartRunOptions) => { const workflowStore = useWorkflowStore() const { handleCancelDebugAndPreviewPanel } = useWorkflowInteractions() @@ -40,14 +38,16 @@ export const useSnippetStartRun = ({ setShowDebugAndPreviewPanel(true) - if (inputFields.length > 0) { + const currentInputFields = useSnippetDetailStore.getState().fields + + if (currentInputFields.length > 0) { setShowInputsPanel(true) return } setShowInputsPanel(false) handleRun({ inputs: {} }) - }, [handleCancelDebugAndPreviewPanel, handleRun, inputFields.length, workflowStore]) + }, [handleCancelDebugAndPreviewPanel, handleRun, workflowStore]) const handleStartWorkflowRun = useCallback(() => { handleWorkflowStartRunInWorkflow() diff --git a/web/app/components/workflow/hooks/__tests__/use-nodes-available-var-list.spec.ts b/web/app/components/workflow/hooks/__tests__/use-nodes-available-var-list.spec.ts index e38c09fea83..27edcbd1633 100644 --- a/web/app/components/workflow/hooks/__tests__/use-nodes-available-var-list.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-nodes-available-var-list.spec.ts @@ -2,12 +2,16 @@ import type { Node, NodeOutPutVar, Var } from '../../types' import { renderHook } from '@testing-library/react' import { useSnippetDetailStore } from '@/app/components/snippets/store' import { PipelineInputVarType } from '@/models/pipeline' +import { FlowType } from '@/types/common' import { BlockEnum, VarType } from '../../types' import useNodesAvailableVarList, { useGetNodesAvailableVarList } from '../use-nodes-available-var-list' const mockGetTreeLeafNodes = vi.hoisted(() => vi.fn()) const mockGetBeforeNodesInSameBranchIncludeParent = vi.hoisted(() => vi.fn()) const mockGetNodeAvailableVars = vi.hoisted(() => vi.fn()) +const mockFlowType = vi.hoisted(() => ({ + value: undefined as FlowType | undefined, +})) vi.mock('@/app/components/workflow/hooks', () => ({ useIsChatMode: () => true, @@ -20,6 +24,14 @@ vi.mock('@/app/components/workflow/hooks', () => ({ }), })) +vi.mock('@/app/components/workflow/hooks-store/store', () => ({ + useHooksStore: (selector: (state: { configsMap?: { flowType?: FlowType } }) => unknown) => selector({ + configsMap: { + flowType: mockFlowType.value, + }, + }), +})) + const createNode = (overrides: Partial = {}): Node => ({ id: 'node-1', type: 'custom', @@ -69,6 +81,7 @@ const outputVarsWithSystemVars: NodeOutPutVar[] = [ describe('useNodesAvailableVarList', () => { beforeEach(() => { vi.clearAllMocks() + mockFlowType.value = undefined globalThis.history.pushState({}, '', '/') useSnippetDetailStore.getState().reset() mockGetBeforeNodesInSameBranchIncludeParent.mockImplementation((nodeId: string) => [createNode({ id: `before-${nodeId}` })]) @@ -178,6 +191,26 @@ describe('useNodesAvailableVarList', () => { expect(result.current['node-a']?.availableVars).toEqual(outputVarsWithSystemVars) }) + it('filters system variables when the current flow is a snippet', () => { + mockFlowType.value = FlowType.snippet + mockGetNodeAvailableVars.mockReturnValue(outputVarsWithSystemVars) + + const currentNode = createNode({ id: 'node-a' }) + + const { result } = renderHook(() => useNodesAvailableVarList([currentNode], { + filterVar: () => true, + })) + + expect(result.current['node-a']?.availableVars).toEqual([{ + nodeId: 'vars-node', + title: 'Vars', + vars: [{ + variable: 'answer', + type: VarType.string, + }], + }]) + }) + it('returns a callback version that can use leaf nodes or caller-provided nodes', () => { const firstNode = createNode({ id: 'node-a' }) const secondNode = createNode({ id: 'node-b' }) diff --git a/web/app/components/workflow/hooks/use-nodes-available-var-list.ts b/web/app/components/workflow/hooks/use-nodes-available-var-list.ts index 7c198aee29e..927096698d7 100644 --- a/web/app/components/workflow/hooks/use-nodes-available-var-list.ts +++ b/web/app/components/workflow/hooks/use-nodes-available-var-list.ts @@ -7,11 +7,14 @@ import { useWorkflow, useWorkflowVariables, } from '@/app/components/workflow/hooks' +import { useHooksStore } from '@/app/components/workflow/hooks-store/store' import { appendSnippetInputFieldVars, filterSnippetSystemVars, + isSnippetCanvas, } from '@/app/components/workflow/nodes/_base/hooks/snippet-input-field-vars' import { BlockEnum } from '@/app/components/workflow/types' +import { FlowType } from '@/types/common' type Params = { onlyLeafNodeVar?: boolean @@ -52,6 +55,7 @@ const useNodesAvailableVarList = (nodes: Node[], { const { getTreeLeafNodes, getBeforeNodesInSameBranchIncludeParent } = useWorkflow() const { getNodeAvailableVars } = useWorkflowVariables() const isChatMode = useIsChatMode() + const isSnippetFlow = useHooksStore(s => s.configsMap?.flowType) === FlowType.snippet || isSnippetCanvas() const nodeAvailabilityMap: { [key: string ]: { availableVars: NodeOutPutVar[], availableNodes: Node[] } } = {} @@ -80,7 +84,7 @@ const useNodesAvailableVarList = (nodes: Node[], { hideEnv, hideChatVar, }), - ]) + ], isSnippetFlow) const result = { node, availableVars, @@ -97,6 +101,7 @@ export const useGetNodesAvailableVarList = () => { const { getTreeLeafNodes, getBeforeNodesInSameBranchIncludeParent } = useWorkflow() const { getNodeAvailableVars } = useWorkflowVariables() const isChatMode = useIsChatMode() + const isSnippetFlow = useHooksStore(s => s.configsMap?.flowType) === FlowType.snippet || isSnippetCanvas() const getNodesAvailableVarList = useCallback((nodes: Node[], { onlyLeafNodeVar, filterVar, @@ -134,7 +139,7 @@ export const useGetNodesAvailableVarList = () => { hideEnv, hideChatVar, }), - ]) + ], isSnippetFlow) const result = { node, availableVars, @@ -143,7 +148,7 @@ export const useGetNodesAvailableVarList = () => { nodeAvailabilityMap[nodeId] = result }) return nodeAvailabilityMap - }, [getTreeLeafNodes, getBeforeNodesInSameBranchIncludeParent, getNodeAvailableVars, isChatMode, snippetInputFields, t]) + }, [getTreeLeafNodes, getBeforeNodesInSameBranchIncludeParent, getNodeAvailableVars, isChatMode, isSnippetFlow, snippetInputFields, t]) return { getNodesAvailableVarList, } diff --git a/web/app/components/workflow/nodes/_base/components/memory-config.tsx b/web/app/components/workflow/nodes/_base/components/memory-config.tsx index 1dffdd0f9ee..8d37e161667 100644 --- a/web/app/components/workflow/nodes/_base/components/memory-config.tsx +++ b/web/app/components/workflow/nodes/_base/components/memory-config.tsx @@ -52,6 +52,7 @@ type Props = { config: { data?: Memory } onChange: (memory?: Memory) => void canSetRoleName?: boolean + defaultMemory?: Memory } const MEMORY_DEFAULT: Memory = { @@ -65,15 +66,16 @@ const MemoryConfig: FC = ({ config = { data: MEMORY_DEFAULT }, onChange, canSetRoleName = false, + defaultMemory = MEMORY_DEFAULT, }) => { const { t } = useTranslation() const payload = config.data const windowSizeLabel = t(`${i18nPrefix}.windowSize`, { ns: 'workflow' }) const handleMemoryEnabledChange = useCallback((enabled: boolean) => { - onChange(enabled ? MEMORY_DEFAULT : undefined) - }, [onChange]) + onChange(enabled ? defaultMemory : undefined) + }, [defaultMemory, onChange]) const handleWindowEnabledChange = useCallback((enabled: boolean) => { - const newPayload = produce(config.data || MEMORY_DEFAULT, (draft) => { + const newPayload = produce(config.data || defaultMemory, (draft) => { if (!draft.window) draft.window = { enabled: false, size: WINDOW_SIZE_DEFAULT } @@ -81,10 +83,10 @@ const MemoryConfig: FC = ({ }) onChange(newPayload) - }, [config, onChange]) + }, [config, defaultMemory, onChange]) const handleWindowSizeChange = useCallback((size: number | string) => { - const newPayload = produce(payload || MEMORY_DEFAULT, (draft) => { + const newPayload = produce(payload || defaultMemory, (draft) => { if (!draft.window) draft.window = { enabled: true, size: WINDOW_SIZE_DEFAULT } let limitedSize: null | string | number = size @@ -106,7 +108,7 @@ const MemoryConfig: FC = ({ draft.window.size = limitedSize as number }) onChange(newPayload) - }, [payload, onChange]) + }, [payload, defaultMemory, onChange]) const handleBlur = useCallback(() => { const payload = config.data @@ -119,7 +121,7 @@ const MemoryConfig: FC = ({ const handleRolePrefixChange = useCallback((role: MemoryRole) => { return (value: string) => { - const newPayload = produce(config.data || MEMORY_DEFAULT, (draft) => { + const newPayload = produce(config.data || defaultMemory, (draft) => { if (!draft.role_prefix) { draft.role_prefix = { user: '', @@ -130,7 +132,7 @@ const MemoryConfig: FC = ({ }) onChange(newPayload) } - }, [config, onChange]) + }, [config, defaultMemory, onChange]) return (
vi.fn()) const mockGetBeforeNodesInSameBranchIncludeParent = vi.hoisted(() => vi.fn()) const mockGetNodeById = vi.hoisted(() => vi.fn()) const mockGetNodeAvailableVars = vi.hoisted(() => vi.fn()) +const mockFlowType = vi.hoisted(() => ({ + value: undefined as FlowType | undefined, +})) vi.mock('@/app/components/snippets/store', () => ({ useSnippetDetailStore: (selector: (state: { fields: unknown[] }) => unknown) => selector({ fields: [] }), @@ -28,6 +32,14 @@ vi.mock('@/app/components/workflow/store', () => ({ useStore: (selector: (state: { ragPipelineVariables: unknown[] }) => unknown) => selector({ ragPipelineVariables: [] }), })) +vi.mock('@/app/components/workflow/hooks-store/store', () => ({ + useHooksStore: (selector: (state: { configsMap?: { flowType?: FlowType } }) => unknown) => selector({ + configsMap: { + flowType: mockFlowType.value, + }, + }), +})) + vi.mock('../use-node-info', () => ({ default: () => ({ parentNode: null, @@ -74,6 +86,7 @@ const outputVarsWithSystemVars: NodeOutPutVar[] = [ describe('useAvailableVarList', () => { beforeEach(() => { vi.clearAllMocks() + mockFlowType.value = undefined globalThis.history.pushState({}, '', '/') mockGetBeforeNodesInSameBranchIncludeParent.mockReturnValue([createNode({ id: 'before-node' })]) mockGetTreeLeafNodes.mockReturnValue([createNode({ id: 'leaf-node' })]) @@ -105,4 +118,21 @@ describe('useAvailableVarList', () => { expect(result.current.availableVars).toEqual(outputVarsWithSystemVars) }) + + it('filters system variables when the current flow is a snippet', () => { + mockFlowType.value = FlowType.snippet + + const { result } = renderHook(() => useAvailableVarList('node-1', { + filterVar: () => true, + })) + + expect(result.current.availableVars).toEqual([{ + nodeId: 'vars-node', + title: 'Vars', + vars: [{ + variable: 'answer', + type: VarType.string, + }], + }]) + }) }) diff --git a/web/app/components/workflow/nodes/_base/hooks/snippet-input-field-vars.ts b/web/app/components/workflow/nodes/_base/hooks/snippet-input-field-vars.ts index 96a64732572..650fa44e851 100644 --- a/web/app/components/workflow/nodes/_base/hooks/snippet-input-field-vars.ts +++ b/web/app/components/workflow/nodes/_base/hooks/snippet-input-field-vars.ts @@ -14,8 +14,8 @@ export const isSnippetCanvas = () => { return /^\/snippets\/[^/]+\/orchestrate/.test(globalThis.location.pathname) } -export const filterSnippetSystemVars = (availableVars: NodeOutPutVar[]) => { - if (!isSnippetCanvas()) +export const filterSnippetSystemVars = (availableVars: NodeOutPutVar[], isSnippetFlow = isSnippetCanvas()) => { + if (!isSnippetFlow) return availableVars return availableVars 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 4e97a3e86aa..40b2b664ef8 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 @@ -6,10 +6,12 @@ import { useWorkflow, useWorkflowVariables, } from '@/app/components/workflow/hooks' +import { useHooksStore } from '@/app/components/workflow/hooks-store/store' import { useStore as useWorkflowStore } from '@/app/components/workflow/store' import { BlockEnum } from '@/app/components/workflow/types' +import { FlowType } from '@/types/common' import { inputVarTypeToVarType } from '../../data-source/utils' -import { appendSnippetInputFieldVars, filterSnippetSystemVars } from './snippet-input-field-vars' +import { appendSnippetInputFieldVars, filterSnippetSystemVars, isSnippetCanvas } from './snippet-input-field-vars' import useNodeInfo from './use-node-info' type Params = { @@ -36,6 +38,7 @@ const useAvailableVarList = (nodeId: string, { const { getTreeLeafNodes, getNodeById, getBeforeNodesInSameBranchIncludeParent } = useWorkflow() const { getNodeAvailableVars } = useWorkflowVariables() const isChatMode = useIsChatMode() + const isSnippetFlow = useHooksStore(s => s.configsMap?.flowType) === FlowType.snippet || isSnippetCanvas() const availableNodes = passedInAvailableNodes || (onlyLeafNodeVar ? getTreeLeafNodes(nodeId) : getBeforeNodesInSameBranchIncludeParent(nodeId)) const snippetInputFieldAvailability = appendSnippetInputFieldVars({ availableNodes, @@ -84,7 +87,7 @@ const useAvailableVarList = (nodeId: string, { hideChatVar, }), ...dataSourceRagVars, - ]) + ], isSnippetFlow) return { availableVars, diff --git a/web/app/components/workflow/nodes/llm/__tests__/use-single-run-form-params.spec.ts b/web/app/components/workflow/nodes/llm/__tests__/use-single-run-form-params.spec.ts new file mode 100644 index 00000000000..aa8e705bcc9 --- /dev/null +++ b/web/app/components/workflow/nodes/llm/__tests__/use-single-run-form-params.spec.ts @@ -0,0 +1,146 @@ +import type { LLMNodeType } from '../types' +import type { InputVar, Variable } from '@/app/components/workflow/types' +import { renderHook } from '@testing-library/react' +import { BlockEnum, EditionType, InputVarType, PromptRole, VarType } from '@/app/components/workflow/types' +import { AppModeEnum } from '@/types/app' +import { FlowType } from '@/types/common' +import useConfigVision from '../../../hooks/use-config-vision' +import useAvailableVarList from '../../_base/hooks/use-available-var-list' +import useNodeCrud from '../../_base/hooks/use-node-crud' +import useSingleRunFormParams from '../use-single-run-form-params' + +vi.mock('../../../hooks/use-config-vision', () => ({ + __esModule: true, + default: vi.fn(), +})) + +vi.mock('../../_base/hooks/use-available-var-list', () => ({ + __esModule: true, + default: vi.fn(), +})) + +vi.mock('../../_base/hooks/use-node-crud', () => ({ + __esModule: true, + default: vi.fn(), +})) + +vi.mock('@/app/components/workflow/hooks', () => ({ + useIsChatMode: () => true, +})) + +const mockFlowType = vi.hoisted(() => ({ + value: undefined as FlowType | undefined, +})) + +vi.mock('@/app/components/workflow/hooks-store/store', () => ({ + useHooksStore: (selector: (state: { configsMap?: { flowType?: FlowType } }) => unknown) => selector({ + configsMap: { + flowType: mockFlowType.value, + }, + }), +})) + +const mockUseConfigVision = vi.mocked(useConfigVision) +const mockUseAvailableVarList = vi.mocked(useAvailableVarList) +const mockUseNodeCrud = vi.mocked(useNodeCrud) + +const createData = (overrides: Partial = {}): LLMNodeType => ({ + title: 'LLM', + desc: '', + type: BlockEnum.LLM, + model: { + provider: 'openai', + name: 'gpt-4o', + mode: AppModeEnum.CHAT, + completion_params: {}, + } as LLMNodeType['model'], + prompt_template: [{ + edition_type: EditionType.basic, + role: PromptRole.user, + text: '{{#start.query#}}', + }], + memory: { + window: { + enabled: false, + size: 50, + }, + query_prompt_template: '{{#sys.query#}}\n{{#sys.files#}}', + }, + context: { + enabled: false, + variable_selector: [], + }, + vision: { + enabled: false, + }, + ...overrides, +}) + +const createInputVar = (variable: string): InputVar => ({ + label: variable, + variable, + type: InputVarType.textInput, + required: false, +}) + +describe('llm/use-single-run-form-params', () => { + beforeEach(() => { + vi.clearAllMocks() + mockFlowType.value = undefined + mockUseNodeCrud.mockImplementation((_id, payload) => ({ + inputs: payload, + setInputs: vi.fn(), + }) as unknown as ReturnType) + mockUseConfigVision.mockReturnValue({ + isVisionModel: false, + } as ReturnType) + mockUseAvailableVarList.mockReturnValue({ + availableVars: [], + } as unknown as ReturnType) + }) + + it('filters system variables from single-run inputs in snippet flows', () => { + mockFlowType.value = FlowType.snippet + const getInputVars = vi.fn(() => [ + createInputVar('#start.query#'), + createInputVar('#sys.query#'), + createInputVar('#sys.files#'), + ]) + const toVarInputs = vi.fn((_variables: Variable[]) => [ + createInputVar('#sys.workflow_id#'), + createInputVar('#start.extra#'), + ]) + + const { result } = renderHook(() => useSingleRunFormParams({ + id: 'llm-node', + payload: createData({ + prompt_template: [{ + edition_type: EditionType.jinja2, + role: PromptRole.user, + text: '{{ query }}', + }], + prompt_config: { + jinja2_variables: [{ + variable: 'extra', + value_selector: ['start', 'extra'], + value_type: VarType.string, + }], + }, + }), + runInputData: {}, + runInputDataRef: { current: {} }, + getInputVars, + setRunInputData: vi.fn(), + toVarInputs, + })) + + expect(result.current.forms[0]!.inputs).toEqual([ + expect.objectContaining({ + variable: '#start.query#', + }), + expect.objectContaining({ + variable: '#start.extra#', + }), + ]) + }) +}) diff --git a/web/app/components/workflow/nodes/llm/components/__tests__/panel-memory-section.spec.tsx b/web/app/components/workflow/nodes/llm/components/__tests__/panel-memory-section.spec.tsx index eb7e28e84e7..58cb8461418 100644 --- a/web/app/components/workflow/nodes/llm/components/__tests__/panel-memory-section.spec.tsx +++ b/web/app/components/workflow/nodes/llm/components/__tests__/panel-memory-section.spec.tsx @@ -1,5 +1,5 @@ import type { LLMNodeType } from '../../types' -import type { Node, NodeOutPutVar } from '@/app/components/workflow/types' +import type { Memory, Node, NodeOutPutVar } from '@/app/components/workflow/types' import { render, screen } from '@testing-library/react' import { AppModeEnum } from '@/types/app' import { FlowType } from '@/types/common' @@ -25,7 +25,7 @@ vi.mock('@/app/components/workflow/nodes/_base/components/prompt/editor', () => vi.mock('@/app/components/workflow/nodes/_base/components/memory-config', () => ({ __esModule: true, - default: (props: { canSetRoleName: boolean, config: { data?: LLMNodeType['memory'] } }) => { + default: (props: { canSetRoleName: boolean, config: { data?: LLMNodeType['memory'] }, defaultMemory?: Memory }) => { mockMemoryConfig(props) return
{props.canSetRoleName ? 'can-set-role' : 'cannot-set-role'}
}, @@ -135,6 +135,25 @@ describe('llm/panel-memory-section', () => { expect(screen.getByTestId('editor')).toHaveTextContent('custom prompt') }) + it('does not default snippet memory prompts to system variables', () => { + render( + , + ) + + expect(screen.getByTestId('editor')).toHaveTextContent('') + expect(mockEditor).toHaveBeenCalledWith(expect.objectContaining({ + value: '', + })) + expect(mockMemoryConfig).toHaveBeenCalledWith(expect.objectContaining({ + defaultMemory: expect.objectContaining({ + query_prompt_template: '', + }), + })) + }) + it('renders nothing outside chat mode', () => { render( = ({ readOnly, @@ -42,7 +56,9 @@ const PanelMemorySection: FC = ({ handleMemoryChange, }) => { const { t } = useTranslation() - const shouldCheckSysQuery = flowType !== FlowType.snippet + const isSnippetFlow = flowType === FlowType.snippet + const shouldCheckSysQuery = !isSnippetFlow + const defaultMemory = isSnippetFlow ? SNIPPET_DEFAULT_MEMORY : DEFAULT_MEMORY if (!isChatMode) return null @@ -75,7 +91,7 @@ const PanelMemorySection: FC = ({
)} - value={inputs.memory.query_prompt_template || '{{#sys.query#}}'} + value={inputs.memory.query_prompt_template || defaultMemory.query_prompt_template} onChange={handleSyeQueryChange} readOnly={readOnly} isShowContext={false} @@ -101,6 +117,7 @@ const PanelMemorySection: FC = ({ config={{ data: inputs.memory }} onChange={handleMemoryChange} canSetRoleName={isCompletionModel} + defaultMemory={defaultMemory} /> ) diff --git a/web/app/components/workflow/nodes/llm/use-single-run-form-params.ts b/web/app/components/workflow/nodes/llm/use-single-run-form-params.ts index e0c4c97fad0..1cdbec1726c 100644 --- a/web/app/components/workflow/nodes/llm/use-single-run-form-params.ts +++ b/web/app/components/workflow/nodes/llm/use-single-run-form-params.ts @@ -5,8 +5,10 @@ import type { InputVar, PromptItem, Var, Variable } from '@/app/components/workf import { noop } from 'es-toolkit/function' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' +import { useHooksStore } from '@/app/components/workflow/hooks-store/store' import { InputVarType, VarType } from '@/app/components/workflow/types' import { AppModeEnum } from '@/types/app' +import { FlowType } from '@/types/common' import { useIsChatMode } from '../../hooks' import useConfigVision from '../../hooks/use-config-vision' import { EditionType } from '../../types' @@ -15,6 +17,10 @@ import useNodeCrud from '../_base/hooks/use-node-crud' import { findVariableWhenOnLLMVision } from '../utils' const i18nPrefix = 'nodes.llm' +const isSystemInputVar = (item: InputVar) => { + return typeof item.variable === 'string' && item.variable.startsWith('#sys.') +} + type Params = { id: string payload: LLMNodeType @@ -37,6 +43,8 @@ const useSingleRunFormParams = ({ const { inputs } = useNodeCrud(id, payload) const getVarInputs = getInputVars const isChatMode = useIsChatMode() + const flowType = useHooksStore(s => s.configsMap?.flowType) + const isSnippetFlow = flowType === FlowType.snippet const contexts = runInputData['#context#'] const setContexts = useCallback((newContexts: string[]) => { @@ -85,19 +93,27 @@ const useSingleRunFormParams = ({ const allVarStrArr = (() => { const arr = isChatModel ? (inputs.prompt_template as PromptItem[]).filter(item => item.edition_type !== EditionType.jinja2).map(item => item.text) : [(inputs.prompt_template as PromptItem).text] - if (isChatMode && isChatModel && !!inputs.memory) { + if (!isSnippetFlow && isChatMode && isChatModel && !!inputs.memory) arr.push('{{#sys.query#}}') + + if (isChatMode && isChatModel && !!inputs.memory) arr.push(inputs.memory.query_prompt_template) - } return arr })() const varInputs = (() => { const vars = getVarInputs(allVarStrArr) || [] - if (isShowVars) - return [...vars, ...(toVarInputs ? (toVarInputs(inputs.prompt_config?.jinja2_variables || [])) : [])] + const filteredVars = isSnippetFlow + ? vars.filter(item => !isSystemInputVar(item)) + : vars + if (isShowVars) { + const jinjaVars = toVarInputs ? (toVarInputs(inputs.prompt_config?.jinja2_variables || [])) : [] + return isSnippetFlow + ? [...filteredVars, ...jinjaVars.filter(item => !isSystemInputVar(item))] + : [...filteredVars, ...jinjaVars] + } - return vars + return filteredVars })() const inputVarValues = (() => { diff --git a/web/app/components/workflow/nodes/question-classifier/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/question-classifier/__tests__/use-config.spec.ts index 89af550de13..c60b54be1c8 100644 --- a/web/app/components/workflow/nodes/question-classifier/__tests__/use-config.spec.ts +++ b/web/app/components/workflow/nodes/question-classifier/__tests__/use-config.spec.ts @@ -12,6 +12,7 @@ import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-cr import { useStore } from '@/app/components/workflow/store' import { BlockEnum } from '@/app/components/workflow/types' import { AppModeEnum } from '@/types/app' +import { FlowType } from '@/types/common' import useConfig from '../use-config' vi.mock('@/app/components/workflow/hooks', () => ({ @@ -37,6 +38,18 @@ vi.mock('@/app/components/workflow/store', () => ({ useStore: vi.fn(), })) +const mockFlowType = vi.hoisted(() => ({ + value: undefined as FlowType | undefined, +})) + +vi.mock('@/app/components/workflow/hooks-store/store', () => ({ + useHooksStore: (selector: (state: { configsMap?: { flowType?: FlowType } }) => unknown) => selector({ + configsMap: { + flowType: mockFlowType.value, + }, + }), +})) + vi.mock('@/app/components/workflow/hooks/use-config-vision', () => ({ __esModule: true, default: vi.fn(), @@ -84,6 +97,7 @@ describe('question-classifier/use-config', () => { beforeEach(() => { vi.clearAllMocks() latestVisionOptions = null + mockFlowType.value = undefined mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false, getNodesReadOnly: () => false }) mockUseIsChatMode.mockReturnValue(true) mockUseWorkflow.mockReturnValue({ @@ -144,4 +158,42 @@ describe('question-classifier/use-config', () => { })) }) }) + + it('does not default the query selector to sys.query in snippet flows', async () => { + mockFlowType.value = FlowType.snippet + mockUseWorkflow.mockReturnValue({ + getBeforeNodesInSameBranch: vi.fn(() => [{ + id: 'start-node', + data: { + type: BlockEnum.Start, + title: 'Start', + }, + }]), + } as unknown as ReturnType) + mockUseStore.mockImplementation((selector) => { + return selector({ + nodesDefaultConfigs: { + [BlockEnum.QuestionClassifier]: { + model: createPayload().model, + classes: createPayload().classes, + vision: createPayload().vision, + }, + }, + } as never) + }) + mockUseNodeCrud.mockReturnValue({ + inputs: createPayload({ + query_variable_selector: [], + }), + setInputs, + }) + + renderHook(() => useConfig('question-classifier-node', createPayload({ + query_variable_selector: [], + }))) + + await waitFor(() => { + expect(setInputs).not.toHaveBeenCalled() + }) + }) }) diff --git a/web/app/components/workflow/nodes/question-classifier/use-config.ts b/web/app/components/workflow/nodes/question-classifier/use-config.ts index 9200b1bb3f4..8a77ae2638f 100644 --- a/web/app/components/workflow/nodes/question-classifier/use-config.ts +++ b/web/app/components/workflow/nodes/question-classifier/use-config.ts @@ -6,8 +6,10 @@ import { useUpdateNodeInternals } from 'reactflow' import { checkHasQueryBlock } from '@/app/components/base/prompt-editor/constants' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' +import { useHooksStore } from '@/app/components/workflow/hooks-store/store' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import { AppModeEnum } from '@/types/app' +import { FlowType } from '@/types/common' import { useIsChatMode, useNodesReadOnly, @@ -22,6 +24,8 @@ const useConfig = (id: string, payload: QuestionClassifierNodeType) => { const updateNodeInternals = useUpdateNodeInternals() const { nodesReadOnly: readOnly } = useNodesReadOnly() const isChatMode = useIsChatMode() + const flowType = useHooksStore(s => s.configsMap?.flowType) + const isSnippetFlow = flowType === FlowType.snippet const defaultConfig = useStore(s => s.nodesDefaultConfigs)?.[payload.type] const { getBeforeNodesInSameBranch } = useWorkflow() const startNode = getBeforeNodesInSameBranch(id).find(node => node.data.type === BlockEnum.Start) @@ -130,7 +134,7 @@ const useConfig = (id: string, payload: QuestionClassifierNodeType) => { if (!draft.vision) draft.vision = defaultConfig.vision - if (draft.query_variable_selector.length === 0 && isChatMode && startNodeId) { + if (!isSnippetFlow && draft.query_variable_selector.length === 0 && isChatMode && startNodeId) { draft.query_variable_selector = [startNodeId, 'sys.query'] shouldUpdate = true } @@ -154,7 +158,7 @@ const useConfig = (id: string, payload: QuestionClassifierNodeType) => { startTransition(() => { setInputs(nextInputs) }) - }, [defaultConfig, isChatMode, setInputs, startNodeId]) + }, [defaultConfig, isChatMode, isSnippetFlow, setInputs, startNodeId]) const handleClassesChange = useCallback((newClasses: Topic[]) => { const newInputs = produce(inputs, (draft) => { diff --git a/web/app/components/workflow/nodes/variable-assigner/__tests__/hooks.spec.ts b/web/app/components/workflow/nodes/variable-assigner/__tests__/hooks.spec.ts index 0cbb98c96a8..a93ebd4ab0c 100644 --- a/web/app/components/workflow/nodes/variable-assigner/__tests__/hooks.spec.ts +++ b/web/app/components/workflow/nodes/variable-assigner/__tests__/hooks.spec.ts @@ -1,4 +1,5 @@ import { act, renderHook } from '@testing-library/react' +import { FlowType } from '@/types/common' import { VarType } from '../../../types' import { useGetAvailableVars, useVariableAssigner } from '../hooks' @@ -9,6 +10,9 @@ const mockUseWorkflow = vi.hoisted(() => vi.fn()) const mockUseWorkflowVariables = vi.hoisted(() => vi.fn()) const mockUseIsChatMode = vi.hoisted(() => vi.fn()) const mockUseWorkflowStore = vi.hoisted(() => vi.fn()) +const mockFlowType = vi.hoisted(() => ({ + value: undefined as FlowType | undefined, +})) vi.mock('reactflow', () => ({ useStoreApi: () => mockUseStoreApi(), @@ -26,6 +30,14 @@ vi.mock('@/app/components/workflow/store', () => ({ useWorkflowStore: () => mockUseWorkflowStore(), })) +vi.mock('@/app/components/workflow/hooks-store/store', () => ({ + useHooksStore: (selector: (state: { configsMap?: { flowType?: FlowType } }) => unknown) => selector({ + configsMap: { + flowType: mockFlowType.value, + }, + }), +})) + describe('variable-assigner/hooks', () => { const mockHandleNodeDataUpdate = vi.fn() const mockSetNodes = vi.fn() @@ -35,6 +47,7 @@ describe('variable-assigner/hooks', () => { beforeEach(() => { vi.clearAllMocks() + mockFlowType.value = undefined getNodes.mockReturnValue([{ id: 'assigner-1', data: { @@ -241,4 +254,38 @@ describe('variable-assigner/hooks', () => { }]) expect(result.current('missing-node', 'target', () => true)).toEqual([]) }) + + it('should filter system variables when the current flow is a snippet', () => { + mockFlowType.value = FlowType.snippet + mockUseNodes.mockReturnValue([ + { + id: 'current-node', + }, + { + id: 'before-1', + }, + ]) + const getBeforeNodesInSameBranchIncludeParent = vi.fn(() => [{ id: 'before-1' }]) + const getNodeAvailableVars = vi.fn(() => [{ + isStartNode: false, + vars: [ + { variable: 'sys.user_id' }, + { variable: 'answer' }, + ], + }]) + + mockUseWorkflow.mockReturnValue({ + getBeforeNodesInSameBranchIncludeParent, + }) + mockUseWorkflowVariables.mockReturnValue({ + getNodeAvailableVars, + }) + + const { result } = renderHook(() => useGetAvailableVars()) + + expect(result.current('current-node', 'target', () => true, false)).toEqual([{ + isStartNode: false, + vars: [{ variable: 'answer' }], + }]) + }) }) diff --git a/web/app/components/workflow/nodes/variable-assigner/hooks.ts b/web/app/components/workflow/nodes/variable-assigner/hooks.ts index 5c2fe369225..6546b759049 100644 --- a/web/app/components/workflow/nodes/variable-assigner/hooks.ts +++ b/web/app/components/workflow/nodes/variable-assigner/hooks.ts @@ -15,13 +15,16 @@ import { useNodes, useStoreApi, } from 'reactflow' +import { FlowType } from '@/types/common' import { useIsChatMode, useNodeDataUpdate, useWorkflow, useWorkflowVariables, } from '../../hooks' +import { useHooksStore } from '../../hooks-store/store' import { useWorkflowStore } from '../../store' +import { filterSnippetSystemVars, isSnippetCanvas } from '../_base/hooks/snippet-input-field-vars' export const useVariableAssigner = () => { const store = useStoreApi() @@ -127,6 +130,7 @@ export const useGetAvailableVars = () => { const { getBeforeNodesInSameBranchIncludeParent } = useWorkflow() const { getNodeAvailableVars } = useWorkflowVariables() const isChatMode = useIsChatMode() + const isSnippetFlow = useHooksStore(s => s.configsMap?.flowType) === FlowType.snippet || isSnippetCanvas() const getAvailableVars = useCallback((nodeId: string, handleId: string, filterVar: (v: Var) => boolean, hideEnv = false) => { const availableNodes: Node[] = [] const currentNode = nodes.find(node => node.id === nodeId)! @@ -138,7 +142,7 @@ export const useGetAvailableVars = () => { const parentNode = nodes.find(node => node.id === currentNode.parentId) if (hideEnv) { - return getNodeAvailableVars({ + const availableVars = getNodeAvailableVars({ parentNode, beforeNodes: uniqBy(availableNodes, 'id').filter(node => node.id !== nodeId), isChatMode, @@ -151,15 +155,17 @@ export const useGetAvailableVars = () => { vars: node.isStartNode ? node.vars.filter(v => !v.variable.startsWith('sys.')) : node.vars, })) .filter(item => item.vars.length > 0) + + return filterSnippetSystemVars(availableVars, isSnippetFlow) } - return getNodeAvailableVars({ + return filterSnippetSystemVars(getNodeAvailableVars({ parentNode, beforeNodes: uniqBy(availableNodes, 'id').filter(node => node.id !== nodeId), isChatMode, filterVar, - }) - }, [nodes, getBeforeNodesInSameBranchIncludeParent, getNodeAvailableVars, isChatMode]) + }), isSnippetFlow) + }, [nodes, getBeforeNodesInSameBranchIncludeParent, getNodeAvailableVars, isChatMode, isSnippetFlow]) return getAvailableVars }