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 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>
|
||||
|
||||
Reference in New Issue
Block a user