feat(web): 增加表单数据校验
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, Record<string, any>>,
|
||||
graphData: GraphData,
|
||||
onGraphDataChange: (graphData: GraphData) => void,
|
||||
}
|
||||
|
||||
function FlowEditor(props: FlowEditorProps) {
|
||||
const navigate = useNavigate()
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
|
||||
38
service-web/client/src/components/flow/Helper.tsx
Normal file
38
service-web/client/src/components/flow/Helper.tsx
Normal file
@@ -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<Node>()
|
||||
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
|
||||
}
|
||||
@@ -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<string, NodeDefine> = {
|
||||
@@ -15,26 +41,31 @@ const NodeRegistry: Record<string, NodeDefine> = {
|
||||
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: [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Node>()
|
||||
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',
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import {create} from 'zustand/react'
|
||||
|
||||
export type ContextStoreState = {
|
||||
export const useContextStore = create<{
|
||||
inputSchema: Record<string, Record<string, any>>,
|
||||
getInputSchema: () => Record<string, Record<string, any>>,
|
||||
setInputSchema: (inputSchema: Record<string, Record<string, any>>) => void,
|
||||
}
|
||||
|
||||
export const useContextStore = create<ContextStoreState>((set, get) => ({
|
||||
}>((set, get) => ({
|
||||
inputSchema: {},
|
||||
getInputSchema: () => get().inputSchema,
|
||||
setInputSchema: (inputSchema: Record<string, Record<string, any>>) => set({inputSchema}),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {create} from 'zustand/react'
|
||||
|
||||
export type DataStoreState = {
|
||||
export const useDataStore = create<{
|
||||
data: Record<string, any>,
|
||||
getData: () => Record<string, any>,
|
||||
setData: (data: Record<string, any>) => 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<DataStoreState>((set, get) => ({
|
||||
}>((set, get) => ({
|
||||
data: {},
|
||||
getData: () => get().data,
|
||||
setData: (data) => set({
|
||||
|
||||
@@ -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<FlowStoreState>((set, get) => ({
|
||||
}>((set, get) => ({
|
||||
nodes: [],
|
||||
getNodes: () => get().nodes,
|
||||
onNodesChange: changes => {
|
||||
|
||||
26
service-web/client/src/components/flow/types.ts
Normal file
26
service-web/client/src/components/flow/types.ts
Normal file
@@ -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<string, Record<string, any>>,
|
||||
graphData: GraphData,
|
||||
onGraphDataChange: (graphData: GraphData) => void,
|
||||
}
|
||||
@@ -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<GraphData>({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
data: {},
|
||||
})
|
||||
// language=JSON
|
||||
const [graphData] = useState<GraphData>(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 (
|
||||
<div className="h-screen">
|
||||
|
||||
Reference in New Issue
Block a user