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', desc: '原图坐标 [x1,y1,x2,y2]', children: [], disabled: true }, { name: 'center', type: 'array', 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', 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) }) }) 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 = [] 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 } } 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 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') 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 || '任意设备' 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 }