Merge branch 'feat/collaboration2' of https://github.com/langgenius/dify into feat/collaboration2

This commit is contained in:
yyh 2026-04-14 15:23:02 +08:00
commit 5fc5eb54c5
No known key found for this signature in database
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'
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
}
}

View File

@ -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 () => {

View File

@ -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 (
<div

View File

@ -333,6 +333,7 @@ describe('EnvPanel container', () => {
}),
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 () => {

View File

@ -41,6 +41,7 @@ const useEnvPanelActions = ({
envSecrets,
updateEnvList,
setEnvSecrets,
setControlPromptEditorRerenderKey,
doSyncWorkflowDraft,
}: {
collaborativeWorkflow: ReturnType<typeof useCollaborativeWorkflow>
@ -48,6 +49,7 @@ const useEnvPanelActions = ({
envSecrets: Record<string, string>
updateEnvList: (envList: EnvironmentVariable[]) => void
setEnvSecrets: (envSecrets: Record<string, string>) => void
setControlPromptEditorRerenderKey: (controlPromptEditorRerenderKey: number) => void
doSyncWorkflowDraft: () => Promise<void>
}) => {
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,
})