4 Commits

Author SHA1 Message Date
v-zhangjc9
e798332828 feat(ai-web): 增加模板渲染的处理 2025-07-12 23:24:22 +08:00
v-zhangjc9
b0d41e0d88 feat(web): 增加模板节点 2025-07-12 22:56:15 +08:00
v-zhangjc9
02b2d44ccc feat(ai-web): 修复出现遗留边的时候导致出现的null错误 2025-07-12 21:46:00 +08:00
v-zhangjc9
47de3cc376 feat(web): 输出节点增加输出类型 2025-07-12 21:16:06 +08:00
11 changed files with 260 additions and 78 deletions

View File

@@ -30,6 +30,7 @@
<curator.version>5.1.0</curator.version> <curator.version>5.1.0</curator.version>
<hutool.version>5.8.27</hutool.version> <hutool.version>5.8.27</hutool.version>
<mapstruct.version>1.6.3</mapstruct.version> <mapstruct.version>1.6.3</mapstruct.version>
<liteflow.version>2.13.2</liteflow.version>
</properties> </properties>
<dependencies> <dependencies>
@@ -153,12 +154,27 @@
<dependency> <dependency>
<groupId>com.yomahub</groupId> <groupId>com.yomahub</groupId>
<artifactId>liteflow-spring-boot-starter</artifactId> <artifactId>liteflow-spring-boot-starter</artifactId>
<version>2.13.2</version> <version>${liteflow.version}</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>com.yomahub</groupId> <groupId>com.yomahub</groupId>
<artifactId>liteflow-el-builder</artifactId> <artifactId>liteflow-el-builder</artifactId>
<version>2.13.2</version> <version>${liteflow.version}</version>
</dependency>
<dependency>
<groupId>com.yomahub</groupId>
<artifactId>liteflow-script-graaljs</artifactId>
<version>${liteflow.version}</version>
</dependency>
<dependency>
<groupId>com.yomahub</groupId>
<artifactId>liteflow-script-python</artifactId>
<version>${liteflow.version}</version>
</dependency>
<dependency>
<groupId>com.yomahub</groupId>
<artifactId>liteflow-script-lua</artifactId>
<version>${liteflow.version}</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.noear</groupId> <groupId>org.noear</groupId>

View File

@@ -50,6 +50,10 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId> <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
</dependency>
<dependency> <dependency>
<groupId>com.blinkfox</groupId> <groupId>com.blinkfox</groupId>
<artifactId>fenix-spring-boot-starter</artifactId> <artifactId>fenix-spring-boot-starter</artifactId>

View File

@@ -1,5 +1,6 @@
package com.lanyuanxiaoyao.service.ai.web.engine; package com.lanyuanxiaoyao.service.ai.web.engine;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import com.lanyuanxiaoyao.service.ai.web.engine.entity.FlowContext; import com.lanyuanxiaoyao.service.ai.web.engine.entity.FlowContext;
import com.lanyuanxiaoyao.service.ai.web.engine.entity.FlowEdge; import com.lanyuanxiaoyao.service.ai.web.engine.entity.FlowEdge;
@@ -47,6 +48,9 @@ public final class FlowGraphRunner {
} }
while (!executionQueue.isEmpty()) { while (!executionQueue.isEmpty()) {
var node = executionQueue.poll(); var node = executionQueue.poll();
if (ObjectUtil.isNull(node)) {
continue;
}
process(node, flowContext); process(node, flowContext);
} }
} }

View File

@@ -1,6 +1,9 @@
package com.lanyuanxiaoyao.service.ai.web.engine; package com.lanyuanxiaoyao.service.ai.web.engine;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.template.TemplateEngine;
import cn.hutool.extra.template.TemplateUtil;
import com.lanyuanxiaoyao.service.ai.web.engine.entity.FlowContext; import com.lanyuanxiaoyao.service.ai.web.engine.entity.FlowContext;
import java.util.Map; import java.util.Map;
import org.eclipse.collections.api.factory.Maps; import org.eclipse.collections.api.factory.Maps;
@@ -11,6 +14,8 @@ import org.eclipse.collections.api.map.ImmutableMap;
* @version 20250711 * @version 20250711
*/ */
public class FlowHelper { public class FlowHelper {
private static final TemplateEngine TEMPLATE_ENGINE = TemplateUtil.createEngine();
public static ImmutableMap<String, Object> generateInputVariablesMap(String nodeId, FlowContext context) { public static ImmutableMap<String, Object> generateInputVariablesMap(String nodeId, FlowContext context) {
var variableMap = Maps.mutable.<String, Object>empty(); var variableMap = Maps.mutable.<String, Object>empty();
var currentNodeData = context.get(nodeId); var currentNodeData = context.get(nodeId);
@@ -18,27 +23,39 @@ public class FlowHelper {
var inputsMap = (Map<String, Map<String, String>>) currentNodeData.get("inputs"); var inputsMap = (Map<String, Map<String, String>>) currentNodeData.get("inputs");
for (String variableName : inputsMap.keySet()) { for (String variableName : inputsMap.keySet()) {
var expression = inputsMap.get(variableName).get("variable"); var expression = inputsMap.get(variableName).get("variable");
if (StrUtil.contains(expression, ".")) { var targetVariable = generateVariable(expression, context);
var splits = StrUtil.splitTrim(expression, ".", 2); if (ObjectUtil.isNotNull(targetVariable)) {
var targetNodeId = splits.get(0);
var targetVariableName = splits.get(1);
if (!context.getData().containsKey(targetNodeId)) {
throw new RuntimeException(StrUtil.format("Target node id not found: {}", targetNodeId));
}
var targetNodeData = context.getData().get(targetNodeId);
if (!targetNodeData.containsKey(targetVariableName)) {
throw new RuntimeException(StrUtil.format("Target node variable not found: {}.{}", targetNodeId, targetVariableName));
}
var targetVariable = targetNodeData.get(targetVariableName);
variableMap.put(variableName, targetVariable); variableMap.put(variableName, targetVariable);
} else if (context.getInput().containsKey(expression)) {
if (!context.getInput().containsKey(variableName)) {
throw new RuntimeException(StrUtil.format("Target variable not found in input {}", variableName));
}
variableMap.put(variableName, context.getInput().get(variableName));
} }
} }
} }
return variableMap.toImmutable(); return variableMap.toImmutable();
} }
public static Object generateVariable(String expression, FlowContext context) {
if (StrUtil.contains(expression, ".")) {
var splits = StrUtil.splitTrim(expression, ".", 2);
var targetNodeId = splits.get(0);
var targetVariableName = splits.get(1);
if (!context.getData().containsKey(targetNodeId)) {
throw new RuntimeException(StrUtil.format("Target node id not found: {}", targetNodeId));
}
var targetNodeData = context.getData().get(targetNodeId);
if (!targetNodeData.containsKey(targetVariableName)) {
throw new RuntimeException(StrUtil.format("Target node variable not found: {}.{}", targetNodeId, targetVariableName));
}
return targetNodeData.get(targetVariableName);
} else if (context.getInput().containsKey(expression)) {
if (!context.getInput().containsKey(expression)) {
throw new RuntimeException(StrUtil.format("Target variable not found in input {}", expression));
}
return context.getInput().get(expression);
}
return null;
}
public static String renderTemplateText(String templateText, Map<?, ?> data) {
var template = TEMPLATE_ENGINE.getTemplate(templateText);
return template.render(data);
}
} }

View File

@@ -1,8 +1,11 @@
package com.lanyuanxiaoyao.service.ai.web.engine.node; package com.lanyuanxiaoyao.service.ai.web.engine.node;
import cn.hutool.core.util.StrUtil;
import com.lanyuanxiaoyao.service.ai.web.configuration.SpringBeanGetter;
import com.lanyuanxiaoyao.service.ai.web.engine.FlowHelper; import com.lanyuanxiaoyao.service.ai.web.engine.FlowHelper;
import com.lanyuanxiaoyao.service.ai.web.engine.FlowNodeRunner; import com.lanyuanxiaoyao.service.ai.web.engine.FlowNodeRunner;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
/** /**
* @author lanyuanxiaoyao * @author lanyuanxiaoyao
@@ -14,10 +17,9 @@ public class LlmNode extends FlowNodeRunner {
public void run() { public void run() {
var variableMap = FlowHelper.generateInputVariablesMap(getNodeId(), getContext()); var variableMap = FlowHelper.generateInputVariablesMap(getNodeId(), getContext());
log.info("Variable map: {}", variableMap); log.info("Variable map: {}", variableMap);
setData("text", "llm"); var sourcePrompt = (String) getData("systemPrompt");
if (StrUtil.isNotBlank(sourcePrompt)) {
/* var prompt = (String) getData("systemPrompt"); var prompt = FlowHelper.renderTemplateText(sourcePrompt, variableMap.toMap());
if (StrUtil.isNotBlank(prompt)) {
var builder = SpringBeanGetter.getBean("chat", ChatClient.Builder.class); var builder = SpringBeanGetter.getBean("chat", ChatClient.Builder.class);
var client = builder.build(); var client = builder.build();
var content = client.prompt() var content = client.prompt()
@@ -25,6 +27,6 @@ public class LlmNode extends FlowNodeRunner {
.call() .call()
.content(); .content();
setData("text", content); setData("text", content);
}*/ }
} }
} }

View File

@@ -12,7 +12,8 @@ import lombok.extern.slf4j.Slf4j;
public class OutputNode extends FlowNodeRunner { public class OutputNode extends FlowNodeRunner {
@Override @Override
public void run() { public void run() {
var variableMap = FlowHelper.generateInputVariablesMap(getNodeId(), getContext()); String expression = getData("output");
log.info("Variable map: {}", variableMap); var targetVariable = FlowHelper.generateVariable(expression, getContext());
log.info("Target: {}", targetVariable);
} }
} }

View File

@@ -1,6 +1,8 @@
import {type Edge, getIncomers, type Node} from '@xyflow/react' import {type Edge, getIncomers, type Node} from '@xyflow/react'
import {find, has, isEqual, unique} from 'licia' import type {Option} from 'amis/lib/Schema'
import {find, has, isEmpty, isEqual, unique} from 'licia'
import Queue from 'yocto-queue' import Queue from 'yocto-queue'
import type {InputFormOptions, InputFormOptionsGroup} from './types.ts'
export const getAllIncomerNodeById: (id: string, nodes: Node[], edges: Edge[]) => string[] = (id, nodes, edges) => { export const getAllIncomerNodeById: (id: string, nodes: Node[], edges: Edge[]) => string[] = (id, nodes, edges) => {
let queue = new Queue<Node>() let queue = new Queue<Node>()
@@ -36,3 +38,47 @@ export const getAllIncomerNodeOutputVariables: (id: string, nodes: Node[], edges
} }
return incomerVariables return incomerVariables
} }
export const generateAllIncomerOutputVariablesFormOptions: (id: string, inputSchema: Record<string, Record<string, any>>, nodes: Node[], edges: Edge[], data: any) => Option[] = (id, inputSchema, nodes, edges, data) => {
let inputSchemaVariables: InputFormOptions[] = Object.keys(inputSchema).map(key => ({
label: `${key} (${inputSchema[key]?.label ?? ''})`,
value: key,
}))
let incomerIds = getAllIncomerNodeById(id, nodes, edges)
let incomerVariables: InputFormOptionsGroup[] = []
for (const incomerId of incomerIds) {
let nodeData = data[incomerId] ?? {}
let group = incomerId
if (has(nodeData, 'node') && has(nodeData.node, 'name')) {
group = `${nodeData.node.name} ${incomerId}`
}
if (has(nodeData, 'outputs')) {
let outputs = nodeData?.outputs ?? []
incomerVariables.push({
group: group,
variables: Object.keys(outputs).map(key => ({
value: `${incomerId}.${key}`,
label: key,
})),
})
}
}
let inputVariables = [
...(isEmpty(inputSchemaVariables) ? [] : [
{
group: '流程入参',
variables: inputSchemaVariables,
},
]),
...incomerVariables,
]
return [
...inputVariables.map(item => ({
label: item.group,
children: item.variables,
})),
]
}

View File

@@ -6,9 +6,32 @@ import KnowledgeNode from './node/KnowledgeNode.tsx'
import LlmNode from './node/LlmNode.tsx' import LlmNode from './node/LlmNode.tsx'
import OutputNode from './node/OutputNode.tsx' import OutputNode from './node/OutputNode.tsx'
import SwitchNode from './node/SwitchNode.tsx' import SwitchNode from './node/SwitchNode.tsx'
import TemplateNode from './node/TemplateNode.tsx'
import type {NodeChecker} from './types.ts' import type {NodeChecker} from './types.ts'
const inputVariableChecker: NodeChecker = (id, inputSchema, nodes, edges, data) => { const inputSingleVariableChecker: (field: string) => NodeChecker = field => {
return (id, inputSchema, nodes, edges, data) => {
let nodeData = data[id] ?? {}
if (has(nodeData, field)) {
let expression = nodeData?.[field] ?? ''
if (!isEmpty(expression)) {
let outputVariables = new Set([
...getAllIncomerNodeOutputVariables(id, nodes, edges, data).map(i => `${i.id}.${i.variable}`),
...Object.keys(inputSchema),
])
if (!outputVariables.has(expression)) {
return {
error: true,
message: `节点 ${id} 存在错误:变量 ${expression} 不存在`,
}
}
}
}
return {error: false}
}
}
const inputMultiVariableChecker: NodeChecker = (id, inputSchema, nodes, edges, data) => {
let nodeData = data[id] ?? {} let nodeData = data[id] ?? {}
if (has(nodeData, 'inputs')) { if (has(nodeData, 'inputs')) {
let inputs = nodeData?.inputs ?? {} let inputs = nodeData?.inputs ?? {}
@@ -49,7 +72,7 @@ export const NodeRegistry: NodeDefine[] = [
icon: <i className="fa fa-message"/>, icon: <i className="fa fa-message"/>,
description: '使用大模型对话', description: '使用大模型对话',
component: LlmNode, component: LlmNode,
checkers: [inputVariableChecker], checkers: [inputMultiVariableChecker],
}, },
{ {
key: 'knowledge-node', key: 'knowledge-node',
@@ -58,7 +81,7 @@ export const NodeRegistry: NodeDefine[] = [
icon: <i className="fa fa-book-bookmark"/>, icon: <i className="fa fa-book-bookmark"/>,
description: '', description: '',
component: KnowledgeNode, component: KnowledgeNode,
checkers: [inputVariableChecker], checkers: [inputMultiVariableChecker],
}, },
{ {
key: 'code-node', key: 'code-node',
@@ -67,7 +90,16 @@ export const NodeRegistry: NodeDefine[] = [
icon: <i className="fa fa-code"/>, icon: <i className="fa fa-code"/>,
description: '执行自定义的处理代码', description: '执行自定义的处理代码',
component: CodeNode, component: CodeNode,
checkers: [inputVariableChecker], checkers: [inputMultiVariableChecker],
},
{
key: 'template-node',
group: '普通节点',
name: '模板替换',
icon: <i className="fa fa-pen-nib"/>,
description: '使用模板聚合转换变量表示',
component: TemplateNode,
checkers: [inputMultiVariableChecker],
}, },
{ {
key: 'switch-node', key: 'switch-node',
@@ -85,7 +117,7 @@ export const NodeRegistry: NodeDefine[] = [
icon: <i className="fa fa-file"/>, icon: <i className="fa fa-file"/>,
description: '定义输出变量', description: '定义输出变量',
component: OutputNode, component: OutputNode,
checkers: [inputVariableChecker], checkers: [inputSingleVariableChecker('output')],
}, },
] ]

View File

@@ -2,14 +2,12 @@ import {CopyFilled, DeleteFilled, EditFilled} from '@ant-design/icons'
import {type Edge, 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 type {Schema} from 'amis'
import {Button, Card, Drawer} from 'antd' import {Button, Card, Drawer} from 'antd'
import {has, isEmpty} from 'licia'
import {type JSX, useCallback, useState} from 'react' import {type JSX, useCallback, useState} from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import {amisRender, commonInfo, horizontalFormOptions} from '../../../util/amis.tsx' import {amisRender, commonInfo, horizontalFormOptions} from '../../../util/amis.tsx'
import {getAllIncomerNodeById} from '../Helper.tsx' import {generateAllIncomerOutputVariablesFormOptions} from '../Helper.tsx'
import {useDataStore} from '../store/DataStore.ts' import {useDataStore} from '../store/DataStore.ts'
import {useFlowStore} from '../store/FlowStore.ts' import {useFlowStore} from '../store/FlowStore.ts'
import type {InputFormOptions, InputFormOptionsGroup} from '../types.ts'
export function inputsFormColumns( export function inputsFormColumns(
nodeId: string, nodeId: string,
@@ -18,41 +16,6 @@ export function inputsFormColumns(
edges: Edge[], edges: Edge[],
data: any, data: any,
): Schema[] { ): Schema[] {
let inputSchemaVariables: InputFormOptions[] = Object.keys(inputSchema).map(key => ({
label: `${key} (${inputSchema[key]?.label ?? ''})`,
value: key,
}))
let incomerIds = getAllIncomerNodeById(nodeId, nodes, edges)
let incomerVariables: InputFormOptionsGroup[] = []
for (const incomerId of incomerIds) {
let nodeData = data[incomerId] ?? {}
let group = incomerId
if (has(nodeData, 'node') && has(nodeData.node, 'name')) {
group = `${nodeData.node.name} ${incomerId}`
}
if (has(nodeData, 'outputs')) {
let outputs = nodeData?.outputs ?? []
incomerVariables.push({
group: group,
variables: Object.keys(outputs).map(key => ({
value: `${incomerId}.${key}`,
label: key,
})),
})
}
}
let inputVariables = [
...(isEmpty(inputSchemaVariables) ? [] : [
{
group: '流程入参',
variables: inputSchemaVariables,
},
]),
...incomerVariables,
]
return [ return [
{ {
type: 'input-kvs', type: 'input-kvs',
@@ -72,12 +35,13 @@ export function inputsFormColumns(
label: '变量', label: '变量',
required: true, required: true,
selectMode: 'group', selectMode: 'group',
options: [ options: generateAllIncomerOutputVariablesFormOptions(
...inputVariables.map(item => ({ nodeId,
label: item.group, inputSchema,
children: item.variables, nodes,
})), edges,
], data,
),
}, },
], ],
}, },

View File

@@ -1,16 +1,33 @@
import type {NodeProps} from '@xyflow/react' import type {NodeProps} from '@xyflow/react'
import React, {useCallback} from 'react' import React, {useCallback} from 'react'
import {generateAllIncomerOutputVariablesFormOptions} from '../Helper.tsx'
import {useContextStore} from '../store/ContextStore.ts' import {useContextStore} from '../store/ContextStore.ts'
import {useDataStore} from '../store/DataStore.ts' import {useDataStore} from '../store/DataStore.ts'
import {useFlowStore} from '../store/FlowStore.ts' import {useFlowStore} from '../store/FlowStore.ts'
import AmisNode, {EndNodeHandler, inputsFormColumns} from './AmisNode.tsx' import AmisNode, {EndNodeHandler} from './AmisNode.tsx'
const OutputNode = (props: NodeProps) => { const OutputNode = (props: NodeProps) => {
const {getNodes, getEdges} = useFlowStore() const {getNodes, getEdges} = useFlowStore()
const {getData} = useDataStore() const {getData} = useDataStore()
const {getInputSchema} = useContextStore() const {getInputSchema} = useContextStore()
const columnsSchema = useCallback( const columnsSchema = useCallback(
() => inputsFormColumns(props.id, getInputSchema(), getNodes(), getEdges(), getData()), () => [
{
type: 'select',
name: 'output',
label: '输出变量',
required: true,
selectMode: 'group',
options: generateAllIncomerOutputVariablesFormOptions(
props.id,
getInputSchema(),
getNodes(),
getEdges(),
getData(),
),
},
],
[props.id], [props.id],
) )

View File

@@ -0,0 +1,79 @@
import type {NodeProps} from '@xyflow/react'
import {Tag} from 'antd'
import React, {useCallback} from 'react'
import {useContextStore} from '../store/ContextStore.ts'
import {useDataStore} from '../store/DataStore.ts'
import {useFlowStore} from '../store/FlowStore.ts'
import AmisNode, {EndNodeHandler, inputsFormColumns} from './AmisNode.tsx'
const typeMap: Record<string, string> = {
default: '默认',
json: 'JSON',
'template-markdown': 'Markdown',
'template-rich-text': '富文本',
}
const TemplateNode = (props: NodeProps) => {
const {getNodes, getEdges} = useFlowStore()
const {getData, getDataById} = useDataStore()
const {getInputSchema} = useContextStore()
const nodeData = getDataById(props.id)
const columnsSchema = useCallback(
() => [
...inputsFormColumns(props.id, getInputSchema(), getNodes(), getEdges(), getData()),
{
type: 'divider',
},
{
type: 'select',
name: 'type',
label: '模板类型',
required: true,
selectFirst: true,
options: Object.keys(typeMap).map(key => ({label: typeMap[key], value: key})),
},
{
visibleOn: 'type === \'template-markdown\'',
type: 'editor',
required: true,
label: '模板内容',
name: 'template',
language: 'markdown',
options: {
wordWrap: 'bounded',
},
},
{
visibleOn: 'type === \'template-rich-text\'',
type: 'input-rich-text',
required: true,
name: 'template',
label: '模板内容',
options: {
min_height: 500,
},
},
],
[props.id],
)
return (
<AmisNode
nodeProps={props}
extraNodeDescription={
nodeData?.type
? <div className="mt-2 flex justify-between">
<span></span>
<Tag className="m-0" color="blue">{typeMap[nodeData.type]}</Tag>
</div>
: <></>
}
columnSchema={columnsSchema}
handler={<EndNodeHandler/>}
/>
)
}
export default React.memo(TemplateNode)