diff --git a/frontend/components/flow/node-context-menu.tsx b/frontend/components/flow/node-context-menu.tsx
new file mode 100644
index 0000000..131ff08
--- /dev/null
+++ b/frontend/components/flow/node-context-menu.tsx
@@ -0,0 +1,198 @@
+import { primitiveRuntimeCapability, runtimeStatusLabel, runtimeStatusTone, type WorkflowCapabilitiesResponse } from "@/lib/workflow/capabilities"
+import { localizeNodeText, type WorkflowLanguage } from "@/lib/workflow/node-i18n"
+import type { WorkflowNodeCatalogItem } from "@/lib/workflow/node-catalog"
+import type { WorkflowPrimitive } from "@/lib/workflow/node-primitives"
+import { getNodeVisualSignature } from "@/lib/workflow/node-visuals"
+import { cn } from "@/lib/utils"
+
+type NodeMenuState = { nodeId: string; x: number; y: number }
+type PrimitiveMenuGroup = {
+ category: string
+ label: string
+ items: WorkflowPrimitive[]
+}
+
+type NodeContextMenuProps = {
+ capabilities: WorkflowCapabilitiesResponse | null | undefined
+ dopNodeMenuItems: WorkflowNodeCatalogItem[]
+ language: WorkflowLanguage
+ menu: NodeMenuState
+ onAddDopNode: (item: WorkflowNodeCatalogItem) => void
+ onAddPrimitive: (item: WorkflowPrimitive, itemIndex: number) => void
+ onDiveIntoNetwork: (nodeId: string) => void
+ onLockInternals: (nodeId: string) => void
+ onSelectComponent: (nodeId: string) => void
+ onShowNodeInfo: () => void
+ onShowParameters: () => void
+ onUnlockInternals: (nodeId: string) => void
+ primitiveMenuGroups: PrimitiveMenuGroup[]
+ wrapperElement: HTMLElement | null
+}
+
+export function NodeContextMenu({
+ capabilities,
+ dopNodeMenuItems,
+ language,
+ menu,
+ onAddDopNode,
+ onAddPrimitive,
+ onDiveIntoNetwork,
+ onLockInternals,
+ onSelectComponent,
+ onShowNodeInfo,
+ onShowParameters,
+ onUnlockInternals,
+ primitiveMenuGroups,
+ wrapperElement,
+}: NodeContextMenuProps) {
+ return (
+
event.stopPropagation()}
+ onClick={(event) => event.stopPropagation()}
+ >
+
+
+
+ DOP Operators
+
+
+ {dopNodeMenuItems.map((item) => {
+ const text = localizeNodeText(item.id, { label: item.label, description: item.description }, language)
+ const visual = getNodeVisualSignature({
+ label: item.label,
+ description: item.description,
+ nodeType: item.kind === "router" ? "condition" : item.kind === "schedule" ? "trigger" : item.kind === "source" ? "http" : "action",
+ category: item.category === "decision" ? "logic" : item.category === "trigger" ? "trigger" : item.category === "source" ? "data" : item.category === "output" ? "action" : "action",
+ icon: item.icon,
+ canonical: { catalogId: item.id, kind: item.kind, capability: item.capability },
+ })
+ return (
+
+ )
+ })}
+
+
+
+ Add Internal Primitive
+ ›
+
+
+ {primitiveMenuGroups.map((group) => (
+
+
+ {group.label}
+ ›
+
+
+ {group.items.map((item, itemIndex) => {
+ const text = localizeNodeText(item.id, { label: item.label, description: item.description }, language)
+ const runtimeCapability = primitiveRuntimeCapability(capabilities, item.id)
+ const visual = getNodeVisualSignature({
+ label: item.label,
+ description: item.description,
+ nodeType: item.nodeType,
+ category: item.nodeCategory,
+ icon: item.icon,
+ primitiveId: item.id,
+ primitiveCategory: item.category,
+ })
+ return (
+
+ )
+ })}
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
Help...
+
+ )
+}
diff --git a/frontend/components/flow/workflow-agent-proposal.ts b/frontend/components/flow/workflow-agent-proposal.ts
new file mode 100644
index 0000000..53e1825
--- /dev/null
+++ b/frontend/components/flow/workflow-agent-proposal.ts
@@ -0,0 +1,78 @@
+import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from "react"
+
+import { useFlowStore } from "@/lib/flow/store"
+import { acceptAgentProposal, type AgentProposal } from "@/lib/workflow/proposal"
+import type { ProposalFocusTarget } from "@/lib/workflow/proposal-focus"
+
+type FitView = (options?: { padding?: number; duration?: number; nodes?: { id: string }[] }) => unknown
+
+export function useWorkflowAgentProposal(options: {
+ clearPendingAgentProposal: () => void
+ clearProposalFocus: () => void
+ fitView: FitView
+ focusProposalTargets: (nodeIds: string[], edgeIds: string[]) => void
+ importWorkflowProject: (project: ReturnType
) => void
+ pendingAgentProposal: AgentProposal | null | undefined
+ setAgentDrawerOpen: Dispatch>
+ showToast: (message: string) => void
+}) {
+ const {
+ clearPendingAgentProposal,
+ clearProposalFocus,
+ fitView,
+ focusProposalTargets,
+ importWorkflowProject,
+ pendingAgentProposal,
+ setAgentDrawerOpen,
+ showToast,
+ } = options
+ const [agentProposal, setAgentProposal] = useState(undefined)
+
+ const acceptProposal = useCallback(
+ (proposal: AgentProposal) => {
+ try {
+ importWorkflowProject(acceptAgentProposal(useFlowStore.getState().workflowProject, proposal))
+ showToast("Agent proposal accepted")
+ setAgentDrawerOpen(false)
+ setAgentProposal(undefined)
+ } catch (error) {
+ showToast(error instanceof Error ? error.message : "Agent proposal failed")
+ }
+ },
+ [importWorkflowProject, setAgentDrawerOpen, showToast],
+ )
+
+ const rejectProposal = useCallback(() => {
+ showToast("Agent proposal rejected")
+ clearProposalFocus()
+ setAgentDrawerOpen(false)
+ setAgentProposal(undefined)
+ }, [clearProposalFocus, setAgentDrawerOpen, showToast])
+
+ const presentAgentProposal = useCallback(
+ (proposal: AgentProposal) => {
+ setAgentProposal(proposal)
+ setAgentDrawerOpen(true)
+ showToast("Demand proposal ready")
+ },
+ [setAgentDrawerOpen, showToast],
+ )
+
+ useEffect(() => {
+ if (!pendingAgentProposal) return
+ presentAgentProposal(pendingAgentProposal)
+ clearPendingAgentProposal()
+ }, [clearPendingAgentProposal, pendingAgentProposal, presentAgentProposal])
+
+ const focusProposalOperation = useCallback(
+ (focus: ProposalFocusTarget) => {
+ focusProposalTargets(focus.nodeIds, focus.edgeIds)
+ if (focus.nodeIds.length > 0) {
+ window.setTimeout(() => void fitView({ nodes: focus.nodeIds.map((id) => ({ id })), padding: 0.35, duration: 280 }), 20)
+ }
+ },
+ [fitView, focusProposalTargets],
+ )
+
+ return { acceptProposal, agentProposal, focusProposalOperation, rejectProposal }
+}
diff --git a/frontend/components/flow/workflow-canvas-geometry.ts b/frontend/components/flow/workflow-canvas-geometry.ts
new file mode 100644
index 0000000..8b38b4e
--- /dev/null
+++ b/frontend/components/flow/workflow-canvas-geometry.ts
@@ -0,0 +1,39 @@
+import type { MouseEvent as ReactMouseEvent } from "react"
+
+export type CanvasPoint = { x: number; y: number }
+
+function distance(a: CanvasPoint, b: CanvasPoint) {
+ return Math.hypot(a.x - b.x, a.y - b.y)
+}
+
+export function edgeIdsAtScreenPoint(point: CanvasPoint, threshold = 10): string[] {
+ const hits: string[] = []
+ const edges = document.querySelectorAll(".react-flow__edge[data-id]")
+
+ edges.forEach((edge) => {
+ const id = edge.dataset.id
+ const path = edge.querySelector("path.react-flow__edge-path, path[id]")
+ if (!id || !path) return
+ const ctm = path.getScreenCTM()
+ if (!ctm) return
+
+ const total = path.getTotalLength()
+ const steps = Math.max(16, Math.ceil(total / 18))
+ for (let i = 0; i <= steps; i++) {
+ const svgPoint = path.getPointAtLength((total * i) / steps)
+ const screenPoint = new DOMPoint(svgPoint.x, svgPoint.y).matrixTransform(ctm)
+ if (distance(point, screenPoint) <= threshold) {
+ hits.push(id)
+ return
+ }
+ }
+ })
+
+ return hits
+}
+
+export function localPoint(element: HTMLElement | null, event: ReactMouseEvent): CanvasPoint {
+ const rect = element?.getBoundingClientRect()
+ if (!rect) return { x: event.clientX, y: event.clientY }
+ return { x: event.clientX - rect.left, y: event.clientY - rect.top }
+}
diff --git a/frontend/components/flow/workflow-canvas-interactions.ts b/frontend/components/flow/workflow-canvas-interactions.ts
new file mode 100644
index 0000000..d6378c5
--- /dev/null
+++ b/frontend/components/flow/workflow-canvas-interactions.ts
@@ -0,0 +1,254 @@
+import {
+ useCallback,
+ type Dispatch,
+ type DragEvent,
+ type MouseEvent as ReactMouseEvent,
+ type RefObject,
+ type SetStateAction,
+} from "react"
+import type { IsValidConnection, Node, OnBeforeDelete, OnNodeDrag } from "@xyflow/react"
+
+import { useFlowStore } from "@/lib/flow/store"
+import type { CanvasSettings } from "@/lib/flow/settings-store"
+import { validateConnection } from "@/lib/flow/graph"
+import type { PaletteItem, WorkflowEdge, WorkflowNode } from "@/lib/flow/types"
+import { edgeIdsAtScreenPoint, localPoint, type CanvasPoint } from "./workflow-canvas-geometry"
+
+type WritableRef = { current: T }
+type ScreenToFlowPosition = (position: CanvasPoint) => CanvasPoint
+type ShowToast = (message: string) => void
+
+export type ShakeState = {
+ lastX: number
+ lastDirection: -1 | 0 | 1
+ turns: number
+ disconnected: boolean
+}
+
+export function usePaletteDrop(options: {
+ addNodeFromPalette: (item: PaletteItem, position: CanvasPoint) => void
+ screenToFlowPosition: ScreenToFlowPosition
+}) {
+ const { addNodeFromPalette, screenToFlowPosition } = options
+ const onDragOver = useCallback((event: DragEvent) => {
+ event.preventDefault()
+ event.dataTransfer.dropEffect = "move"
+ }, [])
+
+ const onDrop = useCallback(
+ (event: DragEvent) => {
+ event.preventDefault()
+ const raw = event.dataTransfer.getData("application/reactflow")
+ if (!raw) return
+ addNodeFromPalette(JSON.parse(raw) as PaletteItem, screenToFlowPosition({ x: event.clientX, y: event.clientY }))
+ },
+ [screenToFlowPosition, addNodeFromPalette],
+ )
+
+ return { onDragOver, onDrop }
+}
+
+export function useScissorCanvasHandlers(options: {
+ cutRef: WritableRef>
+ draggingRef: WritableRef
+ removeEdgesByIds: (ids: string[]) => number
+ setTrail: Dispatch>
+ showToast: ShowToast
+ toolMode: string
+ wrapperRef: RefObject
+}) {
+ const { cutRef, draggingRef, removeEdgesByIds, setTrail, showToast, toolMode, wrapperRef } = options
+ const cutEdgesAtPoint = useCallback(
+ (event: ReactMouseEvent) => {
+ const hits = edgeIdsAtScreenPoint({ x: event.clientX, y: event.clientY })
+ const fresh = hits.filter((id) => !cutRef.current.has(id))
+ if (fresh.length === 0) return
+ fresh.forEach((id) => cutRef.current.add(id))
+ const removed = removeEdgesByIds(fresh)
+ if (removed > 0) showToast(`已剪断 ${removed} 条连接`)
+ },
+ [cutRef, removeEdgesByIds, showToast],
+ )
+
+ const onCanvasMouseDownCapture = useCallback(
+ (event: ReactMouseEvent) => {
+ if (toolMode !== "scissors" || event.button !== 0) return
+ event.preventDefault()
+ event.stopPropagation()
+ draggingRef.current = true
+ cutRef.current = new Set()
+ setTrail([localPoint(wrapperRef.current, event)])
+ cutEdgesAtPoint(event)
+ },
+ [cutEdgesAtPoint, cutRef, draggingRef, setTrail, toolMode, wrapperRef],
+ )
+
+ const onCanvasMouseMoveCapture = useCallback(
+ (event: ReactMouseEvent) => {
+ if (!draggingRef.current) return
+ event.preventDefault()
+ event.stopPropagation()
+ setTrail((trail) => {
+ const next = [...trail, localPoint(wrapperRef.current, event)]
+ return next.length > 80 ? next.slice(-80) : next
+ })
+ cutEdgesAtPoint(event)
+ },
+ [cutEdgesAtPoint, draggingRef, setTrail, wrapperRef],
+ )
+
+ const onCanvasMouseUpCapture = useCallback(
+ (event: ReactMouseEvent) => {
+ if (!draggingRef.current) return
+ event.preventDefault()
+ event.stopPropagation()
+ draggingRef.current = false
+ cutRef.current = new Set()
+ window.setTimeout(() => setTrail([]), 120)
+ },
+ [cutRef, draggingRef, setTrail],
+ )
+
+ return { onCanvasMouseDownCapture, onCanvasMouseMoveCapture, onCanvasMouseUpCapture }
+}
+
+export function useWorkflowNodeDragHandlers(options: {
+ attachToParent: (nodeId: string, parentId: string) => void
+ clearHelperLines: () => void
+ detachFromParent: (nodeId: string) => void
+ disconnectNodeConnections: (nodeId: string) => number
+ getInternalNode: (nodeId: string) => { internals?: { positionAbsolute?: CanvasPoint } } | undefined
+ resizeGroupToFit: (nodeId: string) => void
+ resolveNodeCollisions: (nodeId: string) => void
+ shakeRef: WritableRef