feat: enhance workflow editor with new plugins, improved node handling, and UI updates

This commit is contained in:
mlogclub
2026-06-27 21:44:29 +08:00
parent 689acc997c
commit 1472c487ac
7 changed files with 202 additions and 60 deletions
@@ -6,6 +6,8 @@ import {
type FreeLayoutProps,
type WorkflowJSON,
} from "@flowgram.ai/free-layout-editor"
import { createFreeSnapPlugin } from "@flowgram.ai/free-snap-plugin"
import { createMinimapPlugin } from "@flowgram.ai/minimap-plugin"
import type { AIWorkflowDefinition, AIWorkflowNodeSpec } from "@/lib/api/admin"
@@ -29,11 +31,17 @@ export function useFlowgramEditorProps({
readonly,
initialData: definition as WorkflowJSON,
nodeRegistries: buildFlowgramNodeRegistries(nodeSpecs),
fromNodeJSON(_node, json) {
return json
},
toNodeJSON(_node, json) {
return json
},
materials: {
renderDefaultNode: FlowgramNodeRenderer,
},
nodeEngine: {
enable: false,
enable: true,
},
history: {
enable: !readonly,
@@ -61,7 +69,35 @@ export function useFlowgramEditorProps({
},
}
},
plugins: () => [],
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]
)
@@ -1,4 +1,4 @@
import type { WorkflowNodeRegistry } from "@flowgram.ai/free-layout-editor"
import { Field, type WorkflowNodeRegistry } from "@flowgram.ai/free-layout-editor"
import type { AIWorkflowNodeSpec } from "@/lib/api/admin"
@@ -23,13 +23,38 @@ export function buildFlowgramNodeRegistries(nodeSpecs: AIWorkflowNodeSpec[]): Wo
type: spec.type,
meta: {
defaultExpanded: true,
isStart: spec.type === "start",
deleteDisable: spec.type === "start" || spec.type === "end",
copyDisable: spec.type === "start" || spec.type === "end",
defaultPorts: defaultPortsForNodeType(spec.type),
},
formMeta: {
render: () => <FlowgramNodeForm nodeType={spec.type} fallbackTitle={spec.title || spec.type} />,
},
}))
}
function FlowgramNodeForm({
nodeType,
fallbackTitle,
}: {
nodeType: string
fallbackTitle: string
}) {
return (
<div className="min-w-0 flex-1">
<Field<string> name="title">
{({ field }) => (
<div className="truncate text-sm font-medium leading-5">
{field.value || fallbackTitle}
</div>
)}
</Field>
<div className="mt-1 truncate text-xs text-muted-foreground">{nodeType}</div>
</div>
)
}
function defaultPortsForNodeType(type: string) {
if (type === "start") {
return [{ type: "output" as const }]
@@ -34,11 +34,9 @@ const iconByType: Record<string, ComponentType<{ className?: string }>> = {
}
export function FlowgramNodeRenderer(props: WorkflowNodeProps) {
const { selected, node } = useNodeRender()
const { selected, node, form } = useNodeRender()
const nodeType = String(node.flowNodeType ?? "")
const Icon = iconByType[nodeType] ?? BotIcon
const nodeJSON = node.toJSON?.() as { data?: { title?: string }; title?: string } | undefined
const title = nodeJSON?.data?.title || nodeJSON?.title || nodeType || "节点"
return (
<WorkflowNodeRenderer
@@ -53,10 +51,7 @@ export function FlowgramNodeRenderer(props: WorkflowNodeProps) {
<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>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium leading-5">{title}</div>
<div className="mt-1 truncate text-xs text-muted-foreground">{nodeType}</div>
</div>
{form?.render()}
</div>
</WorkflowNodeRenderer>
)
@@ -34,7 +34,7 @@ export function WorkflowConfigSidebar({
: []
return (
<aside className="flex w-80 shrink-0 flex-col border-l bg-background">
<aside className="relative z-50 flex w-80 shrink-0 flex-col border-l bg-background">
<div className="border-b px-3 py-2">
<div className="text-sm font-medium"></div>
</div>
@@ -50,7 +50,7 @@ export function WorkflowEditorToolbar({
publishDisabled?: boolean
}) {
return (
<div className="flex h-10 shrink-0 items-center justify-between border-b bg-background px-2">
<div className="relative z-50 flex h-10 shrink-0 items-center justify-between border-b bg-background px-2">
<div className="flex min-w-0 items-center gap-2">
<span
className={cn(
@@ -2,7 +2,16 @@
import { useEffect, useMemo, useState, type ReactNode } from "react"
import { EditorRenderer, FreeLayoutEditorProvider } from "@flowgram.ai/free-layout-editor"
import {
EditorRenderer,
FreeLayoutEditorProvider,
WorkflowDocument,
WorkflowSelectService,
type WorkflowJSON,
type WorkflowNodeJSON,
useClientContext,
useService,
} from "@flowgram.ai/free-layout-editor"
import type { AIWorkflowDefinition, AIWorkflowNodeSpec } from "@/lib/api/admin"
@@ -54,20 +63,8 @@ export function WorkflowEditor({
toolbarExtra?: ReactNode
}) {
const [localDefinition, setLocalDefinition] = useState(definition)
const [editorKey, setEditorKey] = useState(0)
const [selectedNodeId, setSelectedNodeId] = useState(definition.nodes[0]?.id ?? "")
useEffect(() => {
setLocalDefinition(definition)
setSelectedNodeId((current) => {
if (definition.nodes.some((node) => node.id === current)) {
return current
}
return definition.nodes[0]?.id ?? ""
})
setEditorKey((current) => current + 1)
}, [definition])
const validation = useMemo(
() => validateWorkflowDefinition(localDefinition, nodeSpecs),
[localDefinition, nodeSpecs]
@@ -82,47 +79,138 @@ export function WorkflowEditor({
},
})
const emitDefinition = (next: AIWorkflowDefinition, remountEditor = false) => {
setLocalDefinition(next)
if (remountEditor) {
setEditorKey((current) => current + 1)
return (
<FreeLayoutEditorProvider {...editorProps}>
<WorkflowEditorInner
definition={localDefinition}
nodeSpecs={nodeSpecs}
selectedNodeId={selectedNodeId}
validation={validation}
toolbarExtra={toolbarExtra}
onDefinitionChange={(next) => {
setLocalDefinition(next)
onDefinitionChange(next)
}}
onSelectNode={setSelectedNodeId}
onUndo={onUndo}
undoDisabled={undoDisabled}
onRedo={onRedo}
redoDisabled={redoDisabled}
onRestoreDefault={onRestoreDefault}
restoreDefaultDisabled={restoreDefaultDisabled}
onValidate={onValidate}
validateDisabled={validateDisabled}
onSaveDraft={onSaveDraft}
saveDraftDisabled={saveDraftDisabled}
onPublish={onPublish}
publishDisabled={publishDisabled}
/>
</FreeLayoutEditorProvider>
)
}
function WorkflowEditorInner({
definition,
nodeSpecs,
selectedNodeId,
validation,
toolbarExtra,
onDefinitionChange,
onSelectNode,
onUndo,
undoDisabled,
onRedo,
redoDisabled,
onRestoreDefault,
restoreDefaultDisabled,
onValidate,
validateDisabled,
onSaveDraft,
saveDraftDisabled,
onPublish,
publishDisabled,
}: {
definition: AIWorkflowDefinition
nodeSpecs: AIWorkflowNodeSpec[]
selectedNodeId: string
validation: ReturnType<typeof validateWorkflowDefinition>
toolbarExtra?: ReactNode
onDefinitionChange: (definition: AIWorkflowDefinition) => void
onSelectNode: (nodeId: string) => void
onUndo?: () => void
undoDisabled?: boolean
onRedo?: () => void
redoDisabled?: boolean
onRestoreDefault?: () => void
restoreDefaultDisabled?: boolean
onValidate?: () => void
validateDisabled?: boolean
onSaveDraft?: () => void
saveDraftDisabled?: boolean
onPublish?: () => void
publishDisabled?: boolean
}) {
const context = useClientContext()
const workflowDocument = useService(WorkflowDocument)
const selectService = useService(WorkflowSelectService)
useEffect(() => {
const disposable = selectService.onSelectionChanged(() => {
const selectedNode = selectService.selectedNodes[0]
if (selectedNode) {
onSelectNode(selectedNode.id)
}
})
return () => disposable.dispose()
}, [onSelectNode, selectService])
const emitCurrentDefinition = () => {
onDefinitionChange(context.document.toJSON() as AIWorkflowDefinition)
}
const addNode = async (spec: AIWorkflowNodeSpec) => {
const nextNode = createWorkflowNodeFromSpec(spec, definition.nodes, nextNodePosition(definition))
const created = workflowDocument.createWorkflowNodeByType(
spec.type,
nextNode.meta?.position,
nextNode as WorkflowNodeJSON
)
await selectService.selectNodeAndScrollToView(created)
onSelectNode(created.id)
emitCurrentDefinition()
}
const selectNode = (nodeId: string) => {
const node = workflowDocument.getAllNodes().find((item) => item.id === nodeId)
if (node) {
selectService.selectNodeAndFocus(node)
}
onDefinitionChange(next)
onSelectNode(nodeId)
}
const addNode = (spec: AIWorkflowNodeSpec) => {
const nextNode = createWorkflowNodeFromSpec(spec, localDefinition.nodes, nextNodePosition(localDefinition))
const next = {
...localDefinition,
nodes: [...localDefinition.nodes, nextNode],
}
setSelectedNodeId(nextNode.id)
emitDefinition(next, true)
const updateNodeData = (nodeId: string, data: WorkflowNodeData) => {
const next = updateWorkflowNodeData(definition, nodeId, data)
context.operation.fromJSON(next as WorkflowJSON)
onDefinitionChange(context.document.toJSON() as AIWorkflowDefinition)
}
const updateNodeData = (
nodeId: string,
data: WorkflowNodeData
) => {
emitDefinition(updateWorkflowNodeData(localDefinition, nodeId, data))
}
const deleteNode = (nodeId: string) => {
const next = deleteWorkflowNode(localDefinition, nodeId)
const nextNodes = next.nodes
setSelectedNodeId(nextNodes[0]?.id ?? "")
emitDefinition(next, true)
const removeNode = (nodeId: string) => {
const next = deleteWorkflowNode(definition, nodeId)
context.operation.fromJSON(next as WorkflowJSON)
const nextSelectedNodeId = next.nodes[0]?.id ?? ""
onSelectNode(nextSelectedNodeId)
onDefinitionChange(context.document.toJSON() as AIWorkflowDefinition)
}
return (
<div className="flex min-h-[640px] flex-1 overflow-hidden border bg-background">
<div className="relative isolate flex min-h-[640px] flex-1 overflow-hidden border bg-background">
<WorkflowNodePalette nodeSpecs={nodeSpecs} onAddNode={addNode} />
<div className="flex min-w-0 flex-1 flex-col">
<WorkflowEditorToolbar
validation={validation}
nodeCount={localDefinition.nodes.length}
edgeCount={localDefinition.edges.length}
nodeCount={definition.nodes.length}
edgeCount={definition.edges.length}
toolbarExtra={toolbarExtra}
onUndo={onUndo}
undoDisabled={undoDisabled}
@@ -138,19 +226,17 @@ export function WorkflowEditor({
publishDisabled={publishDisabled}
/>
<div className="min-h-0 flex-1">
<FreeLayoutEditorProvider key={editorKey} {...editorProps}>
<EditorRenderer className="h-full w-full" />
</FreeLayoutEditorProvider>
<EditorRenderer className="h-full w-full" />
</div>
</div>
<WorkflowConfigSidebar
definition={localDefinition}
definition={definition}
nodeSpecs={nodeSpecs}
selectedNodeId={selectedNodeId}
onSelectNode={setSelectedNodeId}
onSelectNode={selectNode}
onChangeNodeData={updateNodeData}
onDeleteNode={deleteNode}
onDeleteNode={removeNode}
/>
</div>
)
@@ -12,7 +12,7 @@ export function WorkflowNodePalette({
onAddNode: (spec: AIWorkflowNodeSpec) => void
}) {
return (
<aside className="flex w-60 shrink-0 flex-col border-r bg-muted/20">
<aside className="relative z-50 flex w-60 shrink-0 flex-col border-r bg-muted/20">
<div className="border-b px-3 py-2">
<div className="text-sm font-medium"></div>
</div>