feat: enhance flowgram editor with condition node handling, port normalization, and UI improvements

This commit is contained in:
mlogclub
2026-06-28 00:08:55 +08:00
parent 0d568ebf87
commit 2bde314820
5 changed files with 394 additions and 111 deletions
@@ -3,7 +3,9 @@
import { useMemo } from "react"
import {
type FreeLayoutPluginContext,
type FreeLayoutProps,
type WorkflowNodeEntity,
type WorkflowJSON,
} from "@flowgram.ai/free-layout-editor"
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 { buildFlowgramNodeRegistries } from "./flowgram-node-registries"
import {
normalizeConditionPortsForFlowgram,
syncConditionBranchTargetsFromEdges,
} from "./workflow-utils"
export function useFlowgramEditorProps({
definition,
@@ -26,79 +32,121 @@ export function useFlowgramEditorProps({
onDefinitionChange?: (definition: AIWorkflowDefinition) => void
}) {
return useMemo<FreeLayoutProps>(
() => ({
background: true,
readonly,
initialData: definition as WorkflowJSON,
nodeRegistries: buildFlowgramNodeRegistries(nodeSpecs),
fromNodeJSON(_node, json) {
return json
},
toNodeJSON(_node, json) {
return json
},
materials: {
renderDefaultNode: FlowgramNodeRenderer,
},
nodeEngine: {
enable: true,
},
history: {
enable: !readonly,
enableChangeNode: !readonly,
},
canDeleteNode: (_ctx, node) => {
const type = String(node.flowNodeType ?? "")
return type !== "start" && type !== "end"
},
canDeleteLine: () => !readonly,
onContentChange: (ctx) => {
if (readonly) {
return
}
onDefinitionChange?.(ctx.document.toJSON() as AIWorkflowDefinition)
},
onAllLayersRendered: (ctx) => {
void ctx.tools.fitView(false)
},
getNodeDefaultRegistry(type) {
return {
type,
meta: {
defaultExpanded: true,
},
}
},
plugins: () => [
createMinimapPlugin({
disableLayer: true,
canvasStyle: {
canvasWidth: 150,
canvasHeight: 84,
canvasPadding: 48,
canvasBackground: "rgba(245, 245, 245, 1)",
canvasBorderRadius: 8,
viewportBackground: "rgba(235, 235, 235, 1)",
viewportBorderRadius: 4,
viewportBorderColor: "rgba(201, 201, 201, 1)",
viewportBorderWidth: 1,
viewportBorderDashLength: 2,
nodeColor: "rgba(255, 255, 255, 1)",
nodeBorderRadius: 2,
nodeBorderWidth: 0.145,
nodeBorderColor: "rgba(6, 7, 9, 0.10)",
overlayColor: "rgba(255, 255, 255, 0)",
},
}),
createFreeSnapPlugin({
edgeColor: "#00B2B2",
alignColor: "#00B2B2",
edgeLineWidth: 1,
alignLineWidth: 1,
alignCrossWidth: 8,
}),
],
}),
() => {
const initialData = normalizeConditionPortsForFlowgram(definition)
return {
background: true,
readonly,
scroll: {
disableScrollBar: true,
},
initialData: initialData as WorkflowJSON,
nodeRegistries: buildFlowgramNodeRegistries(nodeSpecs),
fromNodeJSON(_node, json) {
return json
},
toNodeJSON(_node, json) {
return json
},
materials: {
renderDefaultNode: FlowgramNodeRenderer,
},
nodeEngine: {
enable: true,
},
history: {
enable: !readonly,
enableChangeNode: !readonly,
},
canDeleteNode: (_ctx, node) => {
const type = String(node.flowNodeType ?? "")
return type !== "start" && type !== "end"
},
canDeleteLine: () => !readonly,
onContentChange: (ctx) => {
if (readonly) {
return
}
const next = normalizeConditionPortsForFlowgram(
syncConditionBranchTargetsFromEdges(ctx.document.toJSON() as AIWorkflowDefinition)
)
onDefinitionChange?.(next)
},
onAllLayersRendered: (ctx) => {
scrollToInitialNode(ctx)
},
getNodeDefaultRegistry(type) {
return {
type,
meta: {
defaultExpanded: true,
},
}
},
plugins: () => [
createMinimapPlugin({
disableLayer: true,
canvasStyle: {
canvasWidth: 150,
canvasHeight: 84,
canvasPadding: 48,
canvasBackground: "rgba(245, 245, 245, 1)",
canvasBorderRadius: 8,
viewportBackground: "rgba(235, 235, 235, 1)",
viewportBorderRadius: 4,
viewportBorderColor: "rgba(201, 201, 201, 1)",
viewportBorderWidth: 1,
viewportBorderDashLength: 2,
nodeColor: "rgba(255, 255, 255, 1)",
nodeBorderRadius: 2,
nodeBorderWidth: 0.145,
nodeBorderColor: "rgba(6, 7, 9, 0.10)",
overlayColor: "rgba(255, 255, 255, 0)",
},
}),
createFreeSnapPlugin({
edgeColor: "#00B2B2",
alignColor: "#00B2B2",
edgeLineWidth: 1,
alignLineWidth: 1,
alignCrossWidth: 8,
}),
],
}
},
[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 { PlusIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import type { AIWorkflowNodeSpec } from "@/lib/api/admin"
import {
createConditionBranchID,
normalizeNodeConfig,
type WorkflowConditionBranch,
} from "./workflow-utils"
export function buildFlowgramNodeRegistries(nodeSpecs: AIWorkflowNodeSpec[]): WorkflowNodeRegistry[] {
const seen = new Set<string>()
@@ -41,16 +48,20 @@ function FlowgramNodeForm({
nodeType: string
fallbackTitle: string
}) {
if (nodeType === "condition") {
return <ConditionNodeForm fallbackTitle={fallbackTitle} />
}
return (
<div className="min-w-0 flex-1">
<div className="flex w-full flex-col">
<Field<string> name="title">
{({ 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}
</div>
)}
</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>
)
}
@@ -62,5 +73,94 @@ function defaultPortsForNodeType(type: string) {
if (type === "end") {
return [{ type: "input" as const }]
}
if (type === "condition") {
return [{ type: "input" 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,
type WorkflowNodeProps,
} from "@flowgram.ai/free-layout-editor"
import {
BotIcon,
CircleStopIcon,
DatabaseIcon,
GitBranchIcon,
MessageSquareTextIcon,
SendIcon,
UserRoundIcon,
} from "lucide-react"
import type { ComponentType } from "react"
import { useLayoutEffect } from "react"
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) {
const { selected, node, form } = useNodeRender()
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 (
<WorkflowNodeRenderer
node={props.node}
className={cn(
"w-[260px] rounded-md border bg-background shadow-sm transition-colors",
selected ? "border-primary ring-2 ring-primary/15" : "border-border"
"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-[#4e40e5]" : "border-[rgba(6,7,9,0.15)]"
)}
style={{ padding: 0 }}
portPrimaryColor="#4e40e5"
portSecondaryColor="#d0d5dd"
portBackgroundColor="#fff"
>
<div className="flex items-start gap-3 p-3">
<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>
{form?.render()}
</WorkflowNodeRenderer>
)
}
@@ -303,4 +303,68 @@ describe("workflow definition mutations", () => {
const deleted = deleteConditionBranch(updated, "condition_1", "default")
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
): AIWorkflowDefinition["nodes"][number] {
const id = uniqueNodeId(existingNodes, spec.type)
const defaultConfig = spec.type === "condition"
? { branches: [{ id: "default", name: "默认分支", targetNodeId: "", default: true }] }
: {}
return {
id,
type: spec.type,
meta: { position },
data: {
title: spec.title || spec.type,
config: {},
config: defaultConfig,
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 {
if (!config || typeof config !== "object" || Array.isArray(config)) {
return {}
@@ -263,6 +328,31 @@ export function normalizeNodeConfig(config: unknown): 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[]) {
const existingIDs = new Set(existingBranches.map((branch) => branch.id))
for (let index = 1; index < 10000; index++) {