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:
2025-09-19 13:41:52 +08:00
parent 81757eecf5
commit 62789fce42
25 changed files with 1651 additions and 313 deletions

View File

@ -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>

View File

@ -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': '全局',

View File

@ -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

View File

@ -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

View 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")')
})
})

View File

@ -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 })
}