From a309d78f95265424abddd75ece473d00302aa4e1 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Tue, 14 Apr 2026 15:08:05 +0800 Subject: [PATCH] fix workflow variable rename propagation --- .../variable/__tests__/utils.spec.ts | 153 ++++++++++++++++++ .../nodes/_base/components/variable/utils.ts | 120 +++++++++++--- .../__tests__/index.spec.tsx | 4 + .../panel/chat-variable-panel/index.tsx | 4 +- .../panel/env-panel/__tests__/index.spec.tsx | 1 + .../workflow/panel/env-panel/index.tsx | 7 +- 6 files changed, 266 insertions(+), 23 deletions(-) create mode 100644 web/app/components/workflow/nodes/_base/components/variable/__tests__/utils.spec.ts diff --git a/web/app/components/workflow/nodes/_base/components/variable/__tests__/utils.spec.ts b/web/app/components/workflow/nodes/_base/components/variable/__tests__/utils.spec.ts new file mode 100644 index 0000000000..ef6b188387 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/variable/__tests__/utils.spec.ts @@ -0,0 +1,153 @@ +import type { Node, PromptItem } from '@/app/components/workflow/types' +import { describe, expect, it } from 'vitest' +import { BlockEnum, EditionType, PromptRole } from '@/app/components/workflow/types' +import { AppModeEnum } from '@/types/app' +import { getNodeUsedVars, updateNodeVars } from '../utils' + +const createNode = (data: Node['data']): Node => ({ + id: 'node-1', + type: 'custom', + position: { x: 0, y: 0 }, + data, +}) + +const createPromptItem = (overrides: Partial = {}): PromptItem => ({ + role: PromptRole.user, + text: '', + ...overrides, +}) + +describe('variable utils', () => { + describe('getNodeUsedVars', () => { + it('should read variables from llm jinja prompt text', () => { + const node = createNode({ + type: BlockEnum.LLM, + title: 'LLM', + desc: '', + model: { + provider: 'provider', + name: 'model', + mode: AppModeEnum.CHAT, + completion_params: {}, + }, + prompt_template: [ + createPromptItem({ + edition_type: EditionType.jinja2, + jinja2_text: 'Hello {{#env.API_KEY#}}', + }), + ], + }) + + expect(getNodeUsedVars(node)).toContainEqual(['env', 'API_KEY']) + }) + + it('should read variables from human input email body', () => { + const node = createNode({ + type: BlockEnum.HumanInput, + title: 'Human Input', + desc: '', + form_content: '', + inputs: [], + user_actions: [], + timeout: 1, + timeout_unit: 'day', + delivery_methods: [ + { + id: 'email', + type: 'email', + enabled: true, + config: { + recipients: { whole_workspace: true, items: [] }, + subject: 'Subject {{#conversation.memory#}}', + body: 'Body {{#env.API_KEY#}}', + debug_mode: false, + }, + }, + ], + }) + + expect(getNodeUsedVars(node)).toEqual( + expect.arrayContaining([ + ['env', 'API_KEY'], + ]), + ) + }) + }) + + describe('updateNodeVars', () => { + it('should replace answer prompt references', () => { + const node = createNode({ + type: BlockEnum.Answer, + title: 'Answer', + desc: '', + answer: 'Answer {{#env.API_KEY#}}', + variables: [], + }) + + const updatedNode = updateNodeVars(node, ['env', 'API_KEY'], ['env', 'RENAMED_KEY']) + + expect(updatedNode.data.answer).toBe('Answer {{#env.RENAMED_KEY#}}') + }) + + it('should replace llm jinja prompt references', () => { + const node = createNode({ + type: BlockEnum.LLM, + title: 'LLM', + desc: '', + model: { + provider: 'provider', + name: 'model', + mode: AppModeEnum.CHAT, + completion_params: {}, + }, + prompt_template: [ + createPromptItem({ + text: '{{#env.API_KEY#}}', + edition_type: EditionType.jinja2, + jinja2_text: 'Hello {{#env.API_KEY#}}', + }), + ], + }) + + const updatedNode = updateNodeVars(node, ['env', 'API_KEY'], ['env', 'RENAMED_KEY']) + + expect((updatedNode.data.prompt_template as PromptItem[])[0]).toMatchObject({ + text: '{{#env.RENAMED_KEY#}}', + jinja2_text: 'Hello {{#env.RENAMED_KEY#}}', + }) + }) + + it('should replace human input email template references', () => { + const node = createNode({ + type: BlockEnum.HumanInput, + title: 'Human Input', + desc: '', + form_content: '', + inputs: [], + user_actions: [], + timeout: 1, + timeout_unit: 'day', + delivery_methods: [ + { + id: 'email', + type: 'email', + enabled: true, + config: { + recipients: { whole_workspace: true, items: [] }, + subject: 'Subject {{#conversation.memory#}}', + body: 'Body {{#env.API_KEY#}}', + debug_mode: false, + }, + }, + ], + }) + + const updatedNode = updateNodeVars(node, ['env', 'API_KEY'], ['env', 'RENAMED_KEY']) + + expect(updatedNode.data.delivery_methods[0]?.config).toMatchObject({ + subject: 'Subject {{#conversation.memory#}}', + body: 'Body {{#env.RENAMED_KEY#}}', + }) + }) + }) +}) 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 d476a21568..e0350c281d 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/utils.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/utils.ts @@ -53,6 +53,7 @@ import { } from '@/app/components/workflow/constants' import DataSourceNodeDefault from '@/app/components/workflow/nodes/data-source/default' import HumanInputNodeDefault from '@/app/components/workflow/nodes/human-input/default' +import { DeliveryMethodType } from '@/app/components/workflow/nodes/human-input/types' import ToolNodeDefault from '@/app/components/workflow/nodes/tool/default' import PluginTriggerNodeDefault from '@/app/components/workflow/nodes/trigger-plugin/default' import { @@ -1310,6 +1311,25 @@ const replaceOldVarInText = ( ) } +const getPromptItemTexts = (prompt: PromptItem): string[] => { + const texts = [prompt.text] + if (prompt.jinja2_text) + texts.push(prompt.jinja2_text) + return texts.filter((text): text is string => !!text) +} + +const replaceOldVarInPromptItem = ( + prompt: PromptItem, + oldVar: ValueSelector, + newVar: ValueSelector, +): PromptItem => ({ + ...prompt, + text: replaceOldVarInText(prompt.text, oldVar, newVar), + ...(prompt.jinja2_text !== undefined + ? { jinja2_text: replaceOldVarInText(prompt.jinja2_text, oldVar, newVar) } + : {}), +}) + export const getNodeUsedVars = (node: Node): ValueSelector[] => { const { data } = node const { type } = data @@ -1331,12 +1351,12 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => { let prompts: string[] = [] if (isChatModel) { prompts - = (payload.prompt_template as PromptItem[])?.map(p => p.text) || [] + = (payload.prompt_template as PromptItem[])?.flatMap(getPromptItemTexts) || [] if (payload.memory?.query_prompt_template) prompts.push(payload.memory.query_prompt_template) } else { - prompts = [(payload.prompt_template as PromptItem).text] + prompts = getPromptItemTexts(payload.prompt_template as PromptItem) } const inputVars: ValueSelector[] = matchNotSystemVars(prompts) @@ -1505,7 +1525,12 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => { case BlockEnum.HumanInput: { const payload = data as HumanInputNodeType const formContent = payload.form_content - res = matchNotSystemVars([formContent]) + const mailTemplates = payload.delivery_methods.flatMap((method) => { + if (method.type !== DeliveryMethodType.Email || !method.config) + return [] + return [method.config.body] + }) + res = matchNotSystemVars([formContent, ...mailTemplates]) break } } @@ -1651,6 +1676,11 @@ export const updateNodeVars = ( } case BlockEnum.Answer: { const payload = data as AnswerNodeType + payload.answer = replaceOldVarInText( + payload.answer, + oldVarSelector, + newVarSelector, + ) if (payload.variables) { payload.variables = payload.variables.map((v) => { if (v.value_selector.join('.') === oldVarSelector.join('.')) @@ -1666,16 +1696,7 @@ export const updateNodeVars = ( if (isChatModel) { payload.prompt_template = ( payload.prompt_template as PromptItem[] - ).map((prompt) => { - return { - ...prompt, - text: replaceOldVarInText( - prompt.text, - oldVarSelector, - newVarSelector, - ), - } - }) + ).map(prompt => replaceOldVarInPromptItem(prompt, oldVarSelector, newVarSelector)) if (payload.memory?.query_prompt_template) { payload.memory.query_prompt_template = replaceOldVarInText( payload.memory.query_prompt_template, @@ -1685,14 +1706,11 @@ export const updateNodeVars = ( } } else { - payload.prompt_template = { - ...payload.prompt_template, - text: replaceOldVarInText( - (payload.prompt_template as PromptItem).text, - oldVarSelector, - newVarSelector, - ), - } + payload.prompt_template = replaceOldVarInPromptItem( + payload.prompt_template as PromptItem, + oldVarSelector, + newVarSelector, + ) } if ( payload.context?.variable_selector?.join('.') @@ -1780,6 +1798,10 @@ export const updateNodeVars = ( oldVarSelector, newVarSelector, ) + payload.classes = payload.classes.map(topic => ({ + ...topic, + name: replaceOldVarInText(topic.name, oldVarSelector, newVarSelector), + })) break } case BlockEnum.HttpRequest: { @@ -1889,6 +1911,46 @@ export const updateNodeVars = ( } break } + case BlockEnum.Agent: { + const payload = data as AgentNodeType + if (payload.agent_parameters) { + Object.keys(payload.agent_parameters).forEach((key) => { + const value = payload.agent_parameters![key] + const { type } = value + + if ( + type === ToolVarType.variable + && Array.isArray(value.value) + && value.value.join('.') === oldVarSelector.join('.') + ) { + payload.agent_parameters![key] = { + ...value, + value: newVarSelector, + } + } + + if (type === ToolVarType.mixed && typeof value.value === 'string') { + payload.agent_parameters![key] = { + ...value, + value: replaceOldVarInText( + value.value, + oldVarSelector, + newVarSelector, + ), + } + } + }) + } + + if (payload.memory?.query_prompt_template) { + payload.memory.query_prompt_template = replaceOldVarInText( + payload.memory.query_prompt_template, + oldVarSelector, + newVarSelector, + ) + } + break + } case BlockEnum.VariableAssigner: { const payload = data as VariableAssignerNodeType if (payload.variables) { @@ -1954,6 +2016,22 @@ export const updateNodeVars = ( oldVarSelector, newVarSelector, ) + payload.delivery_methods = payload.delivery_methods.map((method) => { + if (method.type !== DeliveryMethodType.Email || !method.config) + return method + + return { + ...method, + config: { + ...method.config, + body: replaceOldVarInText( + method.config.body, + oldVarSelector, + newVarSelector, + ), + }, + } + }) break } } diff --git a/web/app/components/workflow/panel/chat-variable-panel/__tests__/index.spec.tsx b/web/app/components/workflow/panel/chat-variable-panel/__tests__/index.spec.tsx index 305ecfd6b5..a12ede5aa5 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/__tests__/index.spec.tsx +++ b/web/app/components/workflow/panel/chat-variable-panel/__tests__/index.spec.tsx @@ -9,6 +9,7 @@ type MockWorkflowStoreState = { appId: string conversationVariables: ConversationVariable[] setConversationVariables: (value: ConversationVariable[]) => void + setControlPromptEditorRerenderKey: (value: number) => void } type MockFlowStore = { @@ -18,6 +19,7 @@ type MockFlowStore = { const mockSetShowChatVariablePanel = vi.fn() const mockSetConversationVariables = vi.fn() +const mockSetControlPromptEditorRerenderKey = vi.fn() const mockInvalidateConversationVarValues = vi.fn() const mockUpdateConversationVariables = vi.fn().mockResolvedValue(undefined) const mockFindUsedVarNodes = vi.fn<(selector: string[], nodes: Node[]) => Node[]>() @@ -64,6 +66,7 @@ vi.mock('@/app/components/workflow/store', () => ({ appId: 'app-1', conversationVariables: mockConversationVariables, setConversationVariables: mockSetConversationVariables, + setControlPromptEditorRerenderKey: mockSetControlPromptEditorRerenderKey, }), })) @@ -239,6 +242,7 @@ describe('ChatVariablePanel', () => { ['conversation', 'conversation_var_next'], ) expect(mockSetNodes).toHaveBeenCalledWith([updatedNode, createNode('node-2')]) + expect(mockSetControlPromptEditorRerenderKey).toHaveBeenCalled() }) it('should require confirmation before deleting variables referenced by workflow nodes', async () => { diff --git a/web/app/components/workflow/panel/chat-variable-panel/index.tsx b/web/app/components/workflow/panel/chat-variable-panel/index.tsx index 1793c7b57b..4d9eab8f69 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/index.tsx +++ b/web/app/components/workflow/panel/chat-variable-panel/index.tsx @@ -29,6 +29,7 @@ const ChatVariablePanel = () => { const setShowChatVariablePanel = useStore(s => s.setShowChatVariablePanel) const varList = useStore(s => s.conversationVariables) as ConversationVariable[] const updateChatVarList = useStore(s => s.setConversationVariables) + const setControlPromptEditorRerenderKey = useStore(s => s.setControlPromptEditorRerenderKey) const appId = useStore(s => s.appId) as string const { invalidateConversationVarValues, @@ -156,6 +157,7 @@ const ChatVariablePanel = () => { return node }) setNodes(newNodes) + setControlPromptEditorRerenderKey(Date.now()) } // Use new dedicated conversation variables API @@ -179,7 +181,7 @@ const ChatVariablePanel = () => { // Revert local state on error updateChatVarList(varList) } - }, [currentVar, getEffectedNodes, collaborativeWorkflow, updateChatVarList, varList, appId, invalidateConversationVarValues]) + }, [currentVar, getEffectedNodes, collaborativeWorkflow, updateChatVarList, varList, appId, invalidateConversationVarValues, setControlPromptEditorRerenderKey]) return (
{ }), expect.objectContaining({ id: 'node-2' }), ]) + expect(store.getState().controlPromptEditorRerenderKey).toBeGreaterThan(0) }) it('should convert edited plain variables into secrets and store the masked secret value', async () => { diff --git a/web/app/components/workflow/panel/env-panel/index.tsx b/web/app/components/workflow/panel/env-panel/index.tsx index 5d9424cbb5..7f201b5bdf 100644 --- a/web/app/components/workflow/panel/env-panel/index.tsx +++ b/web/app/components/workflow/panel/env-panel/index.tsx @@ -41,6 +41,7 @@ const useEnvPanelActions = ({ envSecrets, updateEnvList, setEnvSecrets, + setControlPromptEditorRerenderKey, doSyncWorkflowDraft, }: { collaborativeWorkflow: ReturnType @@ -48,6 +49,7 @@ const useEnvPanelActions = ({ envSecrets: Record updateEnvList: (envList: EnvironmentVariable[]) => void setEnvSecrets: (envSecrets: Record) => void + setControlPromptEditorRerenderKey: (controlPromptEditorRerenderKey: number) => void doSyncWorkflowDraft: () => Promise }) => { const emitVarsAndFeaturesUpdate = useCallback(async () => { @@ -99,7 +101,8 @@ const useEnvPanelActions = ({ return node }) setNodes(nextNodes) - }, [collaborativeWorkflow, getAffectedNodes]) + setControlPromptEditorRerenderKey(Date.now()) + }, [collaborativeWorkflow, getAffectedNodes, setControlPromptEditorRerenderKey]) const syncEnvList = useCallback(async ( nextEnvList: EnvironmentVariable[], @@ -152,6 +155,7 @@ const EnvPanel = () => { const envSecrets = useStore(s => s.envSecrets) const updateEnvList = useStore(s => s.setEnvironmentVariables) const setEnvSecrets = useStore(s => s.setEnvSecrets) + const setControlPromptEditorRerenderKey = useStore(s => s.setControlPromptEditorRerenderKey) const appId = useStore(s => s.appId) as string const { doSyncWorkflowDraft } = useNodesSyncDraft() const { @@ -166,6 +170,7 @@ const EnvPanel = () => { envSecrets, updateEnvList, setEnvSecrets, + setControlPromptEditorRerenderKey, doSyncWorkflowDraft, })