From d33960961a239d6382ff21dc38b62051de41efdc Mon Sep 17 00:00:00 2001 From: mlogclub Date: Mon, 27 Jul 2026 15:28:14 +0800 Subject: [PATCH] refactor: enhance node form panel and editor tools for improved validation and rendering --- .../_components/editor/base-node.tsx | 3 +- .../_components/editor/editor-tools.tsx | 26 +- .../_components/editor/node-form-panel.tsx | 825 ++++++++++++------ .../_components/editor/node-registry.tsx | 72 +- .../_components/editor/workflow-model.ts | 4 +- .../_components/workflow-workbench.tsx | 1 + 6 files changed, 586 insertions(+), 345 deletions(-) diff --git a/web/app/dashboard/ai-workflows/_components/editor/base-node.tsx b/web/app/dashboard/ai-workflows/_components/editor/base-node.tsx index f681a3e..603f3cb 100644 --- a/web/app/dashboard/ai-workflows/_components/editor/base-node.tsx +++ b/web/app/dashboard/ai-workflows/_components/editor/base-node.tsx @@ -39,7 +39,8 @@ export function BaseNode(props: WorkflowNodeProps) { className={cn( "relative flex w-[360px] flex-col rounded-lg border bg-white", "border-[rgba(6,7,9,0.15)] shadow-[0_2px_6px_rgba(0,0,0,0.04),0_4px_12px_rgba(0,0,0,0.02)]", - render.selected && "border-[#4e40e5]" + render.selected && "border-[#4e40e5]", + render.form?.state.invalid && "border-destructive" )} draggable={!render.readonly} onDragStart={(event) => { diff --git a/web/app/dashboard/ai-workflows/_components/editor/editor-tools.tsx b/web/app/dashboard/ai-workflows/_components/editor/editor-tools.tsx index fc75a09..d60dde6 100644 --- a/web/app/dashboard/ai-workflows/_components/editor/editor-tools.tsx +++ b/web/app/dashboard/ai-workflows/_components/editor/editor-tools.tsx @@ -9,6 +9,7 @@ import { import { WorkflowNodePanelService } from "@flowgram.ai/free-node-panel-plugin" import { type InteractiveType, + getAntiOverlapPosition, useClientContext, usePlayground, usePlaygroundTools, @@ -112,14 +113,35 @@ export function EditorTools({ await nodePanel.callNodePanel({ position, enableMultiAdd: true, - onSelect: (result) => { + onSelect: async (result) => { if (!result) return + const rect = playground.node.getBoundingClientRect() + const center = playground.config.getPosFromMouseEvent({ + clientX: rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2, + }) + const existingBounds = document + .getAllNodes() + .map((item) => item.transform.bounds) + const position = + existingBounds.length > 0 + ? { + x: + Math.max(...existingBounds.map((bounds) => bounds.right)) + + 200, + y: Math.min(...existingBounds.map((bounds) => bounds.top)), + } + : center const node: WorkflowNodeEntity = document.createWorkflowNodeByType( result.nodeType, - undefined, + getAntiOverlapPosition(document, position), result.nodeJSON ?? ({} as WorkflowNodeJSON) ) selection.selectNode(node) + await new Promise((resolve) => + window.requestAnimationFrame(() => resolve()) + ) + tools.fitView(false) }, onClose: () => undefined, }) diff --git a/web/app/dashboard/ai-workflows/_components/editor/node-form-panel.tsx b/web/app/dashboard/ai-workflows/_components/editor/node-form-panel.tsx index 30a31a1..ea2bcf6 100644 --- a/web/app/dashboard/ai-workflows/_components/editor/node-form-panel.tsx +++ b/web/app/dashboard/ai-workflows/_components/editor/node-form-panel.tsx @@ -1,20 +1,45 @@ "use client" -import { useEffect, useState } from "react" +import { + startTransition, + useEffect, + useRef, + useState, +} from "react" import { + Field, PlaygroundEntityContext, + WorkflowDocument, type WorkflowNodeEntity, + type WorkflowNodeJSON, + WorkflowSelectService, useClientContext, useNodeRender, + useRefresh, + useService, } from "@flowgram.ai/free-layout-editor" import { usePanelManager } from "@flowgram.ai/panel-manager-plugin" -import { PlusIcon, Trash2Icon, XIcon } from "lucide-react" +import { + AlertCircleIcon, + CopyIcon, + MoreHorizontalIcon, + PencilIcon, + PlusIcon, + Trash2Icon, + XIcon, +} from "lucide-react" import { OptionCombobox } from "@/components/option-combobox" +import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" import { fetchKnowledgeBasesAll, type AIWorkflowDefinition, @@ -23,11 +48,13 @@ import { type KnowledgeBase, } from "@/lib/api/admin" import { Status } from "@/lib/generated/enums" +import { cn } from "@/lib/utils" import { NODE_FORM_PANEL } from "./base-node" import { WorkflowEditorSurfaceProvider, useWorkflowEditorContext, + useWorkflowEditorSurface, } from "./editor-context" import { WorkflowNodeIcon } from "./node-icon" import { @@ -53,122 +80,257 @@ const operatorOptions = [ ] export function NodeFormPanel({ nodeId }: { nodeId: string }) { - const { document } = useClientContext() + const { document, playground, selection } = useClientContext() + const panelManager = usePanelManager() + const refresh = useRefresh() const node = document.getNode(nodeId) - if (!node) return null + + useEffect(() => { + const disposable = playground.config.onReadonlyOrDisabledChange(() => { + panelManager.close(NODE_FORM_PANEL) + refresh() + }) + return () => disposable.dispose() + }, [panelManager, playground, refresh]) + + useEffect(() => { + const disposable = selection.onSelectionChanged(() => { + if ( + selection.selection.length !== 1 || + selection.selection[0] !== node + ) { + startTransition(() => panelManager.close(NODE_FORM_PANEL)) + } + }) + return () => disposable.dispose() + }, [node, panelManager, selection]) + + useEffect(() => { + if (!node) return + const disposable = node.onDispose(() => + panelManager.close(NODE_FORM_PANEL) + ) + return () => disposable.dispose() + }, [node, panelManager]) + + if ( + !node || + playground.config.readonly || + node.getNodeMeta<{ sidebarDisabled?: boolean }>().sidebarDisabled + ) { + return null + } return ( - + - + ) } -function NodeForm({ node }: { node: WorkflowNodeEntity }) { - const panelManager = usePanelManager() +function SidebarNodeRenderer({ node }: { node: WorkflowNodeEntity }) { const render = useNodeRender(node) + return ( +
+ {render.form?.render()} +
+ ) +} + +export function WorkflowNodeForm({ spec }: { spec: AIWorkflowNodeSpec }) { + const render = useNodeRender() + const surface = useWorkflowEditorSurface() + const isSidebar = surface === "sidebar" const { document } = useClientContext() const { nodeSpecs } = useWorkflowEditorContext() - const spec = nodeSpecs.find((item) => item.type === String(node.flowNodeType)) - const data = render.data ?? {} const definition = document.toJSON() as AIWorkflowDefinition - const variables = buildAvailableVariables(definition, node.id, nodeSpecs) - const canDelete = !["start", "end"].includes(String(node.flowNodeType)) + const variables = buildAvailableVariables(definition, render.node.id, nodeSpecs) - function updateData(next: Record) { - render.updateData({ ...data, ...next }) + return ( +
+ +
+ {isSidebar && spec.description ? ( +

+ {spec.description} +

+ ) : null} + + {spec.type === "knowledge_retrieve" ? : null} + {spec.type === "condition" ? ( + + ) : null} + +
+
+ ) +} + +function NodeFormHeader({ spec }: { spec: AIWorkflowNodeSpec }) { + const render = useNodeRender() + const panelManager = usePanelManager() + const { document } = useClientContext() + const selection = useService(WorkflowSelectService) + const surface = useWorkflowEditorSurface() + const isSidebar = surface === "sidebar" + const canDelete = !["start", "end"].includes(String(render.node.flowNodeType)) + const canCopy = canDelete + const [editing, setEditing] = useState(false) + const [menuOpen, setMenuOpen] = useState(false) + const titleRef = useRef(null) + const closeMenuTimer = useRef(null) + + useEffect(() => { + if (editing) titleRef.current?.focus() + }, [editing]) + + useEffect( + () => () => { + if (closeMenuTimer.current) window.clearTimeout(closeMenuTimer.current) + }, + [] + ) + + function openMenu() { + if (closeMenuTimer.current) window.clearTimeout(closeMenuTimer.current) + setMenuOpen(true) + } + + function scheduleCloseMenu() { + if (closeMenuTimer.current) window.clearTimeout(closeMenuTimer.current) + closeMenuTimer.current = window.setTimeout(() => setMenuOpen(false), 120) } return ( -
-
- - - -
-
- {String(data.title || spec?.title || node.flowNodeType)} +
+ + + + name="title"> + {({ field, fieldState }) => ( +
+ {editing && !render.readonly ? ( + event.stopPropagation()} + onBlur={() => setEditing(false)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === "Escape") { + setEditing(false) + } + }} + onChange={(event) => field.onChange(event.target.value)} + /> + ) : ( + + )} + {fieldState?.invalid ? ( + + ) : null}
-
+ )} + + {!render.readonly ? ( + + event.stopPropagation()} + /> + } + > + + + + { + event.stopPropagation() + setEditing(true) + }} + > + + 编辑名称 + + { + event.stopPropagation() + duplicateNode(render.node, document, selection) + }} + > + + 创建副本 + + { + event.stopPropagation() + render.deleteNode() + panelManager.close(NODE_FORM_PANEL) + }} + > + + 删除节点 + + + + ) : null} + {isSidebar ? ( -
-
- - - updateData({ title: event.target.value })} - /> - - - updateData({ inputsValues })} - /> - {String(node.flowNodeType) === "knowledge_retrieve" ? ( - updateData({ config })} - /> - ) : null} - {String(node.flowNodeType) === "condition" ? ( - - updateData({ - config: { ...asRecord(data.config), branches }, - portKeys: branches.map((branch) => branch.id), - ports: branches.map((branch) => branch.id), - }) - } - /> - ) : null} - -
- {canDelete ? ( -
- -
) : null}
) } -function InputSection({ +function InputFields({ spec, - inputsValues, variables, - onChange, }: { - spec?: AIWorkflowNodeSpec - inputsValues: Record + spec: AIWorkflowNodeSpec variables: ReturnType - onChange: (value: Record) => void }) { - if (!spec?.inputSchema?.length) return null + if (!spec.inputSchema?.length) return null const options = variables.map((variable) => ({ value: `${variable.nodeId}.${variable.name}`, label: variable.label || variable.name, @@ -176,40 +338,45 @@ function InputSection({ subtitle: `${variable.nodeId}.${variable.name}`, description: variable.description, })) + return ( - + <> {spec.inputSchema.map((input) => ( - key={input.name} - label={input.label || input.name} - required={input.required} - hint={input.description} + name={`inputsValues.${input.name}`} > - { - const parsed = parseRefKey(value) - if (!parsed) return - onChange({ ...inputsValues, [input.name]: parsed }) - }} - /> - + {({ field, fieldState }) => ( + + { + const parsed = parseRefKey(value) + if (parsed) field.onChange(parsed) + }} + /> + + )} + ))} - + ) } -function KnowledgeSection({ - config, - onChange, -}: { - config: Record - onChange: (value: Record) => void -}) { +function KnowledgeFields() { const [items, setItems] = useState([]) useEffect(() => { let active = true @@ -220,210 +387,316 @@ function KnowledgeSection({ active = false } }, []) - const values = normalizeIDs(config.knowledgeBaseIds).map(String) + return ( - - - ({ - value: String(item.id), - label: item.name, - }))} - placeholder="选择知识库" - searchPlaceholder="搜索知识库" - onValuesChange={(next) => - onChange({ - ...config, - knowledgeBaseIds: next.map(Number).filter((id) => id > 0), - }) - } - /> - - + > name="config"> + {({ field }) => { + const config = asRecord(field.value) + const values = normalizeIDs(config.knowledgeBaseIds).map(String) + return ( + + ({ + value: String(item.id), + label: item.name, + }))} + placeholder="选择知识库" + searchPlaceholder="搜索知识库" + triggerClassName="min-h-8 bg-white text-xs" + onValuesChange={(next) => + field.onChange({ + ...config, + knowledgeBaseIds: next + .map(Number) + .filter((id) => id > 0), + }) + } + /> + + ) + }} + ) } -function ConditionSection({ - branches, +function ConditionFields({ variables, - onChange, }: { - branches: WorkflowConditionBranch[] variables: ReturnType - onChange: (branches: WorkflowConditionBranch[]) => void }) { + const render = useNodeRender() const variableOptions = variables.map((variable) => ({ value: `${variable.nodeId}.${variable.name}`, label: variable.label || variable.name, group: variable.nodeTitle, subtitle: `${variable.nodeId}.${variable.name}`, })) - const fallback = branches.find((branch) => branch.default) - const regular = branches.filter((branch) => !branch.default) - - function update(branch: WorkflowConditionBranch) { - onChange(branches.map((item) => (item.id === branch.id ? branch : item))) - } return ( - { - const next: WorkflowConditionBranch = { - id: nextBranchID(branches), - name: `条件 ${regular.length + 1}`, - targetNodeId: "", - condition: { operator: "eq" }, - } - onChange([...regular, next, fallback].filter(Boolean) as WorkflowConditionBranch[]) - }} - > - - 添加 - - } - > - {[...regular, ...(fallback ? [fallback] : [])].map((branch) => ( -
-
- update({ ...branch, name: event.target.value })} - /> - {!branch.default ? ( + > name="config"> + {({ field }) => { + const config = asRecord(field.value) + const branches = normalizeConditionBranches({ + data: { config }, + }) + const regular = branches.filter((branch) => !branch.default) + const fallback = branches.find((branch) => branch.default) + + function commit(next: WorkflowConditionBranch[]) { + const nextConfig = { ...config, branches: next } + field.onChange(nextConfig) + render.updateData({ + ...render.data, + config: nextConfig, + portKeys: next.map((branch) => branch.id), + ports: next.map((branch) => branch.id), + }) + window.requestAnimationFrame(() => + render.node.ports.updateDynamicPorts() + ) + } + + function update(branch: WorkflowConditionBranch) { + commit( + branches.map((item) => (item.id === branch.id ? branch : item)) + ) + } + + return ( +
+ {branches.map((branch, index) => ( +
+
+ + {branch.default + ? "else" + : index === 0 + ? "if" + : "elif"} + +
+ {branch.default ? ( +
+ 其他条件均不匹配 +
+ ) : ( +
+ + update({ + ...branch, + condition: { + ...branch.condition, + left: parseRefKey(value), + }, + }) + } + /> +
+ + update({ + ...branch, + condition: { + ...branch.condition, + operator, + }, + }) + } + /> + {!["exists", "empty"].includes( + branch.condition?.operator ?? "" + ) ? ( + + update({ + ...branch, + condition: { + ...branch.condition, + right: event.target.value, + }, + }) + } + /> + ) : null} +
+
+ )} + {!branch.default && !render.readonly ? ( + + ) : null} + +
+ ))} + {!render.readonly ? ( ) : null}
- {branch.default ? ( -

其他条件均不匹配时进入此分支。

- ) : ( -
- - update({ - ...branch, - condition: { - ...branch.condition, - left: parseRefKey(value), - }, - }) - } - /> - - update({ - ...branch, - condition: { ...branch.condition, operator }, - }) - } - /> - {!["exists", "empty"].includes(branch.condition?.operator ?? "") ? ( - - update({ - ...branch, - condition: { - ...branch.condition, - right: event.target.value, - }, - }) - } - /> - ) : null} -
- )} -
- ))} - + ) + }} + ) } -function OutputSection({ spec }: { spec?: AIWorkflowNodeSpec }) { - if (!spec?.outputSchema?.length) return null +function OutputFields({ spec }: { spec: AIWorkflowNodeSpec }) { + if (!spec.outputSchema?.length) return null return ( - -
- {spec.outputSchema.map((output) => ( -
-
- {output.label || output.name} - {output.type} -
- {output.description ? ( -

{output.description}

- ) : null} +
+ {spec.outputSchema.map((output) => ( + +
+ {output.name}
- ))} -
- + + ))} +
) } -function FormSection({ - title, - action, - children, -}: { - title: string - action?: React.ReactNode - children: React.ReactNode -}) { - return ( -
-
-

{title}

- {action} -
-
{children}
-
- ) -} - -function FormField({ +function NodeFormRow({ label, + type, required, - hint, + description, children, }: { label: string + type?: string required?: boolean - hint?: string + description?: string children: React.ReactNode }) { return ( -
- - {children} - {hint ?

{hint}

: null} +
event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + > +
+ {type ? ( + + {typeIcon(type)} + + ) : null} + {label} + {required ? * : null} +
+
{children}
) } +function duplicateNode( + node: WorkflowNodeEntity, + document: WorkflowDocument, + selection: WorkflowSelectService +) { + const source = document.toNodeJSON(node) as WorkflowNodeJSON + const position = { + x: Number(source.meta?.position?.x ?? node.transform.position.x) + 48, + y: Number(source.meta?.position?.y ?? node.transform.position.y) + 48, + } + const used = new Set(document.getAllNodes().map((item) => item.id)) + const baseID = `${source.id}_copy` + let id = baseID + let index = 2 + while (used.has(id)) { + id = `${baseID}_${index}` + index += 1 + } + const copied = document.createWorkflowNodeByType( + String(node.flowNodeType), + position, + { + ...source, + id, + meta: { ...source.meta, position }, + } + ) + selection.selectNode(copied) +} + +function typeIcon(type: string) { + if (type === "string") return "S" + if (type === "boolean") return "B" + if (type === "number" || type === "integer") return "N" + if (type.startsWith("array")) return "A" + if (type === "object") return "O" + return "•" +} + function asRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) @@ -433,6 +706,10 @@ function asRecord(value: unknown): Record { function normalizeIDs(value: unknown) { if (!Array.isArray(value)) return [] return Array.from( - new Set(value.map(Number).filter((item) => Number.isInteger(item) && item > 0)) + new Set( + value + .map(Number) + .filter((item) => Number.isInteger(item) && item > 0) + ) ) } diff --git a/web/app/dashboard/ai-workflows/_components/editor/node-registry.tsx b/web/app/dashboard/ai-workflows/_components/editor/node-registry.tsx index d827400..694a530 100644 --- a/web/app/dashboard/ai-workflows/_components/editor/node-registry.tsx +++ b/web/app/dashboard/ai-workflows/_components/editor/node-registry.tsx @@ -1,14 +1,10 @@ "use client" -import { - Field, - type WorkflowNodeRegistry, -} from "@flowgram.ai/free-layout-editor" +import { type WorkflowNodeRegistry } from "@flowgram.ai/free-layout-editor" import type { AIWorkflowNodeSpec } from "@/lib/api/admin" -import { WorkflowNodeIcon } from "./node-icon" -import { normalizeConditionBranches } from "./workflow-model" +import { WorkflowNodeForm } from "./node-form-panel" export function buildNodeRegistries( nodeSpecs: AIWorkflowNodeSpec[] @@ -16,6 +12,9 @@ export function buildNodeRegistries( return [ ...nodeSpecs.map((spec) => ({ type: spec.type, + info: { + description: spec.description, + }, meta: { defaultExpanded: true, isStart: spec.type === "start", @@ -26,7 +25,7 @@ export function buildNodeRegistries( defaultPorts: getDefaultPorts(spec.type), }, formMeta: { - render: () => , + render: () => , }, })), { @@ -47,65 +46,6 @@ export function buildNodeRegistries( ] } -function CanvasNodeContent({ spec }: { spec: AIWorkflowNodeSpec }) { - return ( - name="title"> - {({ field }) => ( -
-
- - - -
- {field.value || spec.title} -
-
-
-
-
-
- {spec.description} -
-
-
- {spec.type === "condition" ? ( - > name="config"> - {({ field: configField }) => { - const branches = normalizeConditionBranches({ - data: { config: configField.value }, - }) - return ( -
- {branches.map((branch, index) => ( -
- - {branch.default ? "else" : index === 0 ? "if" : "elif"} - - - {branch.name || branch.id} - - -
- ))} -
- ) - }} - - ) : null} -
-
- )} - - ) -} - function getDefaultPorts(type: string) { if (type === "start") return [{ type: "output" as const }] if (type === "end") return [{ type: "input" as const }] diff --git a/web/app/dashboard/ai-workflows/_components/editor/workflow-model.ts b/web/app/dashboard/ai-workflows/_components/editor/workflow-model.ts index 11e95ce..fb275e5 100644 --- a/web/app/dashboard/ai-workflows/_components/editor/workflow-model.ts +++ b/web/app/dashboard/ai-workflows/_components/editor/workflow-model.ts @@ -3,6 +3,7 @@ import type { AIWorkflowNodeSpec, AIWorkflowValue, } from "@/lib/api/admin" +import type { WorkflowNodeJSON } from "@flowgram.ai/free-layout-editor" import type { WorkflowConditionBranch, @@ -104,14 +105,13 @@ export function serializeDefinition( export function createNodeJSON( spec: AIWorkflowNodeSpec, existingNodeIDs: string[] = [] -): WorkflowNode { +): WorkflowNodeJSON { const id = uniqueNodeID(spec.type, existingNodeIDs) const config = spec.type === "condition" ? { branches: [defaultBranch] } : {} return { id, type: spec.type, - meta: { position: { x: 0, y: 0 } }, data: { title: spec.title || spec.type, config, diff --git a/web/app/dashboard/ai-workflows/_components/workflow-workbench.tsx b/web/app/dashboard/ai-workflows/_components/workflow-workbench.tsx index 20b8725..0ac6e9d 100644 --- a/web/app/dashboard/ai-workflows/_components/workflow-workbench.tsx +++ b/web/app/dashboard/ai-workflows/_components/workflow-workbench.tsx @@ -265,6 +265,7 @@ export function WorkflowWorkbench({ > {nodeSpecs.length ? ( {