export type WorkflowNodePosition = { x: number y: number } export type WorkflowHelperLineNode = { id: string position: WorkflowNodePosition width?: number | null height?: number | null measured?: { width?: number | null height?: number | null } } export type WorkflowHelperLine = { horizontal?: { y: number left: number width: number } vertical?: { x: number top: number height: number } } export type WorkflowHelperLineResult = WorkflowHelperLine & { position: WorkflowNodePosition } export type WorkflowEditorNode = { id: string type?: string position: WorkflowNodePosition data?: { nodeType?: string name?: string label?: string config?: Record inputs?: Record } } export type WorkflowEditorEdge = { id: string source: string target: string data?: { condition?: { expression?: string left?: WorkflowVariableSelector operator?: string right?: unknown } } } export type WorkflowDraft = { nodes: WorkflowEditorNode[] edges: WorkflowEditorEdge[] } export type WorkflowDefinition = { schemaVersion: number entryNodeId: string nodes: { id: string type: string name: string position: WorkflowNodePosition config: Record inputs?: Record }[] edges: { id: string source: string target: string condition?: { expression?: string left?: WorkflowVariableSelector operator?: string right?: unknown } }[] } export type WorkflowVariableType = | "string" | "integer" | "boolean" | "object" | "array" | "array" | "array" | "any" export type WorkflowVariableSelector = { nodeId: string field: string } export type WorkflowVariableSpec = { name: string type: WorkflowVariableType required?: boolean description?: string } export type WorkflowNodeSpec = { type: string title?: string description?: string inputSchema?: WorkflowVariableSpec[] outputSchema?: WorkflowVariableSpec[] defaultInputs?: Record } export type WorkflowVariableRef = { nodeId: string nodeName: string field: string type: string description: string } export type WorkflowDraftValidation = { valid: boolean errors: string[] } export type WorkflowHistory = { past: T[] future: T[] limit: number } export type WorkflowHistoryChange = { history: WorkflowHistory snapshot: T } const helperLineAlignmentThreshold = 6 const defaultWorkflowHistoryLimit = 50 function cloneHistorySnapshot(snapshot: T): T { return JSON.parse(JSON.stringify(snapshot)) as T } export function createWorkflowHistory(limit = defaultWorkflowHistoryLimit): WorkflowHistory { return { past: [], future: [], limit, } } export function pushWorkflowHistory( history: WorkflowHistory, snapshot: T ): WorkflowHistory { const past = [...history.past, cloneHistorySnapshot(snapshot)] return { past: past.slice(Math.max(0, past.length - history.limit)), future: [], limit: history.limit, } } export function undoWorkflowHistory( history: WorkflowHistory, current: T ): WorkflowHistoryChange | null { const snapshot = history.past.at(-1) if (!snapshot) { return null } return { snapshot: cloneHistorySnapshot(snapshot), history: { past: history.past.slice(0, -1), future: [cloneHistorySnapshot(current), ...history.future], limit: history.limit, }, } } export function redoWorkflowHistory( history: WorkflowHistory, current: T ): WorkflowHistoryChange | null { const snapshot = history.future[0] if (!snapshot) { return null } const past = [...history.past, cloneHistorySnapshot(current)] return { snapshot: cloneHistorySnapshot(snapshot), history: { past: past.slice(Math.max(0, past.length - history.limit)), future: history.future.slice(1), limit: history.limit, }, } } function getNodeSize(node: WorkflowHelperLineNode) { return { width: node.measured?.width ?? node.width ?? 0, height: node.measured?.height ?? node.height ?? 0, } } function getNodeAnchorValues(node: WorkflowHelperLineNode) { const size = getNodeSize(node) return { x: [ node.position.x, node.position.x + size.width / 2, node.position.x + size.width, ], y: [ node.position.y, node.position.y + size.height / 2, node.position.y + size.height, ], } } function getNearestAlignment( axis: "x" | "y", nodes: WorkflowHelperLineNode[], draggingNode: WorkflowHelperLineNode ) { const draggingAnchors = getNodeAnchorValues(draggingNode)[axis] let nearest: | { diff: number targetValue: number candidate: WorkflowHelperLineNode } | undefined for (const candidate of nodes) { if (candidate.id === draggingNode.id) { continue } const candidateAnchors = getNodeAnchorValues(candidate)[axis] for (const draggingAnchor of draggingAnchors) { for (const candidateAnchor of candidateAnchors) { const diff = candidateAnchor - draggingAnchor if (Math.abs(diff) > helperLineAlignmentThreshold) { continue } if (!nearest || Math.abs(diff) < Math.abs(nearest.diff)) { nearest = { diff, targetValue: candidateAnchor, candidate, } } } } } return nearest } export function calculateWorkflowHelperLines( nodes: WorkflowHelperLineNode[], draggingNode: WorkflowHelperLineNode ): WorkflowHelperLineResult { const xAlignment = getNearestAlignment("x", nodes, draggingNode) const yAlignment = getNearestAlignment("y", nodes, draggingNode) const position = { x: draggingNode.position.x + (xAlignment?.diff ?? 0), y: draggingNode.position.y + (yAlignment?.diff ?? 0), } const draggingSize = getNodeSize(draggingNode) const snappedDraggingNode = { ...draggingNode, position, } const result: WorkflowHelperLineResult = { position, } if (yAlignment) { const candidateSize = getNodeSize(yAlignment.candidate) const left = Math.min(snappedDraggingNode.position.x, yAlignment.candidate.position.x) const right = Math.max( snappedDraggingNode.position.x + draggingSize.width, yAlignment.candidate.position.x + candidateSize.width ) result.horizontal = { y: yAlignment.targetValue, left, width: right - left, } } if (xAlignment) { const candidateSize = getNodeSize(xAlignment.candidate) const top = Math.min(snappedDraggingNode.position.y, xAlignment.candidate.position.y) const bottom = Math.max( snappedDraggingNode.position.y + draggingSize.height, xAlignment.candidate.position.y + candidateSize.height ) result.vertical = { x: xAlignment.targetValue, top, height: bottom - top, } } return result } export function validateWorkflowDraft( draft: WorkflowDraft, nodeSpecs: WorkflowNodeSpec[] = [] ): WorkflowDraftValidation { const errors: string[] = [] const nodeIds = new Set() let startCount = 0 let endCount = 0 for (const node of draft.nodes) { const id = node.id.trim() if (!id) { errors.push("node id is required") continue } if (nodeIds.has(id)) { errors.push(`duplicate node id: ${id}`) } nodeIds.add(id) const nodeType = node.data?.nodeType ?? node.type if (nodeType === "start") { startCount += 1 } if (nodeType === "end") { endCount += 1 } } if (startCount !== 1) { errors.push("workflow must contain exactly one start node") } if (endCount < 1) { errors.push("workflow must contain at least one end node") } const edgeIds = new Set() const conditionalSources = new Set() const defaultSources = new Set() for (const edge of draft.edges) { const id = edge.id.trim() if (!id) { errors.push("edge id is required") } else if (edgeIds.has(id)) { errors.push(`duplicate edge id: ${id}`) } edgeIds.add(id) if (!nodeIds.has(edge.source)) { errors.push(`edge source node does not exist: ${edge.source}`) } if (!nodeIds.has(edge.target)) { errors.push(`edge target node does not exist: ${edge.target}`) } if (edge.data?.condition) { conditionalSources.add(edge.source) if (!edge.data.condition.left?.nodeId || !edge.data.condition.left.field) { errors.push(`edge ${edge.id} condition left variable is required`) } if (!edge.data.condition.operator) { errors.push(`edge ${edge.id} condition operator is required`) } } else { defaultSources.add(edge.source) } } for (const source of conditionalSources) { if (!defaultSources.has(source)) { errors.push(`node ${source} conditional branch must include a default edge`) } } for (const node of draft.nodes) { const nodeType = node.data?.nodeType ?? node.type ?? "" const spec = getNodeSpec(nodeSpecs, nodeType) if (!spec) { continue } for (const input of getRequiredInputs(spec)) { const selector = node.data?.inputs?.[input.name] if (!selector?.nodeId || !selector.field) { const nodeName = node.data?.name ?? spec.title ?? node.id errors.push(`${nodeName} 缺少必填输入「${input.name}」,请选择上游节点的输出变量。`) } } } return { valid: errors.length === 0, errors, } } export function toApiDefinition(draft: WorkflowDraft): WorkflowDefinition { const startNode = draft.nodes.find((node) => (node.data?.nodeType ?? node.type) === "start") return { schemaVersion: 1, entryNodeId: startNode?.id ?? "", nodes: draft.nodes.map((node) => ({ id: node.id, type: node.data?.nodeType ?? node.type ?? "", name: node.data?.name ?? node.type ?? node.id, position: { x: node.position.x, y: node.position.y, }, config: node.data?.config ?? {}, ...(node.data?.inputs ? { inputs: node.data.inputs } : {}), })), edges: draft.edges.map((edge) => ({ id: edge.id, source: edge.source, target: edge.target, ...(edge.data?.condition ? { condition: { ...(edge.data.condition.expression ? { expression: edge.data.condition.expression } : {}), ...(edge.data.condition.left ? { left: edge.data.condition.left } : {}), ...(edge.data.condition.operator ? { operator: edge.data.condition.operator } : {}), ...(edge.data.condition.right !== undefined ? { right: edge.data.condition.right } : {}), }, } : {}), })), } } export function fromApiDefinition(definition: WorkflowDefinition): WorkflowDraft { return { nodes: (definition.nodes ?? []).map((node) => ({ id: node.id, type: node.type, position: node.position ?? { x: 0, y: 0 }, data: { nodeType: node.type, name: node.name, config: node.config ?? {}, inputs: node.inputs ?? {}, }, })), edges: (definition.edges ?? []).map((edge) => ({ id: edge.id, source: edge.source, target: edge.target, data: edge.condition ? { condition: edge.condition } : undefined, })), } } export function applyAutoInputMappings( draft: WorkflowDraft, sourceNodeId: string, targetNodeId: string, nodeSpecs: WorkflowNodeSpec[] ): WorkflowDraft { const sourceNode = draft.nodes.find((node) => node.id === sourceNodeId) const targetNode = draft.nodes.find((node) => node.id === targetNodeId) if (!sourceNode || !targetNode) { return draft } const sourceSpec = getNodeSpec(nodeSpecs, sourceNode.data?.nodeType ?? sourceNode.type ?? "") const targetSpec = getNodeSpec(nodeSpecs, targetNode.data?.nodeType ?? targetNode.type ?? "") if (!sourceSpec || !targetSpec) { return draft } const nextInputs = { ...(targetNode.data?.inputs ?? {}) } let changed = false for (const input of targetSpec.inputSchema ?? []) { if (nextInputs[input.name]) { continue } const output = findPreferredOutput(input.name, input.type, sourceSpec.outputSchema ?? []) if (!output) { continue } nextInputs[input.name] = { nodeId: sourceNodeId, field: output.name } changed = true } if (!changed) { return draft } return { ...draft, nodes: draft.nodes.map((node) => node.id === targetNodeId ? { ...node, data: { ...node.data, inputs: nextInputs, }, } : node ), } } export function createWorkflowNodeFromSpec( spec: WorkflowNodeSpec, existingNodes: Pick[], position: WorkflowNodePosition ): WorkflowEditorNode { const id = uniqueNodeId(existingNodes, spec.type) return { id, type: "workflowNode", position, data: { nodeType: spec.type, name: spec.title ?? spec.type, label: spec.title ?? spec.type, config: {}, inputs: spec.defaultInputs ?? {}, }, } } function uniqueNodeId(existingNodes: Pick[], nodeType: string) { let nextIndex = existingNodes.length + 1 let id = `${nodeType}_${nextIndex}` while (existingNodes.some((node) => node.id === id)) { nextIndex += 1 id = `${nodeType}_${nextIndex}` } return id } function findPreferredOutput( inputName: string, inputType: WorkflowVariableType, outputs: WorkflowVariableSpec[] ): WorkflowVariableSpec | undefined { const preferred = preferredOutputName(inputName) if (preferred) { const exact = outputs.find((output) => output.name === preferred && variableTypesCompatible(inputType, output.type)) if (exact) { return exact } } const sameName = outputs.find((output) => output.name === inputName && variableTypesCompatible(inputType, output.type)) if (sameName) { return sameName } return outputs.find((output) => variableTypesCompatible(inputType, output.type)) } function preferredOutputName(inputName: string): string { switch (inputName) { case "query": case "userMessage": case "issue": case "prompt": return "userMessage" case "knowledgeItems": return "items" case "replyText": return "replyText" case "confirmed": return "confirmed" case "ticketDraft": return "ticketDraft" case "reason": return "reason" default: return "" } } function variableTypesCompatible(input: WorkflowVariableType, output: WorkflowVariableType): boolean { return input === "any" || output === "any" || input === output } export function getNodeSpec( nodeSpecs: WorkflowNodeSpec[], nodeType: string ): WorkflowNodeSpec | undefined { return nodeSpecs.find((spec) => spec.type === nodeType) } export function getRequiredInputs(spec: WorkflowNodeSpec | undefined): WorkflowVariableSpec[] { return (spec?.inputSchema ?? []).filter((item) => item.required) } export function getAvailableVariables( draft: WorkflowDraft, nodeId: string, nodeSpecs: WorkflowNodeSpec[] ): WorkflowVariableRef[] { const ancestors = collectAncestorNodeIds(draft, nodeId) const nodesById = new Map(draft.nodes.map((node) => [node.id, node])) const variables: WorkflowVariableRef[] = [] for (const sourceNodeId of ancestors) { const sourceNode = nodesById.get(sourceNodeId) if (!sourceNode) { continue } const nodeType = sourceNode.data?.nodeType ?? sourceNode.type ?? "" const spec = getNodeSpec(nodeSpecs, nodeType) for (const output of spec?.outputSchema ?? []) { variables.push({ nodeId: sourceNode.id, nodeName: sourceNode.data?.name ?? spec?.title ?? sourceNode.id, field: output.name, type: output.type, description: output.description ?? "", }) } } return variables } function collectAncestorNodeIds(draft: WorkflowDraft, nodeId: string): string[] { const incoming = new Map() for (const edge of draft.edges) { const sources = incoming.get(edge.target) ?? [] sources.push(edge.source) incoming.set(edge.target, sources) } const visited = new Set() const ordered: string[] = [] function visit(current: string) { for (const source of incoming.get(current) ?? []) { if (visited.has(source)) { continue } visited.add(source) visit(source) ordered.push(source) } } visit(nodeId) return ordered }