diff --git a/web/app/dashboard/ai-workflows/_components/flowgram-node-renderer.tsx b/web/app/dashboard/ai-workflows/_components/flowgram-node-renderer.tsx index 9f3fcf9..cb40de4 100644 --- a/web/app/dashboard/ai-workflows/_components/flowgram-node-renderer.tsx +++ b/web/app/dashboard/ai-workflows/_components/flowgram-node-renderer.tsx @@ -8,9 +8,11 @@ import { import { useLayoutEffect } from "react" import { cn } from "@/lib/utils" +import { useWorkflowPortAdd } from "./workflow-port-add-context" export function FlowgramNodeRenderer(props: WorkflowNodeProps) { const { selected, node, form } = useNodeRender() + const requestPortAdd = useWorkflowPortAdd() const nodeType = String(node.flowNodeType ?? "") useLayoutEffect(() => { @@ -33,6 +35,13 @@ export function FlowgramNodeRenderer(props: WorkflowNodeProps) { portPrimaryColor="#2575FC" portSecondaryColor="#c9cdd4" portBackgroundColor="#FFFFFF" + onPortClick={(port, event) => { + if (port.portType !== "output" || typeof event === "function") { + return + } + event.stopPropagation() + requestPortAdd?.({ port, event }) + }} > {form?.render()} diff --git a/web/app/dashboard/ai-workflows/_components/workflow-editor.tsx b/web/app/dashboard/ai-workflows/_components/workflow-editor.tsx index b02dadb..605dd74 100644 --- a/web/app/dashboard/ai-workflows/_components/workflow-editor.tsx +++ b/web/app/dashboard/ai-workflows/_components/workflow-editor.tsx @@ -6,9 +6,11 @@ import { EditorRenderer, FreeLayoutEditorProvider, WorkflowDocument, + WorkflowLinesManager, WorkflowSelectService, type WorkflowJSON, type WorkflowNodeJSON, + type WorkflowPortEntity, useClientContext, usePlaygroundTools, useService, @@ -24,7 +26,14 @@ import { } from "./workflow-branch-selection" import { WorkflowConfigPanel } from "./workflow-config-sidebar" import { WorkflowEditorToolbar } from "./workflow-editor-toolbar" -import { WorkflowNodePalette } from "./workflow-node-palette" +import { + WorkflowPortAddProvider, + type WorkflowPortAddRequest, +} from "./workflow-port-add-context" +import { + WorkflowPortNodeMenu, + type WorkflowPortNodeMenuState, +} from "./workflow-port-node-menu" import { createWorkflowNodeFromSpec, deleteWorkflowNode, @@ -190,8 +199,11 @@ function WorkflowEditorInner({ const context = useClientContext() const playgroundTools = usePlaygroundTools() const workflowDocument = useService(WorkflowDocument) + const linesManager = useService(WorkflowLinesManager) const selectService = useService(WorkflowSelectService) + const editorRootRef = useRef(null) const [autoLayouting, setAutoLayouting] = useState(false) + const [nodeMenu, setNodeMenu] = useState<(WorkflowPortNodeMenuState & { port: WorkflowPortEntity }) | null>(null) const zoomPercent = `${Math.round(playgroundTools.zoom * 100)}%` useEffect(() => { @@ -206,13 +218,37 @@ function WorkflowEditorInner({ onDefinitionChange(context.document.toJSON() as AIWorkflowDefinition) } - const addNode = async (spec: AIWorkflowNodeSpec) => { - const nextNode = createWorkflowNodeFromSpec(spec, definition.nodes, nextNodePosition(definition)) + const openNodeMenuFromPort = useCallback((request: WorkflowPortAddRequest) => { + const rootRect = editorRootRef.current?.getBoundingClientRect() + setNodeMenu({ + port: request.port, + x: rootRect ? request.event.clientX - rootRect.left + 10 : request.event.clientX, + y: rootRect ? request.event.clientY - rootRect.top - 10 : request.event.clientY, + }) + }, []) + + const addNodeFromPort = async (spec: AIWorkflowNodeSpec) => { + if (!nodeMenu) { + return + } + const sourcePort = nodeMenu.port + const nextNode = createWorkflowNodeFromSpec( + spec, + context.document.toJSON().nodes ?? definition.nodes, + nextNodePositionFromPort(sourcePort) + ) const created = workflowDocument.createWorkflowNodeByType( spec.type, nextNode.meta?.position, nextNode as WorkflowNodeJSON ) + linesManager.createLine({ + from: sourcePort.node.id, + fromPort: sourcePort.portID, + to: created.id, + toPort: "", + }) + setNodeMenu(null) await selectService.selectNodeAndScrollToView(created) onSelectNode(created.id) emitCurrentDefinition() @@ -264,15 +300,14 @@ function WorkflowEditorInner({ return (
- -
- + + + + + void addNodeFromPort(spec)} + onClose={() => setNodeMenu(null)} + /> node.meta?.position?.x ?? 0)) - const minY = Math.min(...definition.nodes.map((node) => node.meta?.position?.y ?? 80)) - return { x: maxX + 320, y: Number.isFinite(minY) ? minY : 80 } } diff --git a/web/app/dashboard/ai-workflows/_components/workflow-node-palette.tsx b/web/app/dashboard/ai-workflows/_components/workflow-node-palette.tsx deleted file mode 100644 index 2053bb3..0000000 --- a/web/app/dashboard/ai-workflows/_components/workflow-node-palette.tsx +++ /dev/null @@ -1,38 +0,0 @@ -"use client" - -import type { AIWorkflowNodeSpec } from "@/lib/api/admin" -import { WorkflowNodeIcon } from "./workflow-node-icon" - -export function WorkflowNodePalette({ - nodeSpecs, - onAddNode, -}: { - nodeSpecs: AIWorkflowNodeSpec[] - onAddNode: (spec: AIWorkflowNodeSpec) => void -}) { - return ( - - ) -} diff --git a/web/app/dashboard/ai-workflows/_components/workflow-port-add-context.tsx b/web/app/dashboard/ai-workflows/_components/workflow-port-add-context.tsx new file mode 100644 index 0000000..96e9ec6 --- /dev/null +++ b/web/app/dashboard/ai-workflows/_components/workflow-port-add-context.tsx @@ -0,0 +1,30 @@ +"use client" + +import { createContext, useContext } from "react" + +import type { WorkflowPortEntity } from "@flowgram.ai/free-layout-editor" + +export type WorkflowPortAddRequest = { + port: WorkflowPortEntity + event: React.MouseEvent +} + +const WorkflowPortAddContext = createContext<((request: WorkflowPortAddRequest) => void) | null>(null) + +export function WorkflowPortAddProvider({ + onRequestAdd, + children, +}: { + onRequestAdd: (request: WorkflowPortAddRequest) => void + children: React.ReactNode +}) { + return ( + + {children} + + ) +} + +export function useWorkflowPortAdd() { + return useContext(WorkflowPortAddContext) +} diff --git a/web/app/dashboard/ai-workflows/_components/workflow-port-node-menu.tsx b/web/app/dashboard/ai-workflows/_components/workflow-port-node-menu.tsx new file mode 100644 index 0000000..cabb517 --- /dev/null +++ b/web/app/dashboard/ai-workflows/_components/workflow-port-node-menu.tsx @@ -0,0 +1,100 @@ +"use client" + +import { useEffect, useMemo, useRef } from "react" + +import type { AIWorkflowNodeSpec } from "@/lib/api/admin" +import { WorkflowNodeIcon } from "./workflow-node-icon" + +export type WorkflowPortNodeMenuState = { + x: number + y: number +} + +export function WorkflowPortNodeMenu({ + open, + position, + nodeSpecs, + onSelect, + onClose, +}: { + open: boolean + position: WorkflowPortNodeMenuState | null + nodeSpecs: AIWorkflowNodeSpec[] + onSelect: (spec: AIWorkflowNodeSpec) => void + onClose: () => void +}) { + const menuRef = useRef(null) + const insertableNodeSpecs = useMemo( + () => nodeSpecs.filter((spec) => spec.type !== "start"), + [nodeSpecs] + ) + + useEffect(() => { + if (!open) { + return + } + const closeOnPointerDown = (event: PointerEvent) => { + const target = event.target + if (target instanceof Node && menuRef.current?.contains(target)) { + return + } + onClose() + } + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose() + } + } + window.addEventListener("pointerdown", closeOnPointerDown) + window.addEventListener("keydown", closeOnEscape) + return () => { + window.removeEventListener("pointerdown", closeOnPointerDown) + window.removeEventListener("keydown", closeOnEscape) + } + }, [onClose, open]) + + if (!open || !position) { + return null + } + + return ( +
event.stopPropagation()} + > +
+ 添加节点 +
+
+ {insertableNodeSpecs.map((spec) => ( + + ))} +
+
+ ) +}