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 { 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++) {