CMVR-BID/src/utils/flowResources.js
zhanghao d733ea8525 feat: 增强流资源管理和节点执行组件
- 增加了检测流资源中引用循环的功能,以防止循环依赖。
- 改进了流引用的验证,以确保节点输出中不存在循环依赖。
- 增强了资源定义清理功能,以便在执行前移除过时的设备ID。
- 已实现复制资源角色的合并,以优化现有资源的优先级并避免重复。
- 更新了LoopIterationResults组件,以管理运行中迭代的自动扩展。
- 增强了NodeState和NodeTitle组件,以支持循环和子流结果的更宽弹出框显示。
- 优化了ParamsDrawer和SubFlowExecutionResults组件,以处理动态参数更新和执行结果。
- 改进了TaskExecutionLog,以统一输出处理并增强用户对日志的可见性。
- 更新了各种参数组件,以简化输入处理并提升用户体验。
- 在代码中添加了全面的注释和文档,以提高可维护性和易理解性。
2026-08-21 15:01:16 +08:00

518 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { getDeviceKindForAction } from '@/utils/robotDevice'
export const FLOW_SCHEMA_VERSION = 2
export const DEFAULT_ROLE_KEY = 'default_executor'
export const FLOW_NODE_RESOURCE_OWNER = 'FLOW_NODE'
export const USER_RESOURCE_OWNER = 'USER'
// 图片分析节点的输出契约会被节点配置、参数面板和旧流程迁移复用,集中定义避免字段漂移。
export const IMAGE_ANALYSIS_OUTPUT_PARAMS = Object.freeze([
{ name: 'result', type: 'object', desc: '按提示词返回的结果', children: [], disabled: true },
{ name: 'resultText', type: 'string', desc: '模型返回的原始文本', disabled: true },
{ name: 'matched', type: 'boolean', desc: '找图是否匹配', disabled: true },
{ name: 'matchScore', type: 'number', desc: '匹配分数或定位置信度', disabled: true },
{ name: 'coordinates', type: 'array<number>', desc: '原图坐标 [x1,y1,x2,y2]', children: [], disabled: true },
{ name: 'center', type: 'array<number>', desc: '目标中心点 [x,y]', children: [], disabled: true },
{ name: 'matchedDescription', type: 'string', desc: '模型实际定位的目标描述', disabled: true },
{ name: 'refinementMethod', type: 'string', desc: '坐标精修方式', disabled: true },
{ name: 'matchCount', type: 'number', desc: '匹配项数量', disabled: true },
{ name: 'matches', type: 'array<object>', desc: '全部匹配项', children: [], disabled: true },
{ name: 'selectedMatchIndex', type: 'number', desc: '当前选择的匹配序号', disabled: true },
])
const DEVICE_KIND_META = Object.freeze({
1: { key: 'agv', name: '移动底盘' },
2: { key: 'arm', name: '机械臂' },
4: { key: 'bio_head', name: '仿生头' },
5: { key: 'camera', name: '摄像头' },
9: { key: 'microphone', name: '麦克风' },
12: { key: 'robot', name: '机器人本体' },
13: { key: 'speaker', name: '扬声器' },
})
const clone = (value) => JSON.parse(JSON.stringify(value ?? null))
const normalizedText = (value) => String(value || '').trim()
const LEGACY_NODE_TYPE_ALIASES = Object.freeze({
device_universal: 'deviceUniversal',
})
export const createResourceKey = (prefix) => {
const random = globalThis.crypto?.randomUUID?.().replace(/-/g, '').slice(0, 10)
|| `${Date.now()}${Math.random().toString(16).slice(2, 8)}`
return `${prefix}_${random}`
}
export const createDefaultRobotRole = (legacyRobotId = '') => ({
roleKey: DEFAULT_ROLE_KEY,
resourceType: 'ROBOT',
displayName: '执行机器人',
required: true,
quantity: { min: 1, max: 1 },
deviceSlots: [],
...(legacyRobotId ? { legacyRobotId } : {}),
})
export const createDeviceSlot = (deviceKind, existingSlots = [], displayName = '') => {
const meta = DEVICE_KIND_META[Number(deviceKind)] || { key: 'device', name: '执行设备' }
const usedKeys = new Set(existingSlots.map((slot) => slot.slotKey))
let index = 1
let slotKey = meta.key
while (usedKeys.has(slotKey)) slotKey = `${meta.key}_${++index}`
const sameKindCount = existingSlots.filter((slot) => Number(slot.deviceKind) === Number(deviceKind)).length
return {
slotKey,
displayName: displayName || `${meta.name}${sameKindCount ? sameKindCount + 1 : ''}`,
deviceKind: deviceKind == null ? null : Number(deviceKind),
required: true,
allowReuse: false,
}
}
export const getStartNode = (graph = globalThis.lf?.getGraphData?.()) => (
graph?.nodes?.find((node) => node.type === 'start')
)
export const getResourceRoles = (graph = globalThis.lf?.getGraphData?.()) => (
clone(getStartNode(graph)?.properties?.resourceRoles || []) || []
)
const isRobotNode = (node) => node?.properties?.nodeType === 'EDGE'
export const ACTION_QUEUE_ACTION = 'SYSTEM_EXECUTE_ACTION_QUEUE'
export const actionRequiresDevice = (action) => (
getDeviceKindForAction(action) != null || action === 'DEVICE_EXECUTE_JSON_COMMAND'
)
export const nodeRequiresDevice = (node) => actionRequiresDevice(node?.properties?.action)
const resolveLegacyRobotId = (startProperties) => (
startProperties?.inputParams?.find((item) => item.name === 'robotId')?.input
|| startProperties?.robotId
|| ''
)
const findOrCreateLegacySlot = (role, node, legacySlotByIdentity, legacyDeviceId = '') => {
const kind = getDeviceKindForAction(node.properties?.action)
const normalizedDeviceId = normalizedText(legacyDeviceId)
const identity = `${role.roleKey}:${kind ?? 'any'}:${normalizedDeviceId || 'default'}`
if (legacySlotByIdentity.has(identity)) return legacySlotByIdentity.get(identity)
const slot = createDeviceSlot(kind, role.deviceSlots)
slot.managedBy = FLOW_NODE_RESOURCE_OWNER
if (normalizedDeviceId) slot.legacyDeviceId = normalizedDeviceId
role.deviceSlots.push(slot)
legacySlotByIdentity.set(identity, slot)
return slot
}
/**
* Loads old graphs as Schema V2 without mutating the source object. Physical IDs are
* retained only as migration hints and are consumed by the trial-run assignment form.
*/
export const normalizeFlowResourceSchema = (sourceGraph) => {
const graph = clone(sourceGraph || { nodes: [], edges: [] })
graph.nodes = Array.isArray(graph.nodes) ? graph.nodes : []
graph.edges = Array.isArray(graph.edges) ? graph.edges : []
graph.nodes.forEach((node) => {
node.type = LEGACY_NODE_TYPE_ALIASES[node.type] || node.type
// 早期图片分析图数据可能只保存节点类型,加载时补齐运行端识别所需的协议字段。
const properties = node.properties || (node.properties = {})
if (['AGV_MOVE_TO_POINT', 'AGV_MOVE_TO_STATION'].includes(properties.action)) {
properties.nodeParams = Array.isArray(properties.nodeParams) ? properties.nodeParams : []
if (!properties.nodeParams.some((param) => param?.name === 'blockedTimeoutMs')) {
properties.nodeParams.push({
name: 'blockedTimeoutMs',
type: 'input',
componentType: 'number',
input: 60000,
min: 1,
required: true,
disabled: true,
})
}
}
if (node.type === 'imageAnalysis') {
const properties = node.properties || (node.properties = {})
properties.action = 'IMAGE_ANALYZE'
properties.nodeType = 'LLM'
properties.outputType = 'json'
properties.outputParams = clone(IMAGE_ANALYSIS_OUTPUT_PARAMS)
}
// 视频分析新增 result/detailedOutput 后,打开旧流程时补齐输出字段,后续节点才能稳定引用。
if (node.properties?.action === 'VIDEO_ANALYZE') {
const outputParams = Array.isArray(node.properties.outputParams)
? node.properties.outputParams
: []
if (!outputParams.some((param) => param?.name === 'result')) {
outputParams.unshift({ name: 'result', type: 'string', desc: '分析结果', disabled: true })
}
if (!outputParams.some((param) => param?.name === 'detailedOutput')) {
outputParams.push({
name: 'detailedOutput', type: 'boolean', desc: '是否返回详细事件证据', disabled: true,
})
}
node.properties.outputParams = outputParams
}
})
const start = getStartNode(graph)
if (!start) return graph
const startProperties = start.properties || (start.properties = {})
const legacyRobotId = resolveLegacyRobotId(startProperties)
const roles = Array.isArray(startProperties.resourceRoles) && startProperties.resourceRoles.length
? startProperties.resourceRoles
: [createDefaultRobotRole(legacyRobotId)]
roles.forEach((role, index) => {
role.roleKey ||= index === 0 ? DEFAULT_ROLE_KEY : createResourceKey('robot_role')
role.resourceType = 'ROBOT'
role.displayName ||= `机器人角色${index + 1}`
role.required = role.required !== false
role.quantity ||= { min: 1, max: 1 }
role.deviceSlots = Array.isArray(role.deviceSlots) ? role.deviceSlots : []
})
startProperties.schemaVersion = FLOW_SCHEMA_VERSION
startProperties.resourceRoles = roles
startProperties.inputParams = (startProperties.inputParams || []).filter((item) => item.name !== 'robotId')
const defaultRole = roles[0]
const legacySlotByIdentity = new Map()
roles.forEach((role) => role.deviceSlots.forEach((slot) => {
const identity = `${role.roleKey}:${slot.deviceKind ?? 'any'}:${slot.legacyDeviceId || 'default'}`
legacySlotByIdentity.set(identity, slot)
}))
graph.nodes.forEach((node) => {
const properties = node.properties || (node.properties = {})
if (!isRobotNode(node)) {
delete properties.executionTarget
return
}
const target = properties.executionTarget || {
roleKey: defaultRole.roleKey,
dispatchPolicy: 'ONE',
}
target.roleKey ||= defaultRole.roleKey
target.dispatchPolicy ||= 'ONE'
const isActionQueue = properties.action === ACTION_QUEUE_ACTION
if (!isActionQueue) target.deviceSlotKey ||= target.deviceBindings?.[0]?.slotKey || ''
target.deviceBindings = Array.isArray(target.deviceBindings) ? target.deviceBindings : []
const targetRole = roles.find((role) => role.roleKey === target.roleKey) || defaultRole
const deviceParam = properties.nodeParams?.find((param) => param.name === 'deviceId')
if (nodeRequiresDevice(node) && !target.deviceSlotKey) {
const slot = findOrCreateLegacySlot(targetRole, node, legacySlotByIdentity, deviceParam?.input)
if (slot) target.deviceSlotKey = slot.slotKey
}
const referencedSlotKeys = new Set([
target.deviceSlotKey,
...target.deviceBindings.map((binding) => binding?.slotKey),
].filter(Boolean))
targetRole.deviceSlots.forEach((slot) => {
if (referencedSlotKeys.has(slot.slotKey) && !slot.managedBy) slot.managedBy = FLOW_NODE_RESOURCE_OWNER
})
if (!isActionQueue) delete target.deviceBindings
if (isActionQueue) delete target.deviceSlotKey
properties.nodeParams = (properties.nodeParams || []).filter((param) => param.name !== 'deviceId')
properties.executionTarget = target
})
return graph
}
export const ensureNodeExecutionResources = (sourceGraph, nodeId) => {
const graph = normalizeFlowResourceSchema(sourceGraph)
const node = graph.nodes.find((item) => item.id === nodeId)
return { graph, node, start: getStartNode(graph) }
}
/**
* Removes role definitions and device usages that were registered automatically
* for flow nodes and are no longer referenced by any remaining node.
*/
export const pruneUnusedNodeResources = (sourceGraph) => {
const graph = clone(sourceGraph || { nodes: [], edges: [] })
const start = getStartNode(graph)
if (!start?.properties?.resourceRoles) {
return { graph, start, removedRoles: 0, removedSlots: 0 }
}
const usedRoleKeys = new Set()
const usedSlotsByRole = new Map()
graph.nodes.filter(isRobotNode).forEach((node) => {
const target = node.properties?.executionTarget
if (!target?.roleKey) return
usedRoleKeys.add(target.roleKey)
if (!usedSlotsByRole.has(target.roleKey)) usedSlotsByRole.set(target.roleKey, new Set())
if (target.deviceSlotKey) usedSlotsByRole.get(target.roleKey).add(target.deviceSlotKey)
;(target.deviceBindings || []).forEach((binding) => {
if (binding?.slotKey) usedSlotsByRole.get(target.roleKey).add(binding.slotKey)
})
})
graph.nodes.filter((node) => node?.properties?.action === 'SUB_FLOW').forEach((node) => {
;(node.properties?.subFlow?.resourceRoles || []).forEach((role) => {
if (!role?.roleKey) return
usedRoleKeys.add(role.roleKey)
if (!usedSlotsByRole.has(role.roleKey)) usedSlotsByRole.set(role.roleKey, new Set())
;(role.deviceSlots || []).forEach((slot) => {
if (slot?.slotKey) usedSlotsByRole.get(role.roleKey).add(slot.slotKey)
})
})
})
let removedSlots = 0
start.properties.resourceRoles.forEach((role) => {
const usedSlotKeys = usedSlotsByRole.get(role.roleKey) || new Set()
role.deviceSlots = (role.deviceSlots || []).filter((slot) => {
const shouldRemove = slot.managedBy === FLOW_NODE_RESOURCE_OWNER && !usedSlotKeys.has(slot.slotKey)
if (shouldRemove) removedSlots += 1
return !shouldRemove
})
})
const beforeRoleCount = start.properties.resourceRoles.length
start.properties.resourceRoles = start.properties.resourceRoles.filter((role, index) => (
index === 0
|| role.managedBy !== FLOW_NODE_RESOURCE_OWNER
|| usedRoleKeys.has(role.roleKey)
))
return {
graph,
start,
removedRoles: beforeRoleCount - start.properties.resourceRoles.length,
removedSlots,
}
}
export const validateFlowResources = (sourceGraph) => {
const graph = normalizeFlowResourceSchema(sourceGraph)
const roles = getResourceRoles(graph)
const errors = validateFlowReferences(graph)
const roleKeys = new Set()
roles.forEach((role) => {
if (!normalizedText(role.displayName)) errors.push({ nodeId: getStartNode(graph)?.id, message: '机器人角色名称不能为空' })
if (roleKeys.has(role.roleKey)) errors.push({ nodeId: getStartNode(graph)?.id, message: `机器人角色标识重复:${role.roleKey}` })
roleKeys.add(role.roleKey)
const slotKeys = new Set()
;(role.deviceSlots || []).forEach((slot) => {
if (!normalizedText(slot.displayName)) errors.push({ nodeId: getStartNode(graph)?.id, message: `${role.displayName}存在未命名的设备用途` })
if (slotKeys.has(slot.slotKey)) errors.push({ nodeId: getStartNode(graph)?.id, message: `${role.displayName}的设备用途标识重复` })
slotKeys.add(slot.slotKey)
})
})
graph.nodes.filter(isRobotNode).forEach((node) => {
const target = node.properties?.executionTarget
const role = roles.find((item) => item.roleKey === target?.roleKey)
if (!role) {
errors.push({ nodeId: node.id, message: `${node.properties?.name || node.id}未选择有效的机器人角色` })
return
}
if (target.deviceSlotKey && !(role.deviceSlots || []).some((slot) => slot.slotKey === target.deviceSlotKey)) {
errors.push({ nodeId: node.id, message: `${node.properties?.name || node.id}引用的设备用途不存在` })
}
;(target.deviceBindings || []).forEach((binding) => {
if (!binding?.parameterName || !binding?.slotKey) {
errors.push({ nodeId: node.id, message: `${node.properties?.name || node.id}存在未配置的队列设备` })
} else if (!(role.deviceSlots || []).some((slot) => slot.slotKey === binding.slotKey)) {
errors.push({ nodeId: node.id, message: `${node.properties?.name || node.id}引用的队列设备用途不存在` })
}
})
if (nodeRequiresDevice(node)
&& !target.deviceSlotKey) {
errors.push({ nodeId: node.id, message: `${node.properties?.name || node.id}未选择设备用途` })
}
})
return { graph, errors }
}
const collectActiveQuotes = (value, result = []) => {
if (Array.isArray(value)) {
value.forEach((item) => collectActiveQuotes(item, result))
return result
}
if (!value || typeof value !== 'object') return result
if (value.type === 'quote' && Array.isArray(value.quote) && value.quote.length >= 3) {
result.push(value.quote)
}
if (value.nameType === 'quote' && Array.isArray(value.nameQuote) && value.nameQuote.length >= 3) {
result.push(value.nameQuote)
}
Object.values(value).forEach((item) => collectActiveQuotes(item, result))
return result
}
/**
* 对节点引用依赖做深度优先遍历,发现 A 引用 B、B 又回到 A 的循环链路。
* states: 1 表示当前递归栈中,2 表示已确认无环;命中 1 时即可截取循环路径。
*/
const findReferenceCycle = (dependencies) => {
const states = new Map()
const stack = []
const visit = (nodeId) => {
if (states.get(nodeId) === 2) return null
if (states.get(nodeId) === 1) {
const index = stack.indexOf(nodeId)
return [...stack.slice(Math.max(0, index)), nodeId]
}
states.set(nodeId, 1)
stack.push(nodeId)
for (const sourceId of dependencies.get(nodeId) || []) {
if (!dependencies.has(sourceId)) continue
const cycle = visit(sourceId)
if (cycle) return cycle
}
stack.pop()
states.set(nodeId, 2)
return null
}
for (const nodeId of dependencies.keys()) {
const cycle = visit(nodeId)
if (cycle) return cycle
}
return null
}
/**
* 校验已保存的引用配置和参数面板刚选择的引用配置。
* 当前只阻断循环引用,避免节点输出依赖后续又间接依赖自身,导致运行端无法拓扑执行。
*/
export const validateFlowReferences = (graph) => {
const nodes = Array.isArray(graph?.nodes) ? graph.nodes : []
const nodeById = new Map(nodes.map((node) => [String(node.id), node]))
const referencesByNode = new Map()
const dependencies = new Map()
nodes.forEach((node) => {
const references = collectActiveQuotes(node.properties || [])
.map((quote) => quote.map((part) => String(part)))
referencesByNode.set(String(node.id), references)
dependencies.set(String(node.id), new Set(references.map((quote) => quote[0])))
})
const cycle = findReferenceCycle(dependencies)
if (cycle) {
const names = cycle.map((nodeId) => nodeById.get(nodeId)?.properties?.name || nodeId)
return [{
nodeId: cycle[0],
message: `节点参数存在循环引用:${names.join(' -> ')}`,
}]
}
return []
}
/**
* 保存或发布前移除旧版物理机器人/设备 ID,只保留资源角色和设备用途定义。
* 运行时会重新选择实际设备,避免把历史测试设备固化进流程模板。
*/
export const sanitizeFlowResourceDefinition = (sourceGraph) => {
const graph = normalizeFlowResourceSchema(sourceGraph)
const start = getStartNode(graph)
;(start?.properties?.resourceRoles || []).forEach((role) => {
delete role.legacyRobotId
;(role.deviceSlots || []).forEach((slot) => delete slot.legacyDeviceId)
})
graph.nodes.filter(isRobotNode).forEach((node) => {
node.properties.nodeParams = (node.properties?.nodeParams || []).filter((param) => param.name !== 'deviceId')
})
return graph
}
/**
* 粘贴跨流程节点时合并其依赖的机器人角色和设备用途。
* 优先复用同标识或同名资源;找不到时才追加由流程节点托管的资源定义。
*/
export const mergeCopiedResourceRoles = (targetGraph, sourceRoles = [], copiedNodes = []) => {
const graph = normalizeFlowResourceSchema(targetGraph)
const start = getStartNode(graph)
const targetRoles = start.properties.resourceRoles
let addedRoles = 0
let addedSlots = 0
copiedNodes.filter(isRobotNode).forEach((node) => {
const target = node.properties?.executionTarget
if (!target?.roleKey) return
const sourceRole = sourceRoles.find((role) => role.roleKey === target.roleKey)
let targetRole = targetRoles.find((role) => role.roleKey === target.roleKey)
|| targetRoles.find((role) => sourceRole && role.displayName === sourceRole.displayName)
if (!targetRole && sourceRole) {
targetRole = clone(sourceRole)
targetRole.managedBy = FLOW_NODE_RESOURCE_OWNER
delete targetRole.legacyRobotId
;(targetRole.deviceSlots || []).forEach((slot) => {
slot.managedBy = FLOW_NODE_RESOURCE_OWNER
delete slot.legacyDeviceId
})
targetRoles.push(targetRole)
addedRoles += 1
addedSlots += targetRole.deviceSlots?.length || 0
}
targetRole ||= targetRoles[0]
target.roleKey = targetRole.roleKey
// 设备用途可能只存在于来源流程,粘贴后需要映射到目标流程中可用的 slotKey。
const remapSlot = (slotKey) => {
if (!slotKey || targetRole.deviceSlots?.some((slot) => slot.slotKey === slotKey)) return slotKey
const sourceSlot = sourceRole?.deviceSlots?.find((slot) => slot.slotKey === slotKey)
const sameSlot = targetRole.deviceSlots?.find((slot) => (
sourceSlot && slot.displayName === sourceSlot.displayName && Number(slot.deviceKind) === Number(sourceSlot.deviceKind)
))
if (sameSlot) return sameSlot.slotKey
const newSlot = sourceSlot ? clone(sourceSlot) : createDeviceSlot(getDeviceKindForAction(node.properties?.action), targetRole.deviceSlots)
newSlot.managedBy = FLOW_NODE_RESOURCE_OWNER
delete newSlot.legacyDeviceId
targetRole.deviceSlots.push(newSlot)
addedSlots += 1
return newSlot.slotKey
}
target.deviceSlotKey = remapSlot(target.deviceSlotKey)
;(target.deviceBindings || []).forEach((binding) => {
binding.slotKey = remapSlot(binding.slotKey)
})
const bindingSlotByName = new Map((target.deviceBindings || []).map((binding) => (
[binding.parameterName, binding.slotKey]
)))
const queueStepsParam = node.properties?.nodeParams?.find((param) => param.name === 'steps')
// 动作队列步骤内保存的是设备绑定名,合并资源后同步回真实 slotKey,单步执行才能定位设备。
if (Array.isArray(queueStepsParam?.input)) {
queueStepsParam.input.forEach((step) => {
if (bindingSlotByName.has(step?.deviceBindingKey)) {
step.deviceSlotKey = bindingSlotByName.get(step.deviceBindingKey)
}
})
}
})
return { graph, start, addedRoles, addedSlots }
}
export const getDeviceKindName = (deviceKind) => DEVICE_KIND_META[Number(deviceKind)]?.name || '任意设备'
/**
* 汇总主流程和子流程的资源契约,生成运行前需要一次性分配的角色集合。
* 同一 roleKey 下的必选项取更严格配置,设备用途按 slotKey 合并。
*/
export const mergeResourceRoleDefinitions = (roleGroups = []) => {
const merged = []
roleGroups.flat().forEach((sourceRole) => {
let role = merged.find((item) => item.roleKey === sourceRole.roleKey)
if (!role) {
merged.push(clone(sourceRole))
return
}
role.required = role.required !== false || sourceRole.required !== false
;(sourceRole.deviceSlots || []).forEach((sourceSlot) => {
const slot = role.deviceSlots.find((item) => item.slotKey === sourceSlot.slotKey)
if (!slot) {
role.deviceSlots.push(clone(sourceSlot))
return
}
slot.required = slot.required !== false || sourceSlot.required !== false
slot.allowReuse = slot.allowReuse === true && sourceSlot.allowReuse === true
})
})
return merged
}