dify/web/app/components/workflow/panel/env-panel/index.tsx
2026-08-20 07:32:13 +00:00

837 lines
30 KiB
TypeScript

import type { ModelSelectorProvider } from '@/app/components/header/account-setting/model-provider-page/model-selector/types'
import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types'
import type {
EnvironmentVariable,
LLMEnvironmentVariableValue,
} from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { RiCloseLine } from '@remixicon/react'
import { cloneDeep } from 'es-toolkit/object'
import { isEqual } from 'es-toolkit/predicate'
import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
import RemoveEffectVarConfirm from '@/app/components/workflow/nodes/_base/components/remove-effect-var-confirm'
import {
findUsedVarNodes,
updateNodeVars,
} from '@/app/components/workflow/nodes/_base/components/variable/utils'
import EnvItem from '@/app/components/workflow/panel/env-panel/env-item'
import VariableTrigger from '@/app/components/workflow/panel/env-panel/variable-trigger'
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
import { BlockEnum } from '@/app/components/workflow/types'
import { Resolution } from '@/types/app'
import {
fetchModelParameterRulesForModel,
mergeValidCompletionParams,
} from '@/utils/completion-params'
import { useCollaborativeWorkflow } from '../../hooks/use-collaborative-workflow'
import { useNodesSyncDraft } from '../../hooks/use-nodes-sync-draft'
const HIDDEN_SECRET_VALUE = '[__HIDDEN__]'
type EnvironmentVariablePatch = {
environmentVariables: EnvironmentVariable[]
deletedEnvironmentVariableIds: string[]
}
const formatSecret = (secret: string) => {
return secret.length > 8
? `${secret.slice(0, 6)}************${secret.slice(-2)}`
: '********************'
}
const sanitizeSecretValue = (env: EnvironmentVariable) => {
return env.value_type === 'secret' ? { ...env, value: HIDDEN_SECRET_VALUE } : env
}
const removeSecretFromMap = (secretMap: Record<string, string>, envId: string) => {
const nextSecretMap = { ...secretMap }
delete nextSecretMap[envId]
return nextSecretMap
}
const isRecord = (value: unknown): value is Record<string, unknown> => {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
const restoreChangedValue = (current: unknown, previous: unknown, changed: unknown): unknown => {
if (isEqual(previous, changed)) return current
if (isEqual(current, changed)) return cloneDeep(previous)
if (!isRecord(current) || !isRecord(previous) || !isRecord(changed)) return current
const restored = { ...current }
const keys = new Set([...Object.keys(previous), ...Object.keys(changed)])
keys.forEach((key) => {
const hadPrevious = Object.hasOwn(previous, key)
const hadChanged = Object.hasOwn(changed, key)
const hasCurrent = Object.hasOwn(current, key)
if (!hadPrevious && hadChanged) {
if (hasCurrent && isEqual(current[key], changed[key])) delete restored[key]
return
}
if (hadPrevious && !hadChanged) {
if (!hasCurrent) restored[key] = cloneDeep(previous[key])
return
}
restored[key] = restoreChangedValue(current[key], previous[key], changed[key])
})
return restored
}
const mergeUntouchedEnvironmentVariables = (
committedEnvList: EnvironmentVariable[],
latestEnvList: EnvironmentVariable[],
pendingEnvIds: Set<string>,
) => {
const committedById = new Map(committedEnvList.map((env) => [env.id, env]))
const latestIds = new Set(latestEnvList.map((env) => env.id))
const mergedEnvList = latestEnvList.flatMap((env) => {
if (!pendingEnvIds.has(env.id)) return [env]
const committedEnv = committedById.get(env.id)
return committedEnv ? [committedEnv] : []
})
committedEnvList.forEach((env) => {
if (pendingEnvIds.has(env.id) && !latestIds.has(env.id)) mergedEnvList.push(env)
})
return mergedEnvList
}
const environmentVariablePatchMatches = (
environmentVariables: EnvironmentVariable[],
patch: EnvironmentVariablePatch,
) => {
const variablesById = new Map(
environmentVariables.map((variable) => [variable.id, sanitizeSecretValue(variable)]),
)
const upsertsMatch = patch.environmentVariables.every((variable) =>
isEqual(variablesById.get(variable.id), sanitizeSecretValue(variable)),
)
const deletionsMatch = patch.deletedEnvironmentVariableIds.every(
(variableId) => !variablesById.has(variableId),
)
return upsertsMatch && deletionsMatch
}
const environmentVariableGraphPatchMatches = (
environmentVariables: EnvironmentVariable[],
patch: EnvironmentVariablePatch,
) => {
const variablesById = new Map(environmentVariables.map((variable) => [variable.id, variable]))
const upsertsMatch = patch.environmentVariables.every((variable) => {
const persistedVariable = variablesById.get(variable.id)
if (
!persistedVariable ||
persistedVariable.name !== variable.name ||
persistedVariable.value_type !== variable.value_type
)
return false
if (variable.value_type !== 'llm') return true
return isEqual(persistedVariable.value, variable.value)
})
const deletionsMatch = patch.deletedEnvironmentVariableIds.every(
(variableId) => !variablesById.has(variableId),
)
return upsertsMatch && deletionsMatch
}
const getLLMEnvironmentValue = (
variable: EnvironmentVariable,
): LLMEnvironmentVariableValue | undefined => {
if (variable.value_type !== 'llm' || !variable.value || typeof variable.value !== 'object')
return undefined
const value = variable.value as Partial<LLMEnvironmentVariableValue>
if (!value.provider || !value.name || !value.mode) return undefined
return value as LLMEnvironmentVariableValue
}
const useEnvPanelActions = ({
collaborativeWorkflow,
workflowStore,
appId,
updateEnvList,
setEnvSecrets,
setControlPromptEditorRerenderKey,
activeTextGenerationModelList,
}: {
collaborativeWorkflow: ReturnType<typeof useCollaborativeWorkflow>
workflowStore: ReturnType<typeof useWorkflowStore>
appId: string
updateEnvList: (envList: EnvironmentVariable[]) => void
setEnvSecrets: (envSecrets: Record<string, string>) => void
setControlPromptEditorRerenderKey: (controlPromptEditorRerenderKey: number) => void
activeTextGenerationModelList: ModelSelectorProvider[]
}) => {
const emitVarsAndFeaturesUpdate = useCallback(
async (syncWorkflowDraft = false) => {
try {
const { webSocketClient } =
await import('@/app/components/workflow/collaboration/core/websocket-manager')
const socket = webSocketClient.getSocket(appId)
if (socket) {
socket.emit('collaboration_event', {
type: 'vars_and_features_update',
...(syncWorkflowDraft ? { data: { syncWorkflowDraft: true } } : {}),
})
}
} catch (error) {
console.error('Failed to emit vars_and_features_update event:', error)
}
},
[appId],
)
const persistEnvironmentVariables = useCallback(
async (patch: EnvironmentVariablePatch, syncWorkflowDraft = false) => {
try {
const { updateEnvironmentVariables } = await import('@/service/workflow')
await updateEnvironmentVariables({
appId,
...patch,
})
await emitVarsAndFeaturesUpdate(syncWorkflowDraft)
return true
} catch (error) {
console.error('Failed to update environment variables:', error)
return false
}
},
[appId, emitVarsAndFeaturesUpdate],
)
const fetchPersistedEnvironmentVariables = useCallback(async () => {
try {
const { fetchWorkflowDraft } = await import('@/service/workflow')
const workflow = await fetchWorkflowDraft(`/apps/${appId}/workflows/draft`)
return workflow.environment_variables
} catch (error) {
console.error('Failed to refresh environment variables:', error)
return undefined
}
}, [appId])
const getAffectedNodes = useCallback(
(env: EnvironmentVariable) => {
const { nodes: allNodes } = collaborativeWorkflow.getState()
return findUsedVarNodes(['env', env.name], allNodes)
},
[collaborativeWorkflow],
)
const updateAffectedNodes = useCallback(
(currentEnv: EnvironmentVariable, nextSelector: string[]) => {
const { nodes, setNodes } = collaborativeWorkflow.getState()
const affectedNodes = getAffectedNodes(currentEnv)
if (affectedNodes.length === 0) return undefined
const nextNodes = nodes.map((node) => {
if (affectedNodes.find((affectedNode) => affectedNode.id === node.id))
return updateNodeVars(node, ['env', currentEnv.name], nextSelector)
return node
})
setNodes(nextNodes)
setControlPromptEditorRerenderKey(Date.now())
const previousNodesById = new Map(nodes.map((node) => [node.id, node]))
const changedNodesById = new Map(nextNodes.map((node) => [node.id, node]))
return () => {
const { nodes: latestNodes, setNodes: setLatestNodes } = collaborativeWorkflow.getState()
let hasRestoredNode = false
const restoredNodes = latestNodes.map((node) => {
const previousNode = previousNodesById.get(node.id)
const changedNode = changedNodesById.get(node.id)
if (!previousNode || !changedNode || isEqual(previousNode.data, changedNode.data))
return node
const restoredData = restoreChangedValue(
node.data,
previousNode.data,
changedNode.data,
) as typeof node.data
if (isEqual(restoredData, node.data)) return node
hasRestoredNode = true
return { ...node, data: restoredData }
})
if (hasRestoredNode) setLatestNodes(restoredNodes)
}
},
[collaborativeWorkflow, getAffectedNodes, setControlPromptEditorRerenderKey],
)
const prepareAffectedLLMNodeReconciliation = useCallback(
async (currentEnv: EnvironmentVariable, nextEnv: EnvironmentVariable) => {
const currentModel = getLLMEnvironmentValue(currentEnv)
const nextModel = getLLMEnvironmentValue(nextEnv)
if (!currentModel || !nextModel) return undefined
if (
currentModel.provider === nextModel.provider &&
currentModel.name === nextModel.name &&
currentModel.mode === nextModel.mode
)
return undefined
const affectedNodes = getAffectedNodes(currentEnv).filter(
(node) =>
node.data.type === BlockEnum.LLM &&
(node.data as LLMNodeType).model_selector?.join('.') === `env.${currentEnv.name}`,
)
if (affectedNodes.length === 0) return undefined
const targetModel = activeTextGenerationModelList
.find((provider) => provider.provider === nextModel.provider)
?.models.find((model) => model.model === nextModel.name)
const supportsVision = !!targetModel?.features?.includes(ModelFeatureEnum.vision)
const parameterRules = await fetchModelParameterRulesForModel(
nextModel.provider,
nextModel.name,
)
return () => {
const { nodes, setNodes } = collaborativeWorkflow.getState()
let hasReconciledNode = false
const nextNodes = nodes.map((node) => {
if (
node.data.type !== BlockEnum.LLM ||
(node.data as LLMNodeType).model_selector?.join('.') !== `env.${currentEnv.name}`
)
return node
hasReconciledNode = true
const data = node.data as LLMNodeType
const completionParams = mergeValidCompletionParams(
data.model.completion_params,
parameterRules,
true,
).params
const vision = !supportsVision
? { enabled: false }
: data.vision?.enabled
? {
enabled: true,
configs: {
detail: Resolution.high,
variable_selector: [],
},
}
: data.vision
return {
...node,
data: {
...data,
model: {
...data.model,
provider: nextModel.provider,
name: nextModel.name,
completion_params: completionParams,
},
vision,
},
}
})
if (!hasReconciledNode) return undefined
setNodes(nextNodes)
setControlPromptEditorRerenderKey(Date.now())
const previousNodesById = new Map(nodes.map((node) => [node.id, node]))
const changedNodesById = new Map(nextNodes.map((node) => [node.id, node]))
return () => {
const { nodes: latestNodes, setNodes: setLatestNodes } = collaborativeWorkflow.getState()
let hasRestoredNode = false
const restoredNodes = latestNodes.map((node) => {
const previousNode = previousNodesById.get(node.id)
const changedNode = changedNodesById.get(node.id)
if (!previousNode || !changedNode || isEqual(previousNode.data, changedNode.data))
return node
const restoredData = restoreChangedValue(
node.data,
previousNode.data,
changedNode.data,
) as typeof node.data
if (isEqual(restoredData, node.data)) return node
hasRestoredNode = true
return { ...node, data: restoredData }
})
if (hasRestoredNode) setLatestNodes(restoredNodes)
}
}
},
[
activeTextGenerationModelList,
collaborativeWorkflow,
getAffectedNodes,
setControlPromptEditorRerenderKey,
],
)
const syncEnvList = useCallback(
async (
nextEnvList: EnvironmentVariable[],
patch: EnvironmentVariablePatch,
syncWorkflowDraft = false,
) => {
updateEnvList(nextEnvList)
const persisted = await persistEnvironmentVariables(patch, syncWorkflowDraft)
return persisted
},
[persistEnvironmentVariables, updateEnvList],
)
const saveSecretValue = useCallback(
(env: EnvironmentVariable) => {
const latestEnvSecrets = workflowStore.getState().envSecrets
setEnvSecrets({
...latestEnvSecrets,
[env.id]: formatSecret(String(env.value)),
})
},
[setEnvSecrets, workflowStore],
)
const removeEnvSecret = useCallback(
(envId: string) => {
setEnvSecrets(removeSecretFromMap(workflowStore.getState().envSecrets, envId))
},
[setEnvSecrets, workflowStore],
)
return {
emitVarsAndFeaturesUpdate,
fetchPersistedEnvironmentVariables,
getAffectedNodes,
prepareAffectedLLMNodeReconciliation,
updateAffectedNodes,
syncEnvList,
saveSecretValue,
removeEnvSecret,
}
}
const EnvPanel = () => {
const { t } = useTranslation()
const collaborativeWorkflow = useCollaborativeWorkflow()
const workflowStore = useWorkflowStore()
const setShowEnvPanel = useStore((s) => s.setShowEnvPanel)
const envList = useStore((s) => s.environmentVariables) as EnvironmentVariable[]
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 { activeTextGenerationModelList } = useTextGenerationCurrentProviderAndModelAndModelList()
const {
emitVarsAndFeaturesUpdate,
fetchPersistedEnvironmentVariables,
getAffectedNodes,
prepareAffectedLLMNodeReconciliation,
updateAffectedNodes,
syncEnvList,
saveSecretValue,
removeEnvSecret,
} = useEnvPanelActions({
collaborativeWorkflow,
workflowStore,
appId,
updateEnvList,
setEnvSecrets,
setControlPromptEditorRerenderKey,
activeTextGenerationModelList,
})
const [showVariableModal, setShowVariableModal] = useState(false)
const [currentVar, setCurrentVar] = useState<EnvironmentVariable>()
const [showRemoveVarConfirm, setShowRemoveVarConfirm] = useState(false)
const [cacheForDelete, setCacheForDelete] = useState<EnvironmentVariable>()
const saveOperationQueueRef = useRef(Promise.resolve())
const committedEnvListRef = useRef(envList)
const latestEnvListRef = useRef(envList)
const pendingSaveEnvIdsRef = useRef(new Map<string, number>())
useLayoutEffect(() => {
latestEnvListRef.current = envList
}, [envList])
useEffect(() => {
if (pendingSaveEnvIdsRef.current.size === 0) committedEnvListRef.current = envList
}, [envList])
const mergeLatestUntouchedEnvList = useCallback((envListToMerge: EnvironmentVariable[]) => {
return mergeUntouchedEnvironmentVariables(
envListToMerge,
latestEnvListRef.current,
new Set(pendingSaveEnvIdsRef.current.keys()),
)
}, [])
const restoreCommittedEnvList = useCallback(() => {
committedEnvListRef.current = mergeLatestUntouchedEnvList(committedEnvListRef.current)
updateEnvList(committedEnvListRef.current)
}, [mergeLatestUntouchedEnvList, updateEnvList])
const reconcileAmbiguousPersistenceFailure = useCallback(
async (patch: EnvironmentVariablePatch) => {
const persistedEnvList = await fetchPersistedEnvironmentVariables()
if (!persistedEnvList) {
if (!environmentVariablePatchMatches(latestEnvListRef.current, patch)) {
committedEnvListRef.current = latestEnvListRef.current.map(sanitizeSecretValue)
updateEnvList(committedEnvListRef.current)
return false
}
restoreCommittedEnvList()
return false
}
committedEnvListRef.current = persistedEnvList.map(sanitizeSecretValue)
updateEnvList(committedEnvListRef.current)
return environmentVariablePatchMatches(persistedEnvList, patch)
},
[fetchPersistedEnvironmentVariables, restoreCommittedEnvList, updateEnvList],
)
const commitPersistedEnvList = useCallback(
async (nextEnvList: EnvironmentVariable[], patch: EnvironmentVariablePatch) => {
const persistedEnvList = collaborationManager.isConnected()
? await fetchPersistedEnvironmentVariables()
: undefined
committedEnvListRef.current = (
persistedEnvList ?? mergeLatestUntouchedEnvList(nextEnvList)
).map(sanitizeSecretValue)
updateEnvList(committedEnvListRef.current)
return !persistedEnvList || environmentVariableGraphPatchMatches(persistedEnvList, patch)
},
[fetchPersistedEnvironmentVariables, mergeLatestUntouchedEnvList, updateEnvList],
)
const enqueueSaveOperation = useCallback(
(envId: string, operation: () => Promise<void>) => {
pendingSaveEnvIdsRef.current.set(envId, (pendingSaveEnvIdsRef.current.get(envId) ?? 0) + 1)
const runOperation = async () => {
committedEnvListRef.current = mergeLatestUntouchedEnvList(committedEnvListRef.current)
try {
await operation()
} finally {
const pendingCount = pendingSaveEnvIdsRef.current.get(envId) ?? 0
if (pendingCount <= 1) pendingSaveEnvIdsRef.current.delete(envId)
else pendingSaveEnvIdsRef.current.set(envId, pendingCount - 1)
}
}
const queuedOperation = saveOperationQueueRef.current.then(runOperation, runOperation)
saveOperationQueueRef.current = queuedOperation.catch(() => undefined)
return queuedOperation
},
[mergeLatestUntouchedEnvList],
)
const syncDraftWithResult = useCallback(
async (environmentVariablePatch?: EnvironmentVariablePatch) => {
let succeeded = false
try {
await doSyncWorkflowDraft(
false,
{
onSuccess: () => {
succeeded = true
},
onError: () => {
succeeded = false
},
},
environmentVariablePatch ? { environmentVariablePatch } : undefined,
)
} catch {
succeeded = false
}
return succeeded
},
[doSyncWorkflowDraft],
)
const persistQueuedEnvList = useCallback(
async (nextEnvList: EnvironmentVariable[], patch: EnvironmentVariablePatch) => {
const persisted = await syncEnvList(nextEnvList, patch)
if (persisted) return { persisted: true, usedDraftFallback: false }
const fallbackPersisted = await syncDraftWithResult(patch)
if (fallbackPersisted) await emitVarsAndFeaturesUpdate()
return {
persisted: fallbackPersisted,
usedDraftFallback: true,
}
},
[emitVarsAndFeaturesUpdate, syncDraftWithResult, syncEnvList],
)
const persistEnvListWithGraphChanges = useCallback(
async (nextEnvList: EnvironmentVariable[], patch: EnvironmentVariablePatch) => {
const isCollaborationFollower =
collaborationManager.isConnected() && !collaborationManager.getIsLeader()
if (isCollaborationFollower) return syncEnvList(nextEnvList, patch, true)
const persisted = await syncDraftWithResult(patch)
if (persisted) await emitVarsAndFeaturesUpdate()
return persisted
},
[emitVarsAndFeaturesUpdate, syncDraftWithResult, syncEnvList],
)
const handleEdit = (env: EnvironmentVariable) => {
setCurrentVar(env)
setShowVariableModal(true)
}
const handleDelete = useCallback(
async (env: EnvironmentVariable) => {
setCacheForDelete(undefined)
setShowRemoveVarConfirm(false)
await enqueueSaveOperation(env.id, async () => {
const committedEnv = committedEnvListRef.current.find((item) => item.id === env.id)
if (!committedEnv) return
const nextEnvList = committedEnvListRef.current.filter((item) => item.id !== env.id)
const patch: EnvironmentVariablePatch = {
environmentVariables: [],
deletedEnvironmentVariableIds: [committedEnv.id],
}
updateEnvList(nextEnvList)
const rollbackAffectedNodes = updateAffectedNodes(committedEnv, [])
const persisted = rollbackAffectedNodes
? await persistEnvListWithGraphChanges(nextEnvList, patch)
: (await persistQueuedEnvList(nextEnvList, patch)).persisted
if (!persisted) {
const recovered = await reconcileAmbiguousPersistenceFailure(patch)
if (!recovered) {
rollbackAffectedNodes?.()
toast.error(t(($) => $.error, { ns: 'common' }))
}
if (recovered)
await emitVarsAndFeaturesUpdate(
!!rollbackAffectedNodes &&
collaborationManager.isConnected() &&
!collaborationManager.getIsLeader(),
)
if (recovered && committedEnv.value_type === 'secret') removeEnvSecret(committedEnv.id)
return
}
const mutationIsCurrent = await commitPersistedEnvList(nextEnvList, patch)
if (!mutationIsCurrent) {
rollbackAffectedNodes?.()
return
}
if (committedEnv.value_type === 'secret') removeEnvSecret(committedEnv.id)
})
},
[
enqueueSaveOperation,
commitPersistedEnvList,
emitVarsAndFeaturesUpdate,
persistEnvListWithGraphChanges,
persistQueuedEnvList,
removeEnvSecret,
reconcileAmbiguousPersistenceFailure,
t,
updateAffectedNodes,
updateEnvList,
],
)
const deleteCheck = useCallback(
(env: EnvironmentVariable) => {
const affectedNodes = getAffectedNodes(env)
if (affectedNodes.length > 0) {
setCacheForDelete(env)
setShowRemoveVarConfirm(true)
} else {
handleDelete(env)
}
},
[getAffectedNodes, handleDelete],
)
const handleSave = useCallback(
async (env: EnvironmentVariable) => {
let newEnv = env
if (!currentVar) {
await enqueueSaveOperation(env.id, async () => {
const nextEnvList = [
env,
...committedEnvListRef.current.filter((item) => item.id !== env.id),
]
const patch: EnvironmentVariablePatch = {
environmentVariables: [env],
deletedEnvironmentVariableIds: [],
}
const { persisted } = await persistQueuedEnvList(nextEnvList, patch)
if (!persisted) {
const recovered = await reconcileAmbiguousPersistenceFailure(patch)
if (!recovered) toast.error(t(($) => $.error, { ns: 'common' }))
if (recovered) {
await emitVarsAndFeaturesUpdate()
if (env.value_type === 'secret') saveSecretValue(env)
}
return
}
const mutationIsCurrent = await commitPersistedEnvList(nextEnvList, patch)
if (!mutationIsCurrent) return
if (env.value_type === 'secret') saveSecretValue(env)
})
return
}
let shouldSaveSecret = false
let shouldRemoveSecret = false
if (currentVar.value_type === 'secret') {
if (env.value_type === 'secret') {
if (workflowStore.getState().envSecrets[currentVar.id] !== env.value) {
newEnv = env
shouldSaveSecret = true
} else {
newEnv = sanitizeSecretValue(env)
}
} else {
shouldRemoveSecret = true
}
} else if (env.value_type === 'secret') {
shouldSaveSecret = true
}
await enqueueSaveOperation(currentVar.id, async () => {
const committedEnv = committedEnvListRef.current.find((item) => item.id === currentVar.id)
if (!committedEnv) return
let applyLLMNodeReconciliation: (() => (() => void) | undefined) | undefined
try {
applyLLMNodeReconciliation = await prepareAffectedLLMNodeReconciliation(
committedEnv,
newEnv,
)
} catch {
restoreCommittedEnvList()
toast.error(t(($) => $.error, { ns: 'common' }))
return
}
committedEnvListRef.current = mergeLatestUntouchedEnvList(committedEnvListRef.current)
const nextCommittedEnvList = committedEnvListRef.current.map((item) =>
item.id === currentVar.id ? newEnv : item,
)
updateEnvList(nextCommittedEnvList)
const rollbackReconciledLLMNodes = applyLLMNodeReconciliation?.()
const hasEnvNameChanged = committedEnv.name !== newEnv.name
const rollbackRenamedAffectedNodes = hasEnvNameChanged
? updateAffectedNodes(committedEnv, ['env', newEnv.name])
: undefined
const hasGraphChanges = !!rollbackReconciledLLMNodes || !!rollbackRenamedAffectedNodes
const patch: EnvironmentVariablePatch = {
environmentVariables: [newEnv],
deletedEnvironmentVariableIds: [],
}
const persisted = hasGraphChanges
? await persistEnvListWithGraphChanges(nextCommittedEnvList, patch)
: (await persistQueuedEnvList(nextCommittedEnvList, patch)).persisted
if (!persisted) {
const recovered = await reconcileAmbiguousPersistenceFailure(patch)
if (!recovered) {
rollbackRenamedAffectedNodes?.()
rollbackReconciledLLMNodes?.()
toast.error(t(($) => $.error, { ns: 'common' }))
}
if (recovered)
await emitVarsAndFeaturesUpdate(
hasGraphChanges &&
collaborationManager.isConnected() &&
!collaborationManager.getIsLeader(),
)
if (recovered && shouldSaveSecret) saveSecretValue(newEnv)
else if (recovered && shouldRemoveSecret) removeEnvSecret(currentVar.id)
return
}
const mutationIsCurrent = await commitPersistedEnvList(nextCommittedEnvList, patch)
if (!mutationIsCurrent) {
rollbackRenamedAffectedNodes?.()
rollbackReconciledLLMNodes?.()
return
}
if (shouldSaveSecret) saveSecretValue(newEnv)
else if (shouldRemoveSecret) removeEnvSecret(currentVar.id)
})
},
[
currentVar,
commitPersistedEnvList,
enqueueSaveOperation,
emitVarsAndFeaturesUpdate,
mergeLatestUntouchedEnvList,
persistEnvListWithGraphChanges,
persistQueuedEnvList,
removeEnvSecret,
prepareAffectedLLMNodeReconciliation,
reconcileAmbiguousPersistenceFailure,
saveSecretValue,
restoreCommittedEnvList,
t,
updateAffectedNodes,
updateEnvList,
workflowStore,
],
)
const handleVariableModalClose = () => {
setCurrentVar(undefined)
}
return (
<div
className={cn(
'relative flex h-full w-105 flex-col rounded-l-2xl border border-components-panel-border bg-components-panel-bg-alt',
)}
>
<div className="flex shrink-0 items-center justify-between p-4 pb-0 system-xl-semibold text-text-primary">
{t(($) => $['env.envPanelTitle'], { ns: 'workflow' })}
<div className="flex items-center">
<button
type="button"
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
className="flex size-6 cursor-pointer items-center justify-center"
onClick={() => setShowEnvPanel(false)}
>
{/* oxlint-disable-next-line dify/prefer-tailwind-icons */}
<RiCloseLine className="size-4 text-text-tertiary" />
</button>
</div>
</div>
<div className="shrink-0 px-4 py-1 system-sm-regular text-text-tertiary">
{t(($) => $['env.envDescription'], { ns: 'workflow' })}
</div>
<div className="shrink-0 px-4 pt-2 pb-3">
<VariableTrigger
open={showVariableModal}
setOpen={setShowVariableModal}
env={currentVar}
onSave={handleSave}
onClose={handleVariableModalClose}
/>
</div>
<div className="grow overflow-y-auto rounded-b-2xl px-4">
{envList.map((env) => (
<EnvItem key={env.id} env={env} onEdit={handleEdit} onDelete={deleteCheck} />
))}
</div>
<RemoveEffectVarConfirm
isShow={showRemoveVarConfirm}
onCancel={() => setShowRemoveVarConfirm(false)}
onConfirm={() => cacheForDelete && handleDelete(cacheForDelete)}
/>
</div>
)
}
export default memo(EnvPanel)