feat: enhance workflow editor with new plugins, improved node handling, and UI updates
This commit is contained in:
@@ -6,6 +6,8 @@ import {
|
|||||||
type FreeLayoutProps,
|
type FreeLayoutProps,
|
||||||
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 { createMinimapPlugin } from "@flowgram.ai/minimap-plugin"
|
||||||
|
|
||||||
import type { AIWorkflowDefinition, AIWorkflowNodeSpec } from "@/lib/api/admin"
|
import type { AIWorkflowDefinition, AIWorkflowNodeSpec } from "@/lib/api/admin"
|
||||||
|
|
||||||
@@ -29,11 +31,17 @@ export function useFlowgramEditorProps({
|
|||||||
readonly,
|
readonly,
|
||||||
initialData: definition as WorkflowJSON,
|
initialData: definition as WorkflowJSON,
|
||||||
nodeRegistries: buildFlowgramNodeRegistries(nodeSpecs),
|
nodeRegistries: buildFlowgramNodeRegistries(nodeSpecs),
|
||||||
|
fromNodeJSON(_node, json) {
|
||||||
|
return json
|
||||||
|
},
|
||||||
|
toNodeJSON(_node, json) {
|
||||||
|
return json
|
||||||
|
},
|
||||||
materials: {
|
materials: {
|
||||||
renderDefaultNode: FlowgramNodeRenderer,
|
renderDefaultNode: FlowgramNodeRenderer,
|
||||||
},
|
},
|
||||||
nodeEngine: {
|
nodeEngine: {
|
||||||
enable: false,
|
enable: true,
|
||||||
},
|
},
|
||||||
history: {
|
history: {
|
||||||
enable: !readonly,
|
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]
|
[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"
|
import type { AIWorkflowNodeSpec } from "@/lib/api/admin"
|
||||||
|
|
||||||
@@ -23,13 +23,38 @@ export function buildFlowgramNodeRegistries(nodeSpecs: AIWorkflowNodeSpec[]): Wo
|
|||||||
type: spec.type,
|
type: spec.type,
|
||||||
meta: {
|
meta: {
|
||||||
defaultExpanded: true,
|
defaultExpanded: true,
|
||||||
|
isStart: spec.type === "start",
|
||||||
deleteDisable: spec.type === "start" || spec.type === "end",
|
deleteDisable: spec.type === "start" || spec.type === "end",
|
||||||
copyDisable: spec.type === "start" || spec.type === "end",
|
copyDisable: spec.type === "start" || spec.type === "end",
|
||||||
defaultPorts: defaultPortsForNodeType(spec.type),
|
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) {
|
function defaultPortsForNodeType(type: string) {
|
||||||
if (type === "start") {
|
if (type === "start") {
|
||||||
return [{ type: "output" as const }]
|
return [{ type: "output" as const }]
|
||||||
|
|||||||
@@ -34,11 +34,9 @@ const iconByType: Record<string, ComponentType<{ className?: string }>> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FlowgramNodeRenderer(props: WorkflowNodeProps) {
|
export function FlowgramNodeRenderer(props: WorkflowNodeProps) {
|
||||||
const { selected, node } = useNodeRender()
|
const { selected, node, form } = useNodeRender()
|
||||||
const nodeType = String(node.flowNodeType ?? "")
|
const nodeType = String(node.flowNodeType ?? "")
|
||||||
const Icon = iconByType[nodeType] ?? BotIcon
|
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 (
|
return (
|
||||||
<WorkflowNodeRenderer
|
<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">
|
<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" />
|
<Icon className="size-4 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
{form?.render()}
|
||||||
<div className="truncate text-sm font-medium leading-5">{title}</div>
|
|
||||||
<div className="mt-1 truncate text-xs text-muted-foreground">{nodeType}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</WorkflowNodeRenderer>
|
</WorkflowNodeRenderer>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function WorkflowConfigSidebar({
|
|||||||
: []
|
: []
|
||||||
|
|
||||||
return (
|
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="border-b px-3 py-2">
|
||||||
<div className="text-sm font-medium">配置</div>
|
<div className="text-sm font-medium">配置</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function WorkflowEditorToolbar({
|
|||||||
publishDisabled?: boolean
|
publishDisabled?: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
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">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -2,7 +2,16 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState, type ReactNode } from "react"
|
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"
|
import type { AIWorkflowDefinition, AIWorkflowNodeSpec } from "@/lib/api/admin"
|
||||||
|
|
||||||
@@ -54,20 +63,8 @@ export function WorkflowEditor({
|
|||||||
toolbarExtra?: ReactNode
|
toolbarExtra?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
const [localDefinition, setLocalDefinition] = useState(definition)
|
const [localDefinition, setLocalDefinition] = useState(definition)
|
||||||
const [editorKey, setEditorKey] = useState(0)
|
|
||||||
const [selectedNodeId, setSelectedNodeId] = useState(definition.nodes[0]?.id ?? "")
|
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(
|
const validation = useMemo(
|
||||||
() => validateWorkflowDefinition(localDefinition, nodeSpecs),
|
() => validateWorkflowDefinition(localDefinition, nodeSpecs),
|
||||||
[localDefinition, nodeSpecs]
|
[localDefinition, nodeSpecs]
|
||||||
@@ -82,47 +79,138 @@ export function WorkflowEditor({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const emitDefinition = (next: AIWorkflowDefinition, remountEditor = false) => {
|
return (
|
||||||
|
<FreeLayoutEditorProvider {...editorProps}>
|
||||||
|
<WorkflowEditorInner
|
||||||
|
definition={localDefinition}
|
||||||
|
nodeSpecs={nodeSpecs}
|
||||||
|
selectedNodeId={selectedNodeId}
|
||||||
|
validation={validation}
|
||||||
|
toolbarExtra={toolbarExtra}
|
||||||
|
onDefinitionChange={(next) => {
|
||||||
setLocalDefinition(next)
|
setLocalDefinition(next)
|
||||||
if (remountEditor) {
|
|
||||||
setEditorKey((current) => current + 1)
|
|
||||||
}
|
|
||||||
onDefinitionChange(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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const addNode = (spec: AIWorkflowNodeSpec) => {
|
function WorkflowEditorInner({
|
||||||
const nextNode = createWorkflowNodeFromSpec(spec, localDefinition.nodes, nextNodePosition(localDefinition))
|
definition,
|
||||||
const next = {
|
nodeSpecs,
|
||||||
...localDefinition,
|
selectedNodeId,
|
||||||
nodes: [...localDefinition.nodes, nextNode],
|
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)
|
||||||
}
|
}
|
||||||
setSelectedNodeId(nextNode.id)
|
})
|
||||||
emitDefinition(next, true)
|
return () => disposable.dispose()
|
||||||
|
}, [onSelectNode, selectService])
|
||||||
|
|
||||||
|
const emitCurrentDefinition = () => {
|
||||||
|
onDefinitionChange(context.document.toJSON() as AIWorkflowDefinition)
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateNodeData = (
|
const addNode = async (spec: AIWorkflowNodeSpec) => {
|
||||||
nodeId: string,
|
const nextNode = createWorkflowNodeFromSpec(spec, definition.nodes, nextNodePosition(definition))
|
||||||
data: WorkflowNodeData
|
const created = workflowDocument.createWorkflowNodeByType(
|
||||||
) => {
|
spec.type,
|
||||||
emitDefinition(updateWorkflowNodeData(localDefinition, nodeId, data))
|
nextNode.meta?.position,
|
||||||
|
nextNode as WorkflowNodeJSON
|
||||||
|
)
|
||||||
|
await selectService.selectNodeAndScrollToView(created)
|
||||||
|
onSelectNode(created.id)
|
||||||
|
emitCurrentDefinition()
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteNode = (nodeId: string) => {
|
const selectNode = (nodeId: string) => {
|
||||||
const next = deleteWorkflowNode(localDefinition, nodeId)
|
const node = workflowDocument.getAllNodes().find((item) => item.id === nodeId)
|
||||||
const nextNodes = next.nodes
|
if (node) {
|
||||||
setSelectedNodeId(nextNodes[0]?.id ?? "")
|
selectService.selectNodeAndFocus(node)
|
||||||
emitDefinition(next, true)
|
}
|
||||||
|
onSelectNode(nodeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 (
|
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} />
|
<WorkflowNodePalette nodeSpecs={nodeSpecs} onAddNode={addNode} />
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<WorkflowEditorToolbar
|
<WorkflowEditorToolbar
|
||||||
validation={validation}
|
validation={validation}
|
||||||
nodeCount={localDefinition.nodes.length}
|
nodeCount={definition.nodes.length}
|
||||||
edgeCount={localDefinition.edges.length}
|
edgeCount={definition.edges.length}
|
||||||
toolbarExtra={toolbarExtra}
|
toolbarExtra={toolbarExtra}
|
||||||
onUndo={onUndo}
|
onUndo={onUndo}
|
||||||
undoDisabled={undoDisabled}
|
undoDisabled={undoDisabled}
|
||||||
@@ -138,19 +226,17 @@ export function WorkflowEditor({
|
|||||||
publishDisabled={publishDisabled}
|
publishDisabled={publishDisabled}
|
||||||
/>
|
/>
|
||||||
<div className="min-h-0 flex-1">
|
<div className="min-h-0 flex-1">
|
||||||
<FreeLayoutEditorProvider key={editorKey} {...editorProps}>
|
|
||||||
<EditorRenderer className="h-full w-full" />
|
<EditorRenderer className="h-full w-full" />
|
||||||
</FreeLayoutEditorProvider>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<WorkflowConfigSidebar
|
<WorkflowConfigSidebar
|
||||||
definition={localDefinition}
|
definition={definition}
|
||||||
nodeSpecs={nodeSpecs}
|
nodeSpecs={nodeSpecs}
|
||||||
selectedNodeId={selectedNodeId}
|
selectedNodeId={selectedNodeId}
|
||||||
onSelectNode={setSelectedNodeId}
|
onSelectNode={selectNode}
|
||||||
onChangeNodeData={updateNodeData}
|
onChangeNodeData={updateNodeData}
|
||||||
onDeleteNode={deleteNode}
|
onDeleteNode={removeNode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function WorkflowNodePalette({
|
|||||||
onAddNode: (spec: AIWorkflowNodeSpec) => void
|
onAddNode: (spec: AIWorkflowNodeSpec) => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
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="border-b px-3 py-2">
|
||||||
<div className="text-sm font-medium">节点</div>
|
<div className="text-sm font-medium">节点</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user