fix workflow variable rename propagation

This commit is contained in:
hjlarry 2026-04-14 15:08:05 +08:00
parent 0d3ada2bc9
commit a309d78f95
6 changed files with 266 additions and 23 deletions

View File

@ -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> = {}): 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#}}',
})
})
})
})

View File

@ -53,6 +53,7 @@ import {
} from '@/app/components/workflow/constants' } from '@/app/components/workflow/constants'
import DataSourceNodeDefault from '@/app/components/workflow/nodes/data-source/default' import DataSourceNodeDefault from '@/app/components/workflow/nodes/data-source/default'
import HumanInputNodeDefault from '@/app/components/workflow/nodes/human-input/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 ToolNodeDefault from '@/app/components/workflow/nodes/tool/default'
import PluginTriggerNodeDefault from '@/app/components/workflow/nodes/trigger-plugin/default' import PluginTriggerNodeDefault from '@/app/components/workflow/nodes/trigger-plugin/default'
import { 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[] => { export const getNodeUsedVars = (node: Node): ValueSelector[] => {
const { data } = node const { data } = node
const { type } = data const { type } = data
@ -1331,12 +1351,12 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => {
let prompts: string[] = [] let prompts: string[] = []
if (isChatModel) { if (isChatModel) {
prompts prompts
= (payload.prompt_template as PromptItem[])?.map(p => p.text) || [] = (payload.prompt_template as PromptItem[])?.flatMap(getPromptItemTexts) || []
if (payload.memory?.query_prompt_template) if (payload.memory?.query_prompt_template)
prompts.push(payload.memory.query_prompt_template) prompts.push(payload.memory.query_prompt_template)
} }
else { else {
prompts = [(payload.prompt_template as PromptItem).text] prompts = getPromptItemTexts(payload.prompt_template as PromptItem)
} }
const inputVars: ValueSelector[] = matchNotSystemVars(prompts) const inputVars: ValueSelector[] = matchNotSystemVars(prompts)
@ -1505,7 +1525,12 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => {
case BlockEnum.HumanInput: { case BlockEnum.HumanInput: {
const payload = data as HumanInputNodeType const payload = data as HumanInputNodeType
const formContent = payload.form_content 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 break
} }
} }
@ -1651,6 +1676,11 @@ export const updateNodeVars = (
} }
case BlockEnum.Answer: { case BlockEnum.Answer: {
const payload = data as AnswerNodeType const payload = data as AnswerNodeType
payload.answer = replaceOldVarInText(
payload.answer,
oldVarSelector,
newVarSelector,
)
if (payload.variables) { if (payload.variables) {
payload.variables = payload.variables.map((v) => { payload.variables = payload.variables.map((v) => {
if (v.value_selector.join('.') === oldVarSelector.join('.')) if (v.value_selector.join('.') === oldVarSelector.join('.'))
@ -1666,16 +1696,7 @@ export const updateNodeVars = (
if (isChatModel) { if (isChatModel) {
payload.prompt_template = ( payload.prompt_template = (
payload.prompt_template as PromptItem[] payload.prompt_template as PromptItem[]
).map((prompt) => { ).map(prompt => replaceOldVarInPromptItem(prompt, oldVarSelector, newVarSelector))
return {
...prompt,
text: replaceOldVarInText(
prompt.text,
oldVarSelector,
newVarSelector,
),
}
})
if (payload.memory?.query_prompt_template) { if (payload.memory?.query_prompt_template) {
payload.memory.query_prompt_template = replaceOldVarInText( payload.memory.query_prompt_template = replaceOldVarInText(
payload.memory.query_prompt_template, payload.memory.query_prompt_template,
@ -1685,14 +1706,11 @@ export const updateNodeVars = (
} }
} }
else { else {
payload.prompt_template = { payload.prompt_template = replaceOldVarInPromptItem(
...payload.prompt_template, payload.prompt_template as PromptItem,
text: replaceOldVarInText( oldVarSelector,
(payload.prompt_template as PromptItem).text, newVarSelector,
oldVarSelector, )
newVarSelector,
),
}
} }
if ( if (
payload.context?.variable_selector?.join('.') payload.context?.variable_selector?.join('.')
@ -1780,6 +1798,10 @@ export const updateNodeVars = (
oldVarSelector, oldVarSelector,
newVarSelector, newVarSelector,
) )
payload.classes = payload.classes.map(topic => ({
...topic,
name: replaceOldVarInText(topic.name, oldVarSelector, newVarSelector),
}))
break break
} }
case BlockEnum.HttpRequest: { case BlockEnum.HttpRequest: {
@ -1889,6 +1911,46 @@ export const updateNodeVars = (
} }
break 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: { case BlockEnum.VariableAssigner: {
const payload = data as VariableAssignerNodeType const payload = data as VariableAssignerNodeType
if (payload.variables) { if (payload.variables) {
@ -1954,6 +2016,22 @@ export const updateNodeVars = (
oldVarSelector, oldVarSelector,
newVarSelector, 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 break
} }
} }

View File

@ -9,6 +9,7 @@ type MockWorkflowStoreState = {
appId: string appId: string
conversationVariables: ConversationVariable[] conversationVariables: ConversationVariable[]
setConversationVariables: (value: ConversationVariable[]) => void setConversationVariables: (value: ConversationVariable[]) => void
setControlPromptEditorRerenderKey: (value: number) => void
} }
type MockFlowStore = { type MockFlowStore = {
@ -18,6 +19,7 @@ type MockFlowStore = {
const mockSetShowChatVariablePanel = vi.fn() const mockSetShowChatVariablePanel = vi.fn()
const mockSetConversationVariables = vi.fn() const mockSetConversationVariables = vi.fn()
const mockSetControlPromptEditorRerenderKey = vi.fn()
const mockInvalidateConversationVarValues = vi.fn() const mockInvalidateConversationVarValues = vi.fn()
const mockUpdateConversationVariables = vi.fn().mockResolvedValue(undefined) const mockUpdateConversationVariables = vi.fn().mockResolvedValue(undefined)
const mockFindUsedVarNodes = vi.fn<(selector: string[], nodes: Node[]) => Node[]>() const mockFindUsedVarNodes = vi.fn<(selector: string[], nodes: Node[]) => Node[]>()
@ -64,6 +66,7 @@ vi.mock('@/app/components/workflow/store', () => ({
appId: 'app-1', appId: 'app-1',
conversationVariables: mockConversationVariables, conversationVariables: mockConversationVariables,
setConversationVariables: mockSetConversationVariables, setConversationVariables: mockSetConversationVariables,
setControlPromptEditorRerenderKey: mockSetControlPromptEditorRerenderKey,
}), }),
})) }))
@ -239,6 +242,7 @@ describe('ChatVariablePanel', () => {
['conversation', 'conversation_var_next'], ['conversation', 'conversation_var_next'],
) )
expect(mockSetNodes).toHaveBeenCalledWith([updatedNode, createNode('node-2')]) expect(mockSetNodes).toHaveBeenCalledWith([updatedNode, createNode('node-2')])
expect(mockSetControlPromptEditorRerenderKey).toHaveBeenCalled()
}) })
it('should require confirmation before deleting variables referenced by workflow nodes', async () => { it('should require confirmation before deleting variables referenced by workflow nodes', async () => {

View File

@ -29,6 +29,7 @@ const ChatVariablePanel = () => {
const setShowChatVariablePanel = useStore(s => s.setShowChatVariablePanel) const setShowChatVariablePanel = useStore(s => s.setShowChatVariablePanel)
const varList = useStore(s => s.conversationVariables) as ConversationVariable[] const varList = useStore(s => s.conversationVariables) as ConversationVariable[]
const updateChatVarList = useStore(s => s.setConversationVariables) const updateChatVarList = useStore(s => s.setConversationVariables)
const setControlPromptEditorRerenderKey = useStore(s => s.setControlPromptEditorRerenderKey)
const appId = useStore(s => s.appId) as string const appId = useStore(s => s.appId) as string
const { const {
invalidateConversationVarValues, invalidateConversationVarValues,
@ -156,6 +157,7 @@ const ChatVariablePanel = () => {
return node return node
}) })
setNodes(newNodes) setNodes(newNodes)
setControlPromptEditorRerenderKey(Date.now())
} }
// Use new dedicated conversation variables API // Use new dedicated conversation variables API
@ -179,7 +181,7 @@ const ChatVariablePanel = () => {
// Revert local state on error // Revert local state on error
updateChatVarList(varList) updateChatVarList(varList)
} }
}, [currentVar, getEffectedNodes, collaborativeWorkflow, updateChatVarList, varList, appId, invalidateConversationVarValues]) }, [currentVar, getEffectedNodes, collaborativeWorkflow, updateChatVarList, varList, appId, invalidateConversationVarValues, setControlPromptEditorRerenderKey])
return ( return (
<div <div

View File

@ -333,6 +333,7 @@ describe('EnvPanel container', () => {
}), }),
expect.objectContaining({ id: 'node-2' }), 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 () => { it('should convert edited plain variables into secrets and store the masked secret value', async () => {

View File

@ -41,6 +41,7 @@ const useEnvPanelActions = ({
envSecrets, envSecrets,
updateEnvList, updateEnvList,
setEnvSecrets, setEnvSecrets,
setControlPromptEditorRerenderKey,
doSyncWorkflowDraft, doSyncWorkflowDraft,
}: { }: {
collaborativeWorkflow: ReturnType<typeof useCollaborativeWorkflow> collaborativeWorkflow: ReturnType<typeof useCollaborativeWorkflow>
@ -48,6 +49,7 @@ const useEnvPanelActions = ({
envSecrets: Record<string, string> envSecrets: Record<string, string>
updateEnvList: (envList: EnvironmentVariable[]) => void updateEnvList: (envList: EnvironmentVariable[]) => void
setEnvSecrets: (envSecrets: Record<string, string>) => void setEnvSecrets: (envSecrets: Record<string, string>) => void
setControlPromptEditorRerenderKey: (controlPromptEditorRerenderKey: number) => void
doSyncWorkflowDraft: () => Promise<void> doSyncWorkflowDraft: () => Promise<void>
}) => { }) => {
const emitVarsAndFeaturesUpdate = useCallback(async () => { const emitVarsAndFeaturesUpdate = useCallback(async () => {
@ -99,7 +101,8 @@ const useEnvPanelActions = ({
return node return node
}) })
setNodes(nextNodes) setNodes(nextNodes)
}, [collaborativeWorkflow, getAffectedNodes]) setControlPromptEditorRerenderKey(Date.now())
}, [collaborativeWorkflow, getAffectedNodes, setControlPromptEditorRerenderKey])
const syncEnvList = useCallback(async ( const syncEnvList = useCallback(async (
nextEnvList: EnvironmentVariable[], nextEnvList: EnvironmentVariable[],
@ -152,6 +155,7 @@ const EnvPanel = () => {
const envSecrets = useStore(s => s.envSecrets) const envSecrets = useStore(s => s.envSecrets)
const updateEnvList = useStore(s => s.setEnvironmentVariables) const updateEnvList = useStore(s => s.setEnvironmentVariables)
const setEnvSecrets = useStore(s => s.setEnvSecrets) const setEnvSecrets = useStore(s => s.setEnvSecrets)
const setControlPromptEditorRerenderKey = useStore(s => s.setControlPromptEditorRerenderKey)
const appId = useStore(s => s.appId) as string const appId = useStore(s => s.appId) as string
const { doSyncWorkflowDraft } = useNodesSyncDraft() const { doSyncWorkflowDraft } = useNodesSyncDraft()
const { const {
@ -166,6 +170,7 @@ const EnvPanel = () => {
envSecrets, envSecrets,
updateEnvList, updateEnvList,
setEnvSecrets, setEnvSecrets,
setControlPromptEditorRerenderKey,
doSyncWorkflowDraft, doSyncWorkflowDraft,
}) })