feat: enhance flowgram editor with condition node handling, port normalization, and UI improvements
This commit is contained in:
@@ -3,7 +3,9 @@
|
|||||||
import { useMemo } from "react"
|
import { useMemo } from "react"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
type FreeLayoutPluginContext,
|
||||||
type FreeLayoutProps,
|
type FreeLayoutProps,
|
||||||
|
type WorkflowNodeEntity,
|
||||||
type WorkflowJSON,
|
type WorkflowJSON,
|
||||||
} from "@flowgram.ai/free-layout-editor"
|
} from "@flowgram.ai/free-layout-editor"
|
||||||
import { createFreeSnapPlugin } from "@flowgram.ai/free-snap-plugin"
|
import { createFreeSnapPlugin } from "@flowgram.ai/free-snap-plugin"
|
||||||
@@ -13,6 +15,10 @@ import type { AIWorkflowDefinition, AIWorkflowNodeSpec } from "@/lib/api/admin"
|
|||||||
|
|
||||||
import { FlowgramNodeRenderer } from "./flowgram-node-renderer"
|
import { FlowgramNodeRenderer } from "./flowgram-node-renderer"
|
||||||
import { buildFlowgramNodeRegistries } from "./flowgram-node-registries"
|
import { buildFlowgramNodeRegistries } from "./flowgram-node-registries"
|
||||||
|
import {
|
||||||
|
normalizeConditionPortsForFlowgram,
|
||||||
|
syncConditionBranchTargetsFromEdges,
|
||||||
|
} from "./workflow-utils"
|
||||||
|
|
||||||
export function useFlowgramEditorProps({
|
export function useFlowgramEditorProps({
|
||||||
definition,
|
definition,
|
||||||
@@ -26,79 +32,121 @@ export function useFlowgramEditorProps({
|
|||||||
onDefinitionChange?: (definition: AIWorkflowDefinition) => void
|
onDefinitionChange?: (definition: AIWorkflowDefinition) => void
|
||||||
}) {
|
}) {
|
||||||
return useMemo<FreeLayoutProps>(
|
return useMemo<FreeLayoutProps>(
|
||||||
() => ({
|
() => {
|
||||||
background: true,
|
const initialData = normalizeConditionPortsForFlowgram(definition)
|
||||||
readonly,
|
return {
|
||||||
initialData: definition as WorkflowJSON,
|
background: true,
|
||||||
nodeRegistries: buildFlowgramNodeRegistries(nodeSpecs),
|
readonly,
|
||||||
fromNodeJSON(_node, json) {
|
scroll: {
|
||||||
return json
|
disableScrollBar: true,
|
||||||
},
|
},
|
||||||
toNodeJSON(_node, json) {
|
initialData: initialData as WorkflowJSON,
|
||||||
return json
|
nodeRegistries: buildFlowgramNodeRegistries(nodeSpecs),
|
||||||
},
|
fromNodeJSON(_node, json) {
|
||||||
materials: {
|
return json
|
||||||
renderDefaultNode: FlowgramNodeRenderer,
|
},
|
||||||
},
|
toNodeJSON(_node, json) {
|
||||||
nodeEngine: {
|
return json
|
||||||
enable: true,
|
},
|
||||||
},
|
materials: {
|
||||||
history: {
|
renderDefaultNode: FlowgramNodeRenderer,
|
||||||
enable: !readonly,
|
},
|
||||||
enableChangeNode: !readonly,
|
nodeEngine: {
|
||||||
},
|
enable: true,
|
||||||
canDeleteNode: (_ctx, node) => {
|
},
|
||||||
const type = String(node.flowNodeType ?? "")
|
history: {
|
||||||
return type !== "start" && type !== "end"
|
enable: !readonly,
|
||||||
},
|
enableChangeNode: !readonly,
|
||||||
canDeleteLine: () => !readonly,
|
},
|
||||||
onContentChange: (ctx) => {
|
canDeleteNode: (_ctx, node) => {
|
||||||
if (readonly) {
|
const type = String(node.flowNodeType ?? "")
|
||||||
return
|
return type !== "start" && type !== "end"
|
||||||
}
|
},
|
||||||
onDefinitionChange?.(ctx.document.toJSON() as AIWorkflowDefinition)
|
canDeleteLine: () => !readonly,
|
||||||
},
|
onContentChange: (ctx) => {
|
||||||
onAllLayersRendered: (ctx) => {
|
if (readonly) {
|
||||||
void ctx.tools.fitView(false)
|
return
|
||||||
},
|
}
|
||||||
getNodeDefaultRegistry(type) {
|
const next = normalizeConditionPortsForFlowgram(
|
||||||
return {
|
syncConditionBranchTargetsFromEdges(ctx.document.toJSON() as AIWorkflowDefinition)
|
||||||
type,
|
)
|
||||||
meta: {
|
onDefinitionChange?.(next)
|
||||||
defaultExpanded: true,
|
},
|
||||||
},
|
onAllLayersRendered: (ctx) => {
|
||||||
}
|
scrollToInitialNode(ctx)
|
||||||
},
|
},
|
||||||
plugins: () => [
|
getNodeDefaultRegistry(type) {
|
||||||
createMinimapPlugin({
|
return {
|
||||||
disableLayer: true,
|
type,
|
||||||
canvasStyle: {
|
meta: {
|
||||||
canvasWidth: 150,
|
defaultExpanded: true,
|
||||||
canvasHeight: 84,
|
},
|
||||||
canvasPadding: 48,
|
}
|
||||||
canvasBackground: "rgba(245, 245, 245, 1)",
|
},
|
||||||
canvasBorderRadius: 8,
|
plugins: () => [
|
||||||
viewportBackground: "rgba(235, 235, 235, 1)",
|
createMinimapPlugin({
|
||||||
viewportBorderRadius: 4,
|
disableLayer: true,
|
||||||
viewportBorderColor: "rgba(201, 201, 201, 1)",
|
canvasStyle: {
|
||||||
viewportBorderWidth: 1,
|
canvasWidth: 150,
|
||||||
viewportBorderDashLength: 2,
|
canvasHeight: 84,
|
||||||
nodeColor: "rgba(255, 255, 255, 1)",
|
canvasPadding: 48,
|
||||||
nodeBorderRadius: 2,
|
canvasBackground: "rgba(245, 245, 245, 1)",
|
||||||
nodeBorderWidth: 0.145,
|
canvasBorderRadius: 8,
|
||||||
nodeBorderColor: "rgba(6, 7, 9, 0.10)",
|
viewportBackground: "rgba(235, 235, 235, 1)",
|
||||||
overlayColor: "rgba(255, 255, 255, 0)",
|
viewportBorderRadius: 4,
|
||||||
},
|
viewportBorderColor: "rgba(201, 201, 201, 1)",
|
||||||
}),
|
viewportBorderWidth: 1,
|
||||||
createFreeSnapPlugin({
|
viewportBorderDashLength: 2,
|
||||||
edgeColor: "#00B2B2",
|
nodeColor: "rgba(255, 255, 255, 1)",
|
||||||
alignColor: "#00B2B2",
|
nodeBorderRadius: 2,
|
||||||
edgeLineWidth: 1,
|
nodeBorderWidth: 0.145,
|
||||||
alignLineWidth: 1,
|
nodeBorderColor: "rgba(6, 7, 9, 0.10)",
|
||||||
alignCrossWidth: 8,
|
overlayColor: "rgba(255, 255, 255, 0)",
|
||||||
}),
|
},
|
||||||
],
|
}),
|
||||||
}),
|
createFreeSnapPlugin({
|
||||||
|
edgeColor: "#00B2B2",
|
||||||
|
alignColor: "#00B2B2",
|
||||||
|
edgeLineWidth: 1,
|
||||||
|
alignLineWidth: 1,
|
||||||
|
alignCrossWidth: 8,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
[definition, nodeSpecs, onDefinitionChange, readonly]
|
[definition, nodeSpecs, onDefinitionChange, readonly]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scrollToInitialNode(ctx: FreeLayoutPluginContext) {
|
||||||
|
const nodes = ctx.document.getAllNodes()
|
||||||
|
const startNode = nodes.find((node) => String(node.flowNodeType ?? "") === "start")
|
||||||
|
const targetNode = startNode ?? findLeftTopNode(nodes)
|
||||||
|
if (!targetNode) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
const viewport = ctx.playground.config.getViewport(false)
|
||||||
|
void ctx.playground.scrollToView({
|
||||||
|
bounds: targetNode.transform.bounds,
|
||||||
|
scrollDelta: {
|
||||||
|
x: Math.max(viewport.width / 2 - 250, 0),
|
||||||
|
y: Math.max(viewport.height / 2 - 180, 0),
|
||||||
|
},
|
||||||
|
zoom: 1,
|
||||||
|
scrollToCenter: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLeftTopNode(nodes: WorkflowNodeEntity[]) {
|
||||||
|
return [...nodes].sort((left, right) => {
|
||||||
|
const leftBounds = left.transform.bounds
|
||||||
|
const rightBounds = right.transform.bounds
|
||||||
|
if (leftBounds.left !== rightBounds.left) {
|
||||||
|
return leftBounds.left - rightBounds.left
|
||||||
|
}
|
||||||
|
return leftBounds.top - rightBounds.top
|
||||||
|
})[0]
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { Field, type WorkflowNodeRegistry } from "@flowgram.ai/free-layout-editor"
|
import { Field, type WorkflowNodeRegistry } from "@flowgram.ai/free-layout-editor"
|
||||||
|
import { PlusIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
import type { AIWorkflowNodeSpec } from "@/lib/api/admin"
|
import type { AIWorkflowNodeSpec } from "@/lib/api/admin"
|
||||||
|
import {
|
||||||
|
createConditionBranchID,
|
||||||
|
normalizeNodeConfig,
|
||||||
|
type WorkflowConditionBranch,
|
||||||
|
} from "./workflow-utils"
|
||||||
|
|
||||||
export function buildFlowgramNodeRegistries(nodeSpecs: AIWorkflowNodeSpec[]): WorkflowNodeRegistry[] {
|
export function buildFlowgramNodeRegistries(nodeSpecs: AIWorkflowNodeSpec[]): WorkflowNodeRegistry[] {
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
@@ -41,16 +48,20 @@ function FlowgramNodeForm({
|
|||||||
nodeType: string
|
nodeType: string
|
||||||
fallbackTitle: string
|
fallbackTitle: string
|
||||||
}) {
|
}) {
|
||||||
|
if (nodeType === "condition") {
|
||||||
|
return <ConditionNodeForm fallbackTitle={fallbackTitle} />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0 flex-1">
|
<div className="flex w-full flex-col">
|
||||||
<Field<string> name="title">
|
<Field<string> name="title">
|
||||||
{({ field }) => (
|
{({ field }) => (
|
||||||
<div className="truncate text-sm font-medium leading-5">
|
<div className="border-b px-4 py-3 text-sm font-medium leading-5">
|
||||||
{field.value || fallbackTitle}
|
{field.value || fallbackTitle}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
<div className="mt-1 truncate text-xs text-muted-foreground">{nodeType}</div>
|
<div className="px-4 py-3 text-xs text-muted-foreground">{nodeType}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -62,5 +73,94 @@ function defaultPortsForNodeType(type: string) {
|
|||||||
if (type === "end") {
|
if (type === "end") {
|
||||||
return [{ type: "input" as const }]
|
return [{ type: "input" as const }]
|
||||||
}
|
}
|
||||||
|
if (type === "condition") {
|
||||||
|
return [{ type: "input" as const }]
|
||||||
|
}
|
||||||
return [{ type: "input" as const }, { type: "output" as const }]
|
return [{ type: "input" as const }, { type: "output" as const }]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ConditionNodeForm({ fallbackTitle }: { fallbackTitle: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col">
|
||||||
|
<Field<string> name="title">
|
||||||
|
{({ field }) => (
|
||||||
|
<div className="border-b px-4 py-3 text-sm font-medium leading-5">
|
||||||
|
{field.value || fallbackTitle}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field<Record<string, unknown>> name="config">
|
||||||
|
{({ field }) => {
|
||||||
|
const config = normalizeNodeConfig(field.value)
|
||||||
|
const branches = ensureConditionBranches(config.branches ?? [])
|
||||||
|
const updateBranches = (nextBranches: WorkflowConditionBranch[]) => {
|
||||||
|
field.onChange({
|
||||||
|
...config,
|
||||||
|
branches: ensureConditionBranches(nextBranches),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{branches.map((branch) => (
|
||||||
|
<div
|
||||||
|
key={branch.id}
|
||||||
|
className="flex min-h-9 items-center gap-2 rounded-md bg-muted/40 px-3 text-xs hover:bg-muted/70"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate">
|
||||||
|
{branch.name || (branch.default ? "默认分支" : branch.id)}
|
||||||
|
</span>
|
||||||
|
{branch.default ? (
|
||||||
|
<span className="shrink-0 rounded bg-background px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||||
|
else
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span
|
||||||
|
data-port-id={branch.id}
|
||||||
|
data-port-type="output"
|
||||||
|
className="flex size-4 shrink-0 items-center justify-center rounded-full border border-[#4e40e5]/70 bg-background"
|
||||||
|
>
|
||||||
|
<span className="size-2 rounded-full bg-[#4e40e5]" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="mt-2 h-7 px-2 text-xs text-muted-foreground"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
updateBranches([
|
||||||
|
...branches,
|
||||||
|
{
|
||||||
|
id: createConditionBranchID(branches),
|
||||||
|
name: "新条件",
|
||||||
|
targetNodeId: "",
|
||||||
|
condition: { operator: "eq" },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-3.5" />
|
||||||
|
添加条件
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureConditionBranches(branches: WorkflowConditionBranch[]) {
|
||||||
|
const normalized = branches.some((branch) => branch.default)
|
||||||
|
? branches
|
||||||
|
: [...branches, { id: "default", name: "默认分支", targetNodeId: "", default: true }]
|
||||||
|
return [
|
||||||
|
...normalized.filter((branch) => !branch.default),
|
||||||
|
...normalized.filter((branch) => branch.default).slice(0, 1),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,54 +5,35 @@ import {
|
|||||||
WorkflowNodeRenderer,
|
WorkflowNodeRenderer,
|
||||||
type WorkflowNodeProps,
|
type WorkflowNodeProps,
|
||||||
} from "@flowgram.ai/free-layout-editor"
|
} from "@flowgram.ai/free-layout-editor"
|
||||||
import {
|
import { useLayoutEffect } from "react"
|
||||||
BotIcon,
|
|
||||||
CircleStopIcon,
|
|
||||||
DatabaseIcon,
|
|
||||||
GitBranchIcon,
|
|
||||||
MessageSquareTextIcon,
|
|
||||||
SendIcon,
|
|
||||||
UserRoundIcon,
|
|
||||||
} from "lucide-react"
|
|
||||||
import type { ComponentType } from "react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const iconByType: Record<string, ComponentType<{ className?: string }>> = {
|
|
||||||
start: UserRoundIcon,
|
|
||||||
conversation_understanding: BotIcon,
|
|
||||||
reply_policy: MessageSquareTextIcon,
|
|
||||||
condition: GitBranchIcon,
|
|
||||||
knowledge_retrieve: DatabaseIcon,
|
|
||||||
answerability_gate: GitBranchIcon,
|
|
||||||
llm_reply: BotIcon,
|
|
||||||
human_confirm: UserRoundIcon,
|
|
||||||
create_ticket: MessageSquareTextIcon,
|
|
||||||
handoff_to_human: UserRoundIcon,
|
|
||||||
send_reply: SendIcon,
|
|
||||||
end: CircleStopIcon,
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FlowgramNodeRenderer(props: WorkflowNodeProps) {
|
export function FlowgramNodeRenderer(props: WorkflowNodeProps) {
|
||||||
const { selected, node, form } = useNodeRender()
|
const { selected, node, form } = useNodeRender()
|
||||||
const nodeType = String(node.flowNodeType ?? "")
|
const nodeType = String(node.flowNodeType ?? "")
|
||||||
const Icon = iconByType[nodeType] ?? BotIcon
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (nodeType !== "condition") return
|
||||||
|
const frame = window.requestAnimationFrame(() => {
|
||||||
|
node.ports.updateDynamicPorts()
|
||||||
|
})
|
||||||
|
return () => window.cancelAnimationFrame(frame)
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkflowNodeRenderer
|
<WorkflowNodeRenderer
|
||||||
node={props.node}
|
node={props.node}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-[260px] rounded-md border bg-background shadow-sm transition-colors",
|
"w-[360px] overflow-hidden rounded-lg border bg-background shadow-[0_2px_6px_rgba(0,0,0,0.04),0_4px_12px_rgba(0,0,0,0.02)] transition-colors",
|
||||||
selected ? "border-primary ring-2 ring-primary/15" : "border-border"
|
selected ? "border-[#4e40e5]" : "border-[rgba(6,7,9,0.15)]"
|
||||||
)}
|
)}
|
||||||
style={{ padding: 0 }}
|
style={{ padding: 0 }}
|
||||||
|
portPrimaryColor="#4e40e5"
|
||||||
|
portSecondaryColor="#d0d5dd"
|
||||||
|
portBackgroundColor="#fff"
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3 p-3">
|
{form?.render()}
|
||||||
<div className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md border bg-muted">
|
|
||||||
<Icon className="size-4 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
{form?.render()}
|
|
||||||
</div>
|
|
||||||
</WorkflowNodeRenderer>
|
</WorkflowNodeRenderer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -303,4 +303,68 @@ describe("workflow definition mutations", () => {
|
|||||||
const deleted = deleteConditionBranch(updated, "condition_1", "default")
|
const deleted = deleteConditionBranch(updated, "condition_1", "default")
|
||||||
assert.deepEqual(plain(deleted.nodes[0].data.config.branches.map((branch) => branch.id)), ["vip"])
|
assert.deepEqual(plain(deleted.nodes[0].data.config.branches.map((branch) => branch.id)), ["vip"])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("adds FlowGram source ports for condition edges without removing existing lines", async () => {
|
||||||
|
const { normalizeConditionPortsForFlowgram } = await loadModule()
|
||||||
|
const definition = {
|
||||||
|
schemaVersion: 2,
|
||||||
|
nodes: [
|
||||||
|
workflowNode("condition_1", "condition", { x: 240, y: 0 }, {
|
||||||
|
config: {
|
||||||
|
branches: [
|
||||||
|
{ id: "vip", name: "VIP", targetNodeId: "vip_reply", condition: { operator: "eq" } },
|
||||||
|
{ id: "default", name: "默认", targetNodeId: "normal_reply", default: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
workflowNode("vip_reply", "llm_reply", { x: 520, y: 0 }),
|
||||||
|
workflowNode("normal_reply", "llm_reply", { x: 520, y: 120 }),
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
workflowEdge("condition_1", "vip_reply"),
|
||||||
|
workflowEdge("condition_1", "normal_reply"),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = normalizeConditionPortsForFlowgram(definition)
|
||||||
|
|
||||||
|
assert.deepEqual(plain(next.nodes[0].data.portKeys), ["vip", "default"])
|
||||||
|
assert.deepEqual(plain(next.nodes[0].data.ports), ["vip", "default"])
|
||||||
|
assert.deepEqual(plain(next.edges), [
|
||||||
|
workflowEdge("condition_1", "vip_reply", { sourcePortID: "vip" }),
|
||||||
|
workflowEdge("condition_1", "normal_reply", { sourcePortID: "default" }),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("syncs branch targets from condition source ports while preserving unrelated edges", async () => {
|
||||||
|
const { syncConditionBranchTargetsFromEdges } = await loadModule()
|
||||||
|
const definition = {
|
||||||
|
schemaVersion: 2,
|
||||||
|
nodes: [
|
||||||
|
workflowNode("condition_1", "condition", { x: 240, y: 0 }, {
|
||||||
|
config: {
|
||||||
|
branches: [
|
||||||
|
{ id: "vip", name: "VIP", targetNodeId: "", condition: { operator: "eq" } },
|
||||||
|
{ id: "default", name: "默认", targetNodeId: "", default: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
portKeys: ["vip", "default"],
|
||||||
|
ports: ["vip", "default"],
|
||||||
|
}),
|
||||||
|
workflowNode("vip_reply", "llm_reply", { x: 520, y: 0 }),
|
||||||
|
workflowNode("normal_reply", "llm_reply", { x: 520, y: 120 }),
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
workflowEdge("condition_1", "vip_reply", { sourcePortID: "vip" }),
|
||||||
|
workflowEdge("condition_1", "normal_reply", { sourcePortID: "default" }),
|
||||||
|
workflowEdge("vip_reply", "normal_reply"),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = syncConditionBranchTargetsFromEdges(definition)
|
||||||
|
|
||||||
|
assert.equal(next.nodes[0].data.config.branches[0].targetNodeId, "vip_reply")
|
||||||
|
assert.equal(next.nodes[0].data.config.branches[1].targetNodeId, "normal_reply")
|
||||||
|
assert.equal(next.edges.length, 3)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -166,13 +166,16 @@ export function createWorkflowNodeFromSpec(
|
|||||||
position: WorkflowNodePosition
|
position: WorkflowNodePosition
|
||||||
): AIWorkflowDefinition["nodes"][number] {
|
): AIWorkflowDefinition["nodes"][number] {
|
||||||
const id = uniqueNodeId(existingNodes, spec.type)
|
const id = uniqueNodeId(existingNodes, spec.type)
|
||||||
|
const defaultConfig = spec.type === "condition"
|
||||||
|
? { branches: [{ id: "default", name: "默认分支", targetNodeId: "", default: true }] }
|
||||||
|
: {}
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
type: spec.type,
|
type: spec.type,
|
||||||
meta: { position },
|
meta: { position },
|
||||||
data: {
|
data: {
|
||||||
title: spec.title || spec.type,
|
title: spec.title || spec.type,
|
||||||
config: {},
|
config: defaultConfig,
|
||||||
inputsValues: spec.defaultInputs ?? {},
|
inputsValues: spec.defaultInputs ?? {},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -247,6 +250,68 @@ export function deleteConditionBranch(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeConditionPortsForFlowgram(
|
||||||
|
definition: AIWorkflowDefinition
|
||||||
|
): AIWorkflowDefinition {
|
||||||
|
return {
|
||||||
|
...definition,
|
||||||
|
nodes: definition.nodes.map((node) => {
|
||||||
|
if (node.type !== "condition") {
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
const config = normalizeNodeConfig(node.data?.config)
|
||||||
|
const branches = ensureConditionBranches(config.branches ?? [])
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...(node.data ?? {}),
|
||||||
|
config: { ...config, branches },
|
||||||
|
portKeys: branches.map((branch) => branch.id),
|
||||||
|
ports: branches.map((branch) => branch.id),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
edges: definition.edges.map((edge) => {
|
||||||
|
const source = definition.nodes.find((node) => node.id === edge.sourceNodeID)
|
||||||
|
if (!source || source.type !== "condition" || edge.sourcePortID) {
|
||||||
|
return edge
|
||||||
|
}
|
||||||
|
const branch = findConditionBranchForTarget(source, edge.targetNodeID)
|
||||||
|
return branch ? { ...edge, sourcePortID: branch.id } : edge
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncConditionBranchTargetsFromEdges(
|
||||||
|
definition: AIWorkflowDefinition
|
||||||
|
): AIWorkflowDefinition {
|
||||||
|
return {
|
||||||
|
...definition,
|
||||||
|
nodes: definition.nodes.map((node) => {
|
||||||
|
if (node.type !== "condition") {
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
const config = normalizeNodeConfig(node.data?.config)
|
||||||
|
const branches = ensureConditionBranches(config.branches ?? [])
|
||||||
|
const nextBranches = branches.map((branch) => {
|
||||||
|
const edge = definition.edges.find((item) => (
|
||||||
|
item.sourceNodeID === node.id && item.sourcePortID === branch.id
|
||||||
|
))
|
||||||
|
return edge ? { ...branch, targetNodeId: edge.targetNodeID } : branch
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...(node.data ?? {}),
|
||||||
|
config: { ...config, branches: nextBranches },
|
||||||
|
portKeys: nextBranches.map((branch) => branch.id),
|
||||||
|
ports: nextBranches.map((branch) => branch.id),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeNodeConfig(config: unknown): WorkflowNodeConfig {
|
export function normalizeNodeConfig(config: unknown): WorkflowNodeConfig {
|
||||||
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
||||||
return {}
|
return {}
|
||||||
@@ -263,6 +328,31 @@ export function normalizeNodeConfig(config: unknown): WorkflowNodeConfig {
|
|||||||
} as WorkflowNodeConfig
|
} as WorkflowNodeConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureConditionBranches(branches: WorkflowConditionBranch[]) {
|
||||||
|
if (branches.some((branch) => branch.default)) {
|
||||||
|
return orderConditionBranches(branches)
|
||||||
|
}
|
||||||
|
return orderConditionBranches([
|
||||||
|
...branches,
|
||||||
|
{ id: "default", name: "默认分支", targetNodeId: "", default: true },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderConditionBranches(branches: WorkflowConditionBranch[]) {
|
||||||
|
return [
|
||||||
|
...branches.filter((branch) => !branch.default),
|
||||||
|
...branches.filter((branch) => branch.default).slice(0, 1),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function findConditionBranchForTarget(
|
||||||
|
node: AIWorkflowDefinition["nodes"][number],
|
||||||
|
targetNodeId: string
|
||||||
|
) {
|
||||||
|
const branches = ensureConditionBranches(normalizeNodeConfig(node.data?.config).branches ?? [])
|
||||||
|
return branches.find((branch) => branch.targetNodeId === targetNodeId)
|
||||||
|
}
|
||||||
|
|
||||||
export function createConditionBranchID(existingBranches: WorkflowConditionBranch[]) {
|
export function createConditionBranchID(existingBranches: WorkflowConditionBranch[]) {
|
||||||
const existingIDs = new Set(existingBranches.map((branch) => branch.id))
|
const existingIDs = new Set(existingBranches.map((branch) => branch.id))
|
||||||
for (let index = 1; index < 10000; index++) {
|
for (let index = 1; index < 10000; index++) {
|
||||||
|
|||||||
Reference in New Issue
Block a user