diff --git a/service-web/client/src/components/flow/FlowChecker.tsx b/service-web/client/src/components/flow/FlowChecker.tsx index 46a6254..18b335b 100644 --- a/service-web/client/src/components/flow/FlowChecker.tsx +++ b/service-web/client/src/components/flow/FlowChecker.tsx @@ -1,5 +1,6 @@ import {type Connection, type Edge, getOutgoers, type Node} from '@xyflow/react' -import {find, isEmpty, isEqual, lpad, toStr} from 'licia' +import {find, has, isEmpty, isEqual, lpad, toStr} from 'licia' +import NodeRegistry from './NodeRegistry.tsx' export class CheckError extends Error { readonly id: string @@ -63,6 +64,8 @@ export const checkAddConnection: (connection: Connection, nodes: Node[], edges: export const atLeastOneNode = () => new CheckError(300, '至少包含一个节点') export const hasUnfinishedNode = (nodeId: string) => new CheckError(301, `存在尚未配置完成的节点: ${nodeId}`) +export const nodeTypeNotFound = () => new CheckError(302, '节点类型不存在') +export const nodeError = (nodeId: string, reason?: string) => new CheckError(303, reason ?? `节点配置存在错误:${nodeId}`) // @ts-ignore export const checkSave: (nodes: Node[], edges: Edge[], data: any) => void = (nodes, edges, data) => { @@ -71,8 +74,21 @@ export const checkSave: (nodes: Node[], edges: Edge[], data: any) => void = (nod } for (let node of nodes) { - if (!data[node.id] || !data[node.id]?.finished) { - throw hasUnfinishedNode(node.id) + let nodeId = node.id + if (!has(data, nodeId) || !data[nodeId]?.finished) { + throw hasUnfinishedNode(nodeId) + } + + if (!has(node, 'type')) { + throw nodeTypeNotFound() + } + let nodeType = node.type! + let nodeDefine = NodeRegistry[nodeType] + for (let checker of nodeDefine.checkers) { + let checkResult = checker(nodeId, nodes, edges, data) + if (checkResult.error) { + throw nodeError(nodeId, checkResult.message) + } } } } \ No newline at end of file diff --git a/service-web/client/src/components/flow/FlowEditor.tsx b/service-web/client/src/components/flow/FlowEditor.tsx index 9668c73..c06dec0 100644 --- a/service-web/client/src/components/flow/FlowEditor.tsx +++ b/service-web/client/src/components/flow/FlowEditor.tsx @@ -1,5 +1,5 @@ import {PlusCircleFilled, RollbackOutlined, SaveFilled} from '@ant-design/icons' -import {Background, BackgroundVariant, Controls, type Edge, MiniMap, type Node, ReactFlow} from '@xyflow/react' +import {Background, BackgroundVariant, Controls, MiniMap, ReactFlow} from '@xyflow/react' import {Button, Dropdown, message, Popconfirm, Space} from 'antd' import {arrToMap, randomId} from 'licia' import {useEffect} from 'react' @@ -12,6 +12,7 @@ import NodeRegistry from './NodeRegistry.tsx' import {useContextStore} from './store/ContextStore.ts' import {useDataStore} from './store/DataStore.ts' import {useFlowStore} from './store/FlowStore.ts' +import type {FlowEditorProps} from './types.ts' const FlowableDiv = styled.div` .react-flow__node.selectable { @@ -48,14 +49,6 @@ const FlowableDiv = styled.div` } ` -export type GraphData = { nodes: Node[], edges: Edge[], data: any } - -export type FlowEditorProps = { - inputSchema: Record>, - graphData: GraphData, - onGraphDataChange: (graphData: GraphData) => void, -} - function FlowEditor(props: FlowEditorProps) { const navigate = useNavigate() const [messageApi, contextHolder] = message.useMessage() diff --git a/service-web/client/src/components/flow/Helper.tsx b/service-web/client/src/components/flow/Helper.tsx new file mode 100644 index 0000000..504b0f1 --- /dev/null +++ b/service-web/client/src/components/flow/Helper.tsx @@ -0,0 +1,38 @@ +import {type Edge, getIncomers, type Node} from '@xyflow/react' +import {find, has, isEqual, unique} from 'licia' +import Queue from 'yocto-queue' + +export const getAllIncomerNodeById: (id: string, nodes: Node[], edges: Edge[]) => string[] = (id, nodes, edges) => { + let queue = new Queue() + queue.enqueue(find(nodes, node => isEqual(node.id, id))!) + let result: string[] = [] + while (queue.size !== 0) { + let currentNode = queue.dequeue()! + for (const incomer of getIncomers(currentNode, nodes, edges)) { + result.push(incomer.id) + queue.enqueue(incomer) + } + } + return unique(result, (a, b) => isEqual(a, b)) +} + +export const getAllIncomerNodeOutputVariables: (id: string, nodes: Node[], edges: Edge[], data: any) => { + id: string, + variable: string +}[] = (id, nodes, edges, data) => { + let incomerIds = getAllIncomerNodeById(id, nodes, edges) + let incomerVariables: { id: string, variable: string }[] = [] + for (const incomerId of incomerIds) { + let nodeData = data[incomerId] ?? {} + if (has(nodeData, 'outputs')) { + let outputs = nodeData?.outputs ?? [] + for (const output of Object.keys(outputs)) { + incomerVariables.push({ + id: incomerId, + variable: output, + }) + } + } + } + return incomerVariables +} diff --git a/service-web/client/src/components/flow/NodeRegistry.tsx b/service-web/client/src/components/flow/NodeRegistry.tsx index 536afd4..2826c3f 100644 --- a/service-web/client/src/components/flow/NodeRegistry.tsx +++ b/service-web/client/src/components/flow/NodeRegistry.tsx @@ -1,13 +1,39 @@ +import {has, isEmpty} from 'licia' +import {getAllIncomerNodeOutputVariables} from './Helper.tsx' import CodeNode from './node/CodeNode.tsx' import KnowledgeNode from './node/KnowledgeNode.tsx' import LlmNode from './node/LlmNode.tsx' import OutputNode from './node/OutputNode.tsx' import SwitchNode from './node/SwitchNode.tsx' +import type {NodeChecker} from './types.ts' + +const inputVariableChecker: NodeChecker = (id, nodes, edges, data) => { + let nodeData = data[id] ?? {} + if (has(nodeData, 'inputs')) { + let inputs = nodeData?.inputs ?? {} + if (!isEmpty(inputs)) { + let outputVariables = new Set( + getAllIncomerNodeOutputVariables(id, nodes, edges, data).map(i => `${i.id}.${i.variable}`), + ) + for (const key of Object.keys(inputs)) { + let variable = inputs[key]?.variable ?? '' + if (!outputVariables.has(variable)) { + return { + error: true, + message: `节点 ${id} 存在错误:变量 ${variable} 不存在`, + } + } + } + } + } + return {error: false} +} type NodeDefine = { name: string, description: string, component: any, + checkers: NodeChecker[], } const NodeRegistry: Record = { @@ -15,26 +41,31 @@ const NodeRegistry: Record = { name: '输出', description: '定义输出变量', component: OutputNode, + checkers: [inputVariableChecker], }, 'llm-node': { name: '大模型', description: '使用大模型对话', component: LlmNode, + checkers: [inputVariableChecker], }, 'knowledge-node': { name: '知识库', description: '', component: KnowledgeNode, + checkers: [inputVariableChecker], }, 'code-node': { name: '代码执行', description: '执行自定义的处理代码', component: CodeNode, + checkers: [inputVariableChecker], }, 'switch-node': { name: '分支节点', description: '根据不同的情况前往不同的分支', component: SwitchNode, + checkers: [], }, } diff --git a/service-web/client/src/components/flow/node/AmisNode.tsx b/service-web/client/src/components/flow/node/AmisNode.tsx index 409050f..cd34159 100644 --- a/service-web/client/src/components/flow/node/AmisNode.tsx +++ b/service-web/client/src/components/flow/node/AmisNode.tsx @@ -1,38 +1,15 @@ import {CopyFilled, DeleteFilled, EditFilled} from '@ant-design/icons' -import {type Edge, getIncomers, Handle, type Node, type NodeProps, Position} from '@xyflow/react' +import {type Edge, Handle, type Node, type NodeProps, Position} from '@xyflow/react' import type {Schema} from 'amis' import {Button, Card, Drawer} from 'antd' -import {find, has, isEmpty, isEqual, unique} from 'licia' +import {has, isEmpty} from 'licia' import {type JSX, useCallback, useState} from 'react' import styled from 'styled-components' -import Queue from 'yocto-queue' import {amisRender, commonInfo, horizontalFormOptions} from '../../../util/amis.tsx' +import {getAllIncomerNodeById} from '../Helper.tsx' import {useDataStore} from '../store/DataStore.ts' import {useFlowStore} from '../store/FlowStore.ts' - -export type InputFormOptions = { - label: string - value: string -} - -export type InputFormOptionsGroup = { - group: string, - variables: InputFormOptions[], -} - -const getAllIncomerNodeById: (id: string, nodes: Node[], edges: Edge[]) => string[] = (id, nodes, edges) => { - let queue = new Queue() - queue.enqueue(find(nodes, node => isEqual(node.id, id))!) - let result: string[] = [] - while (queue.size !== 0) { - let currentNode = queue.dequeue()! - for (const incomer of getIncomers(currentNode, nodes, edges)) { - result.push(incomer.id) - queue.enqueue(incomer) - } - } - return unique(result, (a, b) => isEqual(a, b)) -} +import type {InputFormOptions, InputFormOptionsGroup} from '../types.ts' export function inputsFormColumns( nodeId: string, @@ -91,7 +68,7 @@ export function inputsFormColumns( { ...horizontalFormOptions(), type: 'select', - name: 'type', + name: 'variable', label: '变量', required: true, selectMode: 'group', diff --git a/service-web/client/src/components/flow/store/ContextStore.ts b/service-web/client/src/components/flow/store/ContextStore.ts index e0d1005..2cd3319 100644 --- a/service-web/client/src/components/flow/store/ContextStore.ts +++ b/service-web/client/src/components/flow/store/ContextStore.ts @@ -1,12 +1,10 @@ import {create} from 'zustand/react' -export type ContextStoreState = { +export const useContextStore = create<{ inputSchema: Record>, getInputSchema: () => Record>, setInputSchema: (inputSchema: Record>) => void, -} - -export const useContextStore = create((set, get) => ({ +}>((set, get) => ({ inputSchema: {}, getInputSchema: () => get().inputSchema, setInputSchema: (inputSchema: Record>) => set({inputSchema}), diff --git a/service-web/client/src/components/flow/store/DataStore.ts b/service-web/client/src/components/flow/store/DataStore.ts index fbe4b28..a67b7a6 100644 --- a/service-web/client/src/components/flow/store/DataStore.ts +++ b/service-web/client/src/components/flow/store/DataStore.ts @@ -1,6 +1,6 @@ import {create} from 'zustand/react' -export type DataStoreState = { +export const useDataStore = create<{ data: Record, getData: () => Record, setData: (data: Record) => void, @@ -8,9 +8,7 @@ export type DataStoreState = { setDataById: (id: string, data: any) => void, mergeDataById: (id: string, data: any) => void, removeDataById: (id: string) => void, -} - -export const useDataStore = create((set, get) => ({ +}>((set, get) => ({ data: {}, getData: () => get().data, setData: (data) => set({ diff --git a/service-web/client/src/components/flow/store/FlowStore.ts b/service-web/client/src/components/flow/store/FlowStore.ts index 9630681..529d517 100644 --- a/service-web/client/src/components/flow/store/FlowStore.ts +++ b/service-web/client/src/components/flow/store/FlowStore.ts @@ -11,7 +11,7 @@ import { import {filter, find, isEqual} from 'licia' import {create} from 'zustand/react' -export type FlowStoreState = { +export const useFlowStore = create<{ nodes: Node[], getNodes: () => Node[], onNodesChange: OnNodesChange, @@ -26,9 +26,7 @@ export type FlowStoreState = { setEdges: (edges: Edge[]) => void, onConnect: OnConnect, -} - -export const useFlowStore = create((set, get) => ({ +}>((set, get) => ({ nodes: [], getNodes: () => get().nodes, onNodesChange: changes => { diff --git a/service-web/client/src/components/flow/types.ts b/service-web/client/src/components/flow/types.ts new file mode 100644 index 0000000..257fd85 --- /dev/null +++ b/service-web/client/src/components/flow/types.ts @@ -0,0 +1,26 @@ +import type {Edge, Node} from '@xyflow/react' + +export type InputFormOptions = { + label: string + value: string +} + +export type InputFormOptionsGroup = { + group: string, + variables: InputFormOptions[], +} + +export type NodeError = { + error: boolean, + message?: string, +} + +export type NodeChecker = (id: string, nodes: Node[], edges: Edge[], data: any) => NodeError + +export type GraphData = { nodes: Node[], edges: Edge[], data: any } + +export type FlowEditorProps = { + inputSchema: Record>, + graphData: GraphData, + onGraphDataChange: (graphData: GraphData) => void, +} \ No newline at end of file diff --git a/service-web/client/src/pages/Test.tsx b/service-web/client/src/pages/Test.tsx index 90fa159..aa7a933 100644 --- a/service-web/client/src/pages/Test.tsx +++ b/service-web/client/src/pages/Test.tsx @@ -1,12 +1,10 @@ import {useState} from 'react' -import FlowEditor, {type GraphData} from '../components/flow/FlowEditor.tsx' +import FlowEditor from '../components/flow/FlowEditor.tsx' +import type {GraphData} from '../components/flow/types.ts' function Test() { - const [graphData] = useState({ - nodes: [], - edges: [], - data: {}, - }) + // language=JSON + const [graphData] = useState(JSON.parse('{\n "nodes": [\n {\n "id": "MzEitlOusl",\n "type": "llm-node",\n "position": {\n "x": 47,\n "y": 92\n },\n "data": {},\n "measured": {\n "width": 256,\n "height": 130\n },\n "selected": false,\n "dragging": false\n },\n {\n "id": "bivXSpiLaI",\n "type": "code-node",\n "position": {\n "x": 381,\n "y": 181\n },\n "data": {},\n "measured": {\n "width": 256,\n "height": 130\n },\n "selected": true,\n "dragging": false\n }\n ],\n "edges": [\n {\n "source": "MzEitlOusl",\n "sourceHandle": "source",\n "target": "bivXSpiLaI",\n "targetHandle": "target",\n "id": "xy-edge__MzEitlOuslsource-bivXSpiLaItarget"\n }\n ],\n "data": {\n "MzEitlOusl": {\n "node": {\n "name": "大模型",\n "description": "使用大模型对话"\n },\n "outputs": {\n "text": {\n "type": "string"\n }\n },\n "model": "qwen3",\n "systemPrompt": "你是个好人",\n "finished": true\n },\n "bivXSpiLaI": {\n "node": {\n "name": "代码执行",\n "description": "执行自定义的处理代码"\n },\n "outputs": {\n "result": {\n "type": "string"\n }\n },\n "type": "javascript",\n "content": "console.log(\'hello\')",\n "inputs": {\n "text": {\n "variable": "MzEitlOusl.text"\n }\n },\n "finished": true\n }\n }\n}')) return (