Merge remote-tracking branch 'origin/feat/workflow-parallel-support' into feat/workflow-parallel-support

This commit is contained in:
takatost 2024-09-02 17:56:16 +08:00
commit 35d9c59a29
12 changed files with 369 additions and 158 deletions

View File

@ -384,7 +384,7 @@ export const useNodesInteractions = () => {
handleSyncWorkflowDraft() handleSyncWorkflowDraft()
saveStateToHistory(WorkflowHistoryEvent.NodeConnect) saveStateToHistory(WorkflowHistoryEvent.NodeConnect)
} }
}, [getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory]) }, [getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory, checkNestedParallelLimit])
const handleNodeConnectStart = useCallback<OnConnectStart>((_, { nodeId, handleType, handleId }) => { const handleNodeConnectStart = useCallback<OnConnectStart>((_, { nodeId, handleType, handleId }) => {
if (getNodesReadOnly()) if (getNodesReadOnly())
@ -930,7 +930,7 @@ export const useNodesInteractions = () => {
} }
handleSyncWorkflowDraft() handleSyncWorkflowDraft()
saveStateToHistory(WorkflowHistoryEvent.NodeAdd) saveStateToHistory(WorkflowHistoryEvent.NodeAdd)
}, [getNodesReadOnly, store, t, handleSyncWorkflowDraft, saveStateToHistory, workflowStore, getAfterNodesInSameBranch]) }, [getNodesReadOnly, store, t, handleSyncWorkflowDraft, saveStateToHistory, workflowStore, getAfterNodesInSameBranch, checkNestedParallelLimit])
const handleNodeChange = useCallback(( const handleNodeChange = useCallback((
currentNodeId: string, currentNodeId: string,
@ -1254,6 +1254,42 @@ export const useNodesInteractions = () => {
saveStateToHistory(WorkflowHistoryEvent.NodeResize) saveStateToHistory(WorkflowHistoryEvent.NodeResize)
}, [getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory]) }, [getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory])
const handleNodeDisconnect = useCallback((nodeId: string) => {
if (getNodesReadOnly())
return
const {
getNodes,
setNodes,
edges,
setEdges,
} = store.getState()
const nodes = getNodes()
const currentNode = nodes.find(node => node.id === nodeId)!
const connectedEdges = getConnectedEdges([currentNode], edges)
const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap(
connectedEdges.map(edge => ({ type: 'remove', edge })),
nodes,
)
const newNodes = produce(nodes, (draft: Node[]) => {
draft.forEach((node) => {
if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) {
node.data = {
...node.data,
...nodesConnectedSourceOrTargetHandleIdsMap[node.id],
}
}
})
})
setNodes(newNodes)
const newEdges = produce(edges, (draft) => {
return draft.filter(edge => !connectedEdges.find(connectedEdge => connectedEdge.id === edge.id))
})
setEdges(newEdges)
handleSyncWorkflowDraft()
saveStateToHistory(WorkflowHistoryEvent.EdgeDelete)
}, [store, getNodesReadOnly, handleSyncWorkflowDraft, saveStateToHistory])
const handleHistoryBack = useCallback(() => { const handleHistoryBack = useCallback(() => {
if (getNodesReadOnly() || getWorkflowReadOnly()) if (getNodesReadOnly() || getWorkflowReadOnly())
return return
@ -1306,6 +1342,7 @@ export const useNodesInteractions = () => {
handleNodesDuplicate, handleNodesDuplicate,
handleNodesDelete, handleNodesDelete,
handleNodeResize, handleNodeResize,
handleNodeDisconnect,
handleHistoryBack, handleHistoryBack,
handleHistoryForward, handleHistoryForward,
} }

View File

@ -300,7 +300,6 @@ export const useWorkflow = () => {
const checkNestedParallelLimit = useCallback((nodes: Node[], edges: Edge[], parentNodeId?: string) => { const checkNestedParallelLimit = useCallback((nodes: Node[], edges: Edge[], parentNodeId?: string) => {
const parallelList = getParallelInfo(nodes, edges, parentNodeId) const parallelList = getParallelInfo(nodes, edges, parentNodeId)
console.log(parallelList, 'parallelList')
for (let i = 0; i < parallelList.length; i++) { for (let i = 0; i < parallelList.length; i++) {
const parallel = parallelList[i] const parallel = parallelList[i]
@ -313,7 +312,7 @@ export const useWorkflow = () => {
} }
return true return true
}, []) }, [t, workflowStore])
const isValidConnection = useCallback(({ source, target }: Connection) => { const isValidConnection = useCallback(({ source, target }: Connection) => {
const { const {

View File

@ -421,7 +421,6 @@ const WorkflowWrap = memo(() => {
citation: features.retriever_resource || { enabled: false }, citation: features.retriever_resource || { enabled: false },
moderation: features.sensitive_word_avoidance || { enabled: false }, moderation: features.sensitive_word_avoidance || { enabled: false },
} }
// getParallelInfo(nodesData, edgesData)
return ( return (
<ReactFlowProvider> <ReactFlowProvider>

View File

@ -21,13 +21,13 @@ type AddProps = {
nodeId: string nodeId: string
nodeData: CommonNodeType nodeData: CommonNodeType
sourceHandle: string sourceHandle: string
branchName?: string isParallel?: boolean
} }
const Add = ({ const Add = ({
nodeId, nodeId,
nodeData, nodeData,
sourceHandle, sourceHandle,
branchName, isParallel,
}: AddProps) => { }: AddProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const { handleNodeAdd } = useNodesInteractions() const { handleNodeAdd } = useNodesInteractions()
@ -57,23 +57,19 @@ const Add = ({
${nodesReadOnly && '!cursor-not-allowed'} ${nodesReadOnly && '!cursor-not-allowed'}
`} `}
> >
{
branchName && (
<div
className='absolute left-1 right-1 -top-[7.5px] flex items-center h-3 text-[10px] text-text-placeholder font-semibold'
title={branchName.toLocaleUpperCase()}
>
<div className='inline-block px-0.5 rounded-[5px] bg-background-default truncate'>{branchName.toLocaleUpperCase()}</div>
</div>
)
}
<div className='flex items-center justify-center mr-1.5 w-5 h-5 rounded-[5px] bg-background-default-dimm'> <div className='flex items-center justify-center mr-1.5 w-5 h-5 rounded-[5px] bg-background-default-dimm'>
<RiAddLine className='w-3 h-3' /> <RiAddLine className='w-3 h-3' />
</div> </div>
{t('workflow.panel.selectNextStep')} <div className='flex items-center uppercase'>
{
isParallel
? t('workflow.common.addParallelNode')
: t('workflow.panel.selectNextStep')
}
</div>
</div> </div>
) )
}, [branchName, t, nodesReadOnly]) }, [t, nodesReadOnly, isParallel])
return ( return (
<BlockSelector <BlockSelector

View File

@ -0,0 +1,55 @@
import Add from './add'
import Item from './item'
import type {
CommonNodeType,
Node,
} from '@/app/components/workflow/types'
type ContainerProps = {
nodeId: string
nodeData: CommonNodeType
sourceHandle: string
nextNodes: Node[]
branchName?: string
}
const Container = ({
nodeId,
nodeData,
sourceHandle,
nextNodes,
branchName,
}: ContainerProps) => {
return (
<div className='p-0.5 space-y-0.5 rounded-[10px] bg-background-section-burn'>
{
branchName && (
<div
className='flex items-center px-2 system-2xs-semibold-uppercase text-text-tertiary truncate'
title={branchName}
>
{branchName}
</div>
)
}
{
nextNodes.map(nextNode => (
<Item
key={nextNode.id}
nodeId={nextNode.id}
data={nextNode.data}
sourceHandle='source'
/>
))
}
<Add
isParallel={!!nextNodes.length}
nodeId={nodeId}
nodeData={nodeData}
sourceHandle={sourceHandle}
/>
</div>
)
}
export default Container

View File

@ -1,4 +1,5 @@
import { memo } from 'react' import { memo, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { import {
getConnectedEdges, getConnectedEdges,
getOutgoers, getOutgoers,
@ -8,13 +9,11 @@ import {
import { useToolIcon } from '../../../../hooks' import { useToolIcon } from '../../../../hooks'
import BlockIcon from '../../../../block-icon' import BlockIcon from '../../../../block-icon'
import type { import type {
Branch,
Node, Node,
} from '../../../../types' } from '../../../../types'
import { BlockEnum } from '../../../../types' import { BlockEnum } from '../../../../types'
import Add from './add'
import Item from './item'
import Line from './line' import Line from './line'
import Container from './container'
type NextStepProps = { type NextStepProps = {
selectedNode: Node selectedNode: Node
@ -22,15 +21,33 @@ type NextStepProps = {
const NextStep = ({ const NextStep = ({
selectedNode, selectedNode,
}: NextStepProps) => { }: NextStepProps) => {
const { t } = useTranslation()
const data = selectedNode.data const data = selectedNode.data
const toolIcon = useToolIcon(data) const toolIcon = useToolIcon(data)
const store = useStoreApi() const store = useStoreApi()
const branches = data._targetBranches || [] const branches = useMemo(() => {
return data._targetBranches || []
}, [data])
const nodeWithBranches = data.type === BlockEnum.IfElse || data.type === BlockEnum.QuestionClassifier const nodeWithBranches = data.type === BlockEnum.IfElse || data.type === BlockEnum.QuestionClassifier
const edges = useEdges() const edges = useEdges()
const outgoers = getOutgoers(selectedNode as Node, store.getState().getNodes(), edges) const outgoers = getOutgoers(selectedNode as Node, store.getState().getNodes(), edges)
const connectedEdges = getConnectedEdges([selectedNode] as Node[], edges).filter(edge => edge.source === selectedNode!.id) const connectedEdges = getConnectedEdges([selectedNode] as Node[], edges).filter(edge => edge.source === selectedNode!.id)
const branchesOutgoers = useMemo(() => {
if (!branches?.length)
return []
return branches.map((branch) => {
const connected = connectedEdges.filter(edge => edge.sourceHandle === branch.id)
const nextNodes = connected.map(edge => outgoers.find(outgoer => outgoer.id === edge.target)!)
return {
branch,
nextNodes,
}
})
}, [branches, connectedEdges, outgoers])
return ( return (
<div className='flex py-1'> <div className='flex py-1'>
<div className='shrink-0 relative flex items-center justify-center w-9 h-9 bg-background-default rounded-lg border-[0.5px] border-divider-regular shadow-xs'> <div className='shrink-0 relative flex items-center justify-center w-9 h-9 bg-background-default rounded-lg border-[0.5px] border-divider-regular shadow-xs'>
@ -39,59 +56,32 @@ const NextStep = ({
toolIcon={toolIcon} toolIcon={toolIcon}
/> />
</div> </div>
<Line linesNumber={nodeWithBranches ? branches.length : 1} /> <Line
<div className='grow'> list={nodeWithBranches ? branchesOutgoers.map(item => item.nextNodes.length + 1) : [1]}
/>
<div className='grow space-y-2'>
{ {
!nodeWithBranches && !!outgoers.length && ( !nodeWithBranches && (
<Item <Container
nodeId={outgoers[0].id}
data={outgoers[0].data}
sourceHandle='source'
/>
)
}
{
!nodeWithBranches && !outgoers.length && (
<Add
nodeId={selectedNode!.id} nodeId={selectedNode!.id}
nodeData={selectedNode!.data} nodeData={selectedNode!.data}
sourceHandle='source' sourceHandle='source'
nextNodes={outgoers}
/> />
) )
} }
{ {
!!branches?.length && nodeWithBranches && ( nodeWithBranches && (
branches.map((branch: Branch) => { branchesOutgoers.map((item, index) => {
const connected = connectedEdges.find(edge => edge.sourceHandle === branch.id)
const target = outgoers.find(outgoer => outgoer.id === connected?.target)
return ( return (
<div <Container
key={branch.id} key={item.branch.id}
className='mb-3 last-of-type:mb-0' nodeId={selectedNode!.id}
> nodeData={selectedNode!.data}
{ sourceHandle={item.branch.id}
connected && ( nextNodes={item.nextNodes}
<Item branchName={item.branch.name || `${t('workflow.nodes.questionClassifiers.class')} ${index + 1}`}
data={target!.data!} />
nodeId={target!.id}
sourceHandle={branch.id}
branchName={branch.name}
/>
)
}
{
!connected && (
<Add
key={branch.id}
nodeId={selectedNode!.id}
nodeData={selectedNode!.data}
sourceHandle={branch.id}
branchName={branch.name}
/>
)
}
</div>
) )
}) })
) )

View File

@ -1,94 +1,82 @@
import { import {
memo, memo,
useCallback, useCallback,
useState,
} from 'react' } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { intersection } from 'lodash-es' import Operator from './operator'
import type { import type {
CommonNodeType, CommonNodeType,
OnSelectBlock,
} from '@/app/components/workflow/types' } from '@/app/components/workflow/types'
import BlockIcon from '@/app/components/workflow/block-icon' import BlockIcon from '@/app/components/workflow/block-icon'
import BlockSelector from '@/app/components/workflow/block-selector'
import { import {
useAvailableBlocks,
useNodesInteractions, useNodesInteractions,
useNodesReadOnly, useNodesReadOnly,
useToolIcon, useToolIcon,
} from '@/app/components/workflow/hooks' } from '@/app/components/workflow/hooks'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import cn from '@/utils/classnames'
type ItemProps = { type ItemProps = {
nodeId: string nodeId: string
sourceHandle: string sourceHandle: string
branchName?: string
data: CommonNodeType data: CommonNodeType
} }
const Item = ({ const Item = ({
nodeId, nodeId,
sourceHandle, sourceHandle,
branchName,
data, data,
}: ItemProps) => { }: ItemProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const { handleNodeChange } = useNodesInteractions() const [open, setOpen] = useState(false)
const { nodesReadOnly } = useNodesReadOnly() const { nodesReadOnly } = useNodesReadOnly()
const { handleNodeSelect } = useNodesInteractions()
const toolIcon = useToolIcon(data) const toolIcon = useToolIcon(data)
const {
availablePrevBlocks,
availableNextBlocks,
} = useAvailableBlocks(data.type, data.isInIteration)
const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => { const handleOpenChange = useCallback((v: boolean) => {
handleNodeChange(nodeId, type, sourceHandle, toolDefaultValue) setOpen(v)
}, [nodeId, sourceHandle, handleNodeChange]) }, [])
const renderTrigger = useCallback((open: boolean) => {
return (
<Button
size='small'
className={`
hidden group-hover:flex
${open && '!bg-gray-100 !flex'}
`}
>
{t('workflow.panel.change')}
</Button>
)
}, [t])
return ( return (
<div <div
className='relative group flex items-center mb-3 last-of-type:mb-0 px-2 h-9 rounded-lg border-[0.5px] border-divider-regular bg-background-default hover:bg-background-default-hover shadow-xs text-xs text-text-secondary cursor-pointer' className='relative group flex items-center last-of-type:mb-0 px-2 h-9 rounded-lg border-[0.5px] border-divider-regular bg-background-default hover:bg-background-default-hover shadow-xs text-xs text-text-secondary cursor-pointer'
> >
{
branchName && (
<div
className='absolute left-1 right-1 -top-[7.5px] flex items-center h-3 text-[10px] text-gray-500 font-semibold'
title={branchName.toLocaleUpperCase()}
>
<div className='inline-block px-0.5 rounded-[5px] bg-white truncate'>{branchName.toLocaleUpperCase()}</div>
</div>
)
}
<BlockIcon <BlockIcon
type={data.type} type={data.type}
toolIcon={toolIcon} toolIcon={toolIcon}
className='shrink-0 mr-1.5' className='shrink-0 mr-1.5'
/> />
<div className='grow system-xs-medium text-text-secondary'>{data.title}</div> <div
className='grow system-xs-medium text-text-secondary truncate'
title={data.title}
>
{data.title}
</div>
{ {
!nodesReadOnly && ( !nodesReadOnly && (
<BlockSelector <>
onSelect={handleSelect} <Button
placement='top-end' className='hidden group-hover:flex shrink-0 mr-1'
offset={{ size='small'
mainAxis: 6, onClick={() => handleNodeSelect(nodeId)}
crossAxis: 8, >
}} {t('workflow.common.jumpToNode')}
trigger={renderTrigger} </Button>
popupClassName='!w-[328px]' <div
availableBlocksTypes={intersection(availablePrevBlocks, availableNextBlocks).filter(item => item !== data.type)} className={cn(
/> 'hidden shrink-0 group-hover:flex items-center',
open && 'flex',
)}
>
<Operator
data={data}
nodeId={nodeId}
sourceHandle={sourceHandle}
open={open}
onOpenChange={handleOpenChange}
/>
</div>
</>
) )
} }
</div> </div>

View File

@ -1,56 +1,70 @@
import { memo } from 'react' import { memo } from 'react'
type LineProps = { type LineProps = {
linesNumber: number list: number[]
} }
const Line = ({ const Line = ({
linesNumber, list,
}: LineProps) => { }: LineProps) => {
const svgHeight = linesNumber * 36 + (linesNumber - 1) * 12 const listHeight = list.map((item) => {
return item * 36 + (item - 1) * 2 + 12 + 6
})
const processedList = listHeight.map((item, index) => {
if (index === 0)
return item
return listHeight.slice(0, index).reduce((acc, cur) => acc + cur, 0) + item
})
const processedListLength = processedList.length
const svgHeight = processedList[processedListLength - 1] + (processedListLength - 1) * 8
return ( return (
<svg className='shrink-0 w-6' style={{ height: svgHeight }}> <svg className='shrink-0 w-6' style={{ height: svgHeight }}>
{ {
Array(linesNumber).fill(0).map((_, index) => ( processedList.map((item, index) => {
<g key={index}> const prevItem = index > 0 ? processedList[index - 1] : 0
{ const space = prevItem + index * 8 + 16
index === 0 && ( return (
<> <g key={index}>
{
index === 0 && (
<>
<path
d='M0,18 L24,18'
strokeWidth={1}
fill='none'
className='stroke-divider-soild'
/>
<rect
x={0}
y={16}
width={1}
height={4}
className='fill-divider-soild-alt'
/>
</>
)
}
{
index > 0 && (
<path <path
d='M0,18 L24,18' d={`M0,18 Q12,18 12,28 L12,${space - 10 + 2} Q12,${space + 2} 24,${space + 2}`}
strokeWidth={1} strokeWidth={1}
fill='none' fill='none'
className='stroke-divider-soild' className='stroke-divider-soild'
/> />
<rect )
x={0} }
y={16} <rect
width={1} x={23}
height={4} y={space}
className='fill-divider-soild-alt' width={1}
/> height={4}
</> className='fill-divider-soild-alt'
) />
} </g>
{ )
index > 0 && ( })
<path
d={`M0,18 Q12,18 12,28 L12,${index * 48 + 18 - 10} Q12,${index * 48 + 18} 24,${index * 48 + 18}`}
strokeWidth={1}
fill='none'
className='stroke-divider-soild'
/>
)
}
<rect
x={23}
y={index * 48 + 18 - 2}
width={1}
height={4}
className='fill-divider-soild-alt'
/>
</g>
))
} }
</svg> </svg>
) )

View File

@ -0,0 +1,129 @@
import {
useCallback,
} from 'react'
import { useTranslation } from 'react-i18next'
import { RiMoreFill } from '@remixicon/react'
import { intersection } from 'lodash-es'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Button from '@/app/components/base/button'
import BlockSelector from '@/app/components/workflow/block-selector'
import {
useAvailableBlocks,
useNodesInteractions,
} from '@/app/components/workflow/hooks'
import type {
CommonNodeType,
OnSelectBlock,
} from '@/app/components/workflow/types'
type ChangeItemProps = {
data: CommonNodeType
nodeId: string
sourceHandle: string
}
const ChangeItem = ({
data,
nodeId,
sourceHandle,
}: ChangeItemProps) => {
const { t } = useTranslation()
const { handleNodeChange } = useNodesInteractions()
const {
availablePrevBlocks,
availableNextBlocks,
} = useAvailableBlocks(data.type, data.isInIteration)
const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => {
handleNodeChange(nodeId, type, sourceHandle, toolDefaultValue)
}, [nodeId, sourceHandle, handleNodeChange])
const renderTrigger = useCallback(() => {
return (
<div className='flex items-center px-2 h-8 rounded-lg cursor-pointer hover:bg-state-base-hover'>
{t('workflow.panel.change')}
</div>
)
}, [t])
return (
<BlockSelector
onSelect={handleSelect}
placement='top-end'
offset={{
mainAxis: 6,
crossAxis: 8,
}}
trigger={renderTrigger}
popupClassName='!w-[328px]'
availableBlocksTypes={intersection(availablePrevBlocks, availableNextBlocks).filter(item => item !== data.type)}
/>
)
}
type OperatorProps = {
open: boolean
onOpenChange: (v: boolean) => void
data: CommonNodeType
nodeId: string
sourceHandle: string
}
const Operator = ({
open,
onOpenChange,
data,
nodeId,
sourceHandle,
}: OperatorProps) => {
const { t } = useTranslation()
const {
handleNodeDelete,
handleNodeDisconnect,
} = useNodesInteractions()
return (
<PortalToFollowElem
placement='bottom-end'
offset={{ mainAxis: 4, crossAxis: -4 }}
open={open}
onOpenChange={onOpenChange}
>
<PortalToFollowElemTrigger onClick={() => onOpenChange(!open)}>
<Button className='p-0 w-6 h-6'>
<RiMoreFill className='w-4 h-4' />
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-10'>
<div className='min-w-[120px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg system-md-regular text-text-secondary'>
<div className='p-1'>
<ChangeItem
data={data}
nodeId={nodeId}
sourceHandle={sourceHandle}
/>
<div
className='flex items-center px-2 h-8 rounded-lg cursor-pointer hover:bg-state-base-hover'
onClick={() => handleNodeDisconnect(nodeId)}
>
{t('workflow.common.disconnect')}
</div>
</div>
<div className='p-1'>
<div
className='flex items-center px-2 h-8 rounded-lg cursor-pointer hover:bg-state-base-hover'
onClick={() => handleNodeDelete(nodeId)}
>
{t('common.operation.delete')}
</div>
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default Operator

View File

@ -748,7 +748,5 @@ export const getParallelInfo = (nodes: Node[], edges: Edge[], parentNodeId?: str
traverse(nodeHandle) traverse(nodeHandle)
} }
console.log(parallelList, 'parallelList')
return parallelList return parallelList
} }

View File

@ -90,6 +90,9 @@ const translation = {
limit: 'Parallelism is limited to {{num}} branches.', limit: 'Parallelism is limited to {{num}} branches.',
depthLimit: 'Parallel nesting layer limit of {{num}} layers', depthLimit: 'Parallel nesting layer limit of {{num}} layers',
}, },
disconnect: 'Disconnect',
jumpToNode: 'Jump to this node',
addParallelNode: 'Add Parallel Node',
}, },
env: { env: {
envPanelTitle: 'Environment Variables', envPanelTitle: 'Environment Variables',

View File

@ -90,6 +90,9 @@ const translation = {
limit: '并行分支限制为 {{num}} 个', limit: '并行分支限制为 {{num}} 个',
depthLimit: '并行嵌套层数限制 {{num}} 层', depthLimit: '并行嵌套层数限制 {{num}} 层',
}, },
disconnect: '断开连接',
jumpToNode: '跳转到节点',
addParallelNode: '添加并行节点',
}, },
env: { env: {
envPanelTitle: '环境变量', envPanelTitle: '环境变量',