feat: 新增条件节点和多语言脚本支持
refactor(flow): 将Decision节点重命名为Condition节点 feat(flow): 新增多语言脚本执行器(Rhai/JS/Python) feat(flow): 实现变量映射和执行功能 feat(flow): 添加条件节点执行逻辑 feat(frontend): 为开始/结束节点添加多语言描述 test: 添加yaml条件转换测试 chore: 移除废弃的storage模块
This commit is contained in:
@ -5,7 +5,7 @@
|
||||
|
||||
import { useRef, useEffect } from 'react';
|
||||
|
||||
import { Field, FieldRenderProps } from '@flowgram.ai/free-layout-editor';
|
||||
import { Field, FieldRenderProps, I18n } from '@flowgram.ai/free-layout-editor';
|
||||
import { Typography, Input } from '@douyinfe/semi-ui';
|
||||
|
||||
import { Title } from './styles';
|
||||
@ -39,7 +39,10 @@ export function TitleInput(props: {
|
||||
onBlur={() => updateTitleEdit(false)}
|
||||
/>
|
||||
) : (
|
||||
<Text ellipsis={{ showTooltip: true }}>{value}</Text>
|
||||
// 对默认的 Start/End 标题进行按需本地化显示
|
||||
<Text ellipsis={{ showTooltip: true }}>{
|
||||
value === 'Start' || value === 'End' ? I18n.t(value as any) : (value as any)
|
||||
}</Text>
|
||||
)}
|
||||
<Feedback errors={fieldState?.errors} />
|
||||
</div>
|
||||
|
||||
@ -355,6 +355,9 @@ export function useEditorProps(
|
||||
'Cannot paste nodes to invalid container': '无法粘贴到无效容器',
|
||||
'Start': '开始',
|
||||
'End': '结束',
|
||||
// ==== 开始/结束节点描述 ====
|
||||
'The starting node of the workflow, used to set the information needed to initiate the workflow.': '流程开始节点,用于设置启动流程所需的信息。',
|
||||
'The final node of the workflow, used to return the result information after the workflow is run.': '流程结束节点,用于返回流程运行后的结果信息。',
|
||||
'Variable List': '变量列表',
|
||||
'Global Editor': '全局变量编辑',
|
||||
'Global': '全局',
|
||||
|
||||
@ -7,6 +7,7 @@ import { FlowNodeRegistry } from '../../typings';
|
||||
import iconEnd from '../../assets/icon-end.jpg';
|
||||
import { formMeta } from './form-meta';
|
||||
import { WorkflowNodeType } from '../constants';
|
||||
import { I18n } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
export const EndNodeRegistry: FlowNodeRegistry = {
|
||||
type: WorkflowNodeType.End,
|
||||
@ -23,7 +24,7 @@ export const EndNodeRegistry: FlowNodeRegistry = {
|
||||
info: {
|
||||
icon: iconEnd,
|
||||
description:
|
||||
'The final node of the workflow, used to return the result information after the workflow is run.',
|
||||
I18n.t('The final node of the workflow, used to return the result information after the workflow is run.'),
|
||||
},
|
||||
/**
|
||||
* Render node via formMeta
|
||||
|
||||
@ -7,6 +7,7 @@ import { FlowNodeRegistry } from '../../typings';
|
||||
import iconStart from '../../assets/icon-start.jpg';
|
||||
import { formMeta } from './form-meta';
|
||||
import { WorkflowNodeType } from '../constants';
|
||||
import { I18n } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
export const StartNodeRegistry: FlowNodeRegistry = {
|
||||
type: WorkflowNodeType.Start,
|
||||
@ -24,7 +25,7 @@ export const StartNodeRegistry: FlowNodeRegistry = {
|
||||
info: {
|
||||
icon: iconStart,
|
||||
description:
|
||||
'The starting node of the workflow, used to set the information needed to initiate the workflow.',
|
||||
I18n.t('The starting node of the workflow, used to set the information needed to initiate the workflow.'),
|
||||
},
|
||||
/**
|
||||
* Render node via formMeta
|
||||
|
||||
145
frontend/src/flows/utils/yaml.test.ts
Normal file
145
frontend/src/flows/utils/yaml.test.ts
Normal file
@ -0,0 +1,145 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { stringifyFlowDoc } from './yaml'
|
||||
import type { FlowDocumentJSON } from '../typings'
|
||||
|
||||
function baseNodes() {
|
||||
return [
|
||||
{ id: 'start_1', type: 'start', data: { title: 'Start' }, meta: {} } as any,
|
||||
{ id: 'cond_1', type: 'condition', data: { title: 'Cond', conditions: [] }, meta: {} } as any,
|
||||
{ id: 'code_1', type: 'code', data: { title: 'Code' }, meta: {} } as any,
|
||||
]
|
||||
}
|
||||
|
||||
describe('stringifyFlowDoc condition -> edges.condition', () => {
|
||||
it('expression mode: raw expr filled to condition', () => {
|
||||
const nodes = baseNodes()
|
||||
;(nodes[1] as any).data.conditions = [
|
||||
{ key: 'if_a', value: { type: 'expression', content: '1 + 1 == 2' } },
|
||||
]
|
||||
const doc: FlowDocumentJSON = {
|
||||
nodes: nodes as any,
|
||||
edges: [
|
||||
{ sourceNodeID: 'start_1', targetNodeID: 'cond_1' } as any,
|
||||
{ sourceNodeID: 'cond_1', sourcePortID: 'if_a', targetNodeID: 'code_1' } as any,
|
||||
],
|
||||
}
|
||||
const y = stringifyFlowDoc(doc)
|
||||
expect(y).toContain('condition: 1 + 1 == 2')
|
||||
})
|
||||
|
||||
it('structured: is_empty and not_empty (unary ops)', () => {
|
||||
const nodes = baseNodes()
|
||||
;(nodes[1] as any).data.conditions = [
|
||||
{ key: 'if_empty', value: { left: { type: 'ref', content: ['input', 'name'] }, operator: 'is_empty' } },
|
||||
{ key: 'if_not_empty', value: { left: { type: 'ref', content: ['input', 'age'] }, operator: 'not_empty' } },
|
||||
]
|
||||
const doc: FlowDocumentJSON = {
|
||||
nodes: nodes as any,
|
||||
edges: [
|
||||
{ sourceNodeID: 'cond_1', sourcePortID: 'if_empty', targetNodeID: 'code_1' } as any,
|
||||
{ sourceNodeID: 'cond_1', sourcePortID: 'if_not_empty', targetNodeID: 'code_1' } as any,
|
||||
],
|
||||
}
|
||||
const y = stringifyFlowDoc(doc)
|
||||
expect(y).toContain('condition: is_empty((ctx["input"]["name"]))')
|
||||
expect(y).toContain('condition: not_empty((ctx["input"]["age"]))')
|
||||
})
|
||||
|
||||
it('structured: contains builds method call', () => {
|
||||
const nodes = baseNodes()
|
||||
;(nodes[1] as any).data.conditions = [
|
||||
{ key: 'if_contains', value: { left: { type: 'ref', content: ['input', 'q'] }, operator: 'contains', right: { type: 'constant', content: 'hi' } } },
|
||||
]
|
||||
const doc: FlowDocumentJSON = {
|
||||
nodes: nodes as any,
|
||||
edges: [
|
||||
{ sourceNodeID: 'cond_1', sourcePortID: 'if_contains', targetNodeID: 'code_1' } as any,
|
||||
],
|
||||
}
|
||||
const y = stringifyFlowDoc(doc)
|
||||
expect(y).toContain('condition: (ctx["input"]["q"]).contains("hi")')
|
||||
})
|
||||
|
||||
it('structured: is_true / is_false, starts_with / ends_with', () => {
|
||||
const nodes = baseNodes()
|
||||
;(nodes[1] as any).data.conditions = [
|
||||
{ key: 'if_true', value: { left: { type: 'ref', content: ['flags', 'ok'] }, operator: 'is_true' } },
|
||||
{ key: 'if_false', value: { left: { type: 'ref', content: ['flags', 'ok'] }, operator: 'is_false' } },
|
||||
{ key: 'if_sw', value: { left: { type: 'ref', content: ['input', 'name'] }, operator: 'starts_with', right: { type: 'constant', content: 'Mr' } } },
|
||||
{ key: 'if_ew', value: { left: { type: 'ref', content: ['input', 'name'] }, operator: 'ends_with', right: { type: 'constant', content: 'Jr' } } },
|
||||
]
|
||||
const doc: FlowDocumentJSON = {
|
||||
nodes: nodes as any,
|
||||
edges: [
|
||||
{ sourceNodeID: 'cond_1', sourcePortID: 'if_true', targetNodeID: 'code_1' } as any,
|
||||
{ sourceNodeID: 'cond_1', sourcePortID: 'if_false', targetNodeID: 'code_1' } as any,
|
||||
{ sourceNodeID: 'cond_1', sourcePortID: 'if_sw', targetNodeID: 'code_1' } as any,
|
||||
{ sourceNodeID: 'cond_1', sourcePortID: 'if_ew', targetNodeID: 'code_1' } as any,
|
||||
],
|
||||
}
|
||||
const y = stringifyFlowDoc(doc)
|
||||
expect(y).toContain('condition: ((ctx["flags"]["ok"])) == true')
|
||||
expect(y).toContain('condition: ((ctx["flags"]["ok"])) == false')
|
||||
expect(y).toContain('condition: (ctx["input"]["name"]).starts_with("Mr")')
|
||||
expect(y).toContain('condition: (ctx["input"]["name"]).ends_with("Jr")')
|
||||
})
|
||||
|
||||
it('structured: regex uses regex_match helper', () => {
|
||||
const nodes = baseNodes()
|
||||
;(nodes[1] as any).data.conditions = [
|
||||
{ key: 'if_regex', value: { left: { type: 'ref', content: ['input', 'email'] }, operator: 'regex', right: { type: 'constant', content: '^.+@example\\.com$' } } },
|
||||
]
|
||||
const doc: FlowDocumentJSON = {
|
||||
nodes: nodes as any,
|
||||
edges: [
|
||||
{ sourceNodeID: 'cond_1', sourcePortID: 'if_regex', targetNodeID: 'code_1' } as any,
|
||||
],
|
||||
}
|
||||
const y = stringifyFlowDoc(doc)
|
||||
// allow one or two backslashes before the dot depending on string escaping
|
||||
expect(y).toMatch(/condition: regex_match\(\(ctx\["input"\]\["email"\]\), "\^\.\+@example\\{1,2}\.com\$"\)/)
|
||||
})
|
||||
|
||||
it('edges & node mapping: only conditional edges have condition, empty expr omitted; node kind mapped to condition', () => {
|
||||
const nodes = baseNodes()
|
||||
;(nodes[1] as any).data.conditions = [
|
||||
{ key: 'if_empty_expr', value: { type: 'expression', content: ' ' } },
|
||||
{ key: 'if_true', value: { left: { type: 'ref', content: ['flags', 'ok'] }, operator: 'is_true' } },
|
||||
]
|
||||
const doc: FlowDocumentJSON = {
|
||||
nodes: nodes as any,
|
||||
edges: [
|
||||
{ sourceNodeID: 'start_1', targetNodeID: 'cond_1' } as any,
|
||||
{ sourceNodeID: 'cond_1', sourcePortID: 'if_empty_expr', targetNodeID: 'code_1' } as any,
|
||||
{ sourceNodeID: 'cond_1', sourcePortID: 'if_true', targetNodeID: 'code_1' } as any,
|
||||
],
|
||||
}
|
||||
const y = stringifyFlowDoc(doc)
|
||||
// node kind mapping
|
||||
expect(y).toContain('\n - id: cond_1\n kind: condition')
|
||||
// only one condition written (the is_true one)
|
||||
const count = (y.match(/\bcondition:/g) || []).length
|
||||
expect(count).toBe(1)
|
||||
})
|
||||
|
||||
it('value building: constant number/boolean/string escaping and ref path', () => {
|
||||
const nodes = baseNodes()
|
||||
;(nodes[1] as any).data.conditions = [
|
||||
{ key: 'num', value: { left: { type: 'ref', content: ['a', 'b'] }, operator: 'eq', right: { type: 'constant', content: 123 } } },
|
||||
{ key: 'bool', value: { left: { type: 'ref', content: ['a', 'ok'] }, operator: 'eq', right: { type: 'constant', content: true } } },
|
||||
{ key: 'str', value: { left: { type: 'ref', content: ['a', 's'] }, operator: 'eq', right: { type: 'constant', content: 'a"b\\c\n' } } },
|
||||
]
|
||||
const doc: FlowDocumentJSON = {
|
||||
nodes: nodes as any,
|
||||
edges: [
|
||||
{ sourceNodeID: 'cond_1', sourcePortID: 'num', targetNodeID: 'code_1' } as any,
|
||||
{ sourceNodeID: 'cond_1', sourcePortID: 'bool', targetNodeID: 'code_1' } as any,
|
||||
{ sourceNodeID: 'cond_1', sourcePortID: 'str', targetNodeID: 'code_1' } as any,
|
||||
],
|
||||
}
|
||||
const y = stringifyFlowDoc(doc)
|
||||
expect(y).toContain('condition: (ctx["a"]["b"]) == (123)')
|
||||
expect(y).toContain('condition: (ctx["a"]["ok"]) == (true)')
|
||||
expect(y).toContain('condition: (ctx["a"]["s"]) == ("a\\"b\\\\c\\n")')
|
||||
})
|
||||
})
|
||||
@ -21,6 +21,9 @@ function mapKindToType(kind: string | undefined): string {
|
||||
return 'llm'
|
||||
case 'code':
|
||||
return 'code'
|
||||
case 'condition':
|
||||
case 'decision':
|
||||
return 'condition'
|
||||
default:
|
||||
return 'code'
|
||||
}
|
||||
@ -41,6 +44,8 @@ function mapTypeToKind(type: string | undefined): string {
|
||||
return 'llm'
|
||||
case 'code':
|
||||
return 'code'
|
||||
case 'condition':
|
||||
return 'condition'
|
||||
default:
|
||||
return 'code'
|
||||
}
|
||||
@ -102,18 +107,111 @@ export function parseFlowYaml(yamlStr: string): { name?: string; doc: FlowDocume
|
||||
return { name, doc }
|
||||
}
|
||||
|
||||
// -------- Condition -> Rhai expression helpers --------
|
||||
function escapeStringLiteral(s: string): string {
|
||||
return '"' + String(s).replace(/\\/g, '\\\\').replace(/\"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r') + '"'
|
||||
}
|
||||
|
||||
function buildRefPath(ref: any): string {
|
||||
// ref.content is like [nodeId, key1, key2, ...]
|
||||
const parts = Array.isArray(ref?.content) ? ref.content : []
|
||||
if (!parts.length) return 'null'
|
||||
// Always use bracket-notation: ctx["node"]["key"]...
|
||||
const segs = parts.map((p: any) => `[${escapeStringLiteral(String(p))}]`).join('')
|
||||
return `ctx${segs}`
|
||||
}
|
||||
|
||||
function buildValueExpr(v: any): string {
|
||||
if (!v) return 'null'
|
||||
const t = v.type || v.kind || ''
|
||||
switch (t) {
|
||||
case 'ref':
|
||||
return buildRefPath(v)
|
||||
case 'constant': {
|
||||
const c = v.content
|
||||
if (typeof c === 'string') return escapeStringLiteral(c)
|
||||
if (typeof c === 'number') return String(c)
|
||||
if (typeof c === 'boolean') return String(c)
|
||||
// fallback stringify
|
||||
try { return escapeStringLiteral(JSON.stringify(c)) } catch { return 'null' }
|
||||
}
|
||||
case 'expression': {
|
||||
const s = (v.content ?? '').trim()
|
||||
return s || 'false'
|
||||
}
|
||||
case 'template': {
|
||||
// For now, treat template as a raw string constant
|
||||
return escapeStringLiteral(String(v.content ?? ''))
|
||||
}
|
||||
default: {
|
||||
// Maybe raw value { content: any }
|
||||
if (v && 'content' in v) {
|
||||
return buildValueExpr({ type: 'constant', content: (v as any).content })
|
||||
}
|
||||
return 'null'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildBinaryOpExpr(op: string, left: string, right?: string): string {
|
||||
switch (op) {
|
||||
case 'eq': return `(${left}) == (${right})`
|
||||
case 'ne': return `(${left}) != (${right})`
|
||||
case 'gt': return `(${left}) > (${right})`
|
||||
case 'lt': return `(${left}) < (${right})`
|
||||
case 'ge': return `(${left}) >= (${right})`
|
||||
case 'le': return `(${left}) <= (${right})`
|
||||
case 'contains': return `(${left}).contains(${right})`
|
||||
case 'starts_with': return `(${left}).starts_with(${right})`
|
||||
case 'ends_with': return `(${left}).ends_with(${right})`
|
||||
case 'regex': return `regex_match((${left}), ${right})`
|
||||
case 'is_true': return `((${left})) == true`
|
||||
case 'is_false': return `((${left})) == false`
|
||||
case 'is_empty': return `is_empty((${left}))`
|
||||
case 'not_empty': return `not_empty((${left}))`
|
||||
default: return 'false'
|
||||
}
|
||||
}
|
||||
|
||||
function conditionToRhaiExpr(value: any): string | undefined {
|
||||
if (!value) return undefined
|
||||
// expression mode
|
||||
if (value.type === 'expression') {
|
||||
const expr = String(value.content ?? '').trim()
|
||||
return expr || undefined
|
||||
}
|
||||
// structured mode: { left, operator, right? }
|
||||
const left = buildValueExpr(value.left)
|
||||
const op = String(value.operator || '').toLowerCase()
|
||||
const right = value.right != null ? buildValueExpr(value.right) : undefined
|
||||
return buildBinaryOpExpr(op, left, right)
|
||||
}
|
||||
|
||||
export function stringifyFlowDoc(doc: FlowDocumentJSON, name?: string): string {
|
||||
const nodeMap = new Map<string, any>((doc.nodes || []).map((n: any) => [n.id, n]))
|
||||
|
||||
const data: any = {
|
||||
...(name ? { name } : {}),
|
||||
nodes: (doc.nodes || []).map((n) => ({
|
||||
nodes: (doc.nodes || []).map((n: any) => ({
|
||||
id: n.id,
|
||||
kind: mapTypeToKind((n as any).type),
|
||||
name: (n as any).data?.title || (n as any).type || 'node',
|
||||
})),
|
||||
edges: (doc.edges || []).map((e) => ({
|
||||
from: (e as any).sourceNodeID,
|
||||
to: (e as any).targetNodeID,
|
||||
kind: mapTypeToKind(n.type),
|
||||
name: n?.data?.title || n?.type || 'node',
|
||||
})),
|
||||
edges: (doc.edges || []).map((e: any) => {
|
||||
const out: any = {
|
||||
from: e.sourceNodeID,
|
||||
to: e.targetNodeID,
|
||||
}
|
||||
const src = nodeMap.get(e.sourceNodeID)
|
||||
if (src?.type === 'condition') {
|
||||
const key = e.sourcePortID
|
||||
const conds: any[] = Array.isArray(src?.data?.conditions) ? src.data.conditions : []
|
||||
const condItem = conds.find((c: any) => c?.key === key)
|
||||
const expr = conditionToRhaiExpr(condItem?.value)
|
||||
if (expr) out.condition = expr
|
||||
}
|
||||
return out
|
||||
}),
|
||||
}
|
||||
return yaml.dump(data, { lineWidth: 120 })
|
||||
}
|
||||
Reference in New Issue
Block a user