iblai-workflow

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

/iblai-workflow

/iblai-workflow

Add AI workflow builder features -- a visual workflow editor with a node type browser sidebar, node configuration panels, connector management, and create/delete workflow modals. Includes a custom canvas with drag-and-drop, bezier curve edges, pan/zoom, undo/redo, and auto-save.
AI Assistant: Do NOT add custom styles, colors, or CSS overrides to ibl.ai SDK components. They ship with their own styling. Keep the components as-is. Do NOT implement dark mode unless the user explicitly asks for it.
When building custom UI around SDK components, use the ibl.ai brand:
  • Primary:
    #0058cc
    , Gradient:
    linear-gradient(135deg, #00b0ef, #0058cc)
  • Button:
    bg-gradient-to-r from-[#2563EB] to-[#93C5FD] text-white
  • Font: System sans-serif stack, Style: shadcn/ui new-york variant
  • Use shadcn components for custom UI -- they share the same Tailwind theme and render in ibl.ai brand colors automatically.
You MUST run
/iblai-test
before telling the user the work is ready.
After all work is complete, start a dev server (
pnpm dev
) so the user can see the result at http://localhost:3000.
iblai.env
is NOT a
.env.local
replacement — it only holds the 3 shorthand variables (
DOMAIN
,
PLATFORM
,
TOKEN
). Next.js still reads its runtime env vars from
.env.local
.
Use
pnpm
as the default package manager. Fall back to
npm
if pnpm is not installed. The generated app should live in the current directory, not in a subdirectory.
添加AI工作流构建器功能——包含节点类型浏览器侧边栏、节点配置面板、连接器管理、工作流创建/删除弹窗的可视化工作流编辑器。还支持拖拽功能的自定义画布、贝塞尔曲线连接边、平移/缩放、撤销/重做以及自动保存功能。
AI 助手提示: 请勿为 ibl.ai SDK 组件添加自定义样式、颜色或 CSS 覆盖,它们自带默认样式,请保持组件原样。 除非用户明确要求,否则不要实现深色模式。
当你在 SDK 组件外围构建自定义 UI 时,请遵循 ibl.ai 品牌规范:
  • 主色:
    #0058cc
    , 渐变:
    linear-gradient(135deg, #00b0ef, #0058cc)
  • 按钮:
    bg-gradient-to-r from-[#2563EB] to-[#93C5FD] text-white
  • 字体: 系统无衬线字体栈, 样式: shadcn/ui new-york 变体
  • 自定义 UI 请使用 shadcn 组件——它们共享相同的 Tailwind 主题,会自动适配 ibl.ai 品牌色渲染。
在告知用户工作完成前,你必须先运行
/iblai-test
所有工作完成后,启动开发服务器(
pnpm dev
),用户即可在 http://localhost:3000 查看效果。
iblai.env
不是
.env.local
的替代品——它仅存储3个简写变量(
DOMAIN
PLATFORM
TOKEN
),Next.js 仍会从
.env.local
读取运行时环境变量。
默认使用
pnpm
作为包管理器,若未安装 pnpm 则回退到
npm
。生成的应用应当存放在当前目录,而非子目录中。

Prerequisites

前置要求

  • Auth must be set up first (
    /iblai-auth
    )
  • MCP and skills must be set up:
    iblai add mcp
  • 必须先完成身份验证配置(
    /iblai-auth
  • 必须先完成 MCP 和 skill 配置:
    iblai add mcp

Step 0: Check for CLI Updates

步骤0:检查 CLI 更新

Before running any
iblai
command, ensure the CLI is up to date. Run
iblai --version
to check the current version, then upgrade directly:
  • pip:
    pip install --upgrade iblai-app-cli
  • npm:
    npm install -g @iblai/cli@latest
This is safe to run even if already at the latest version.
运行任何
iblai
命令前,请确保 CLI 是最新版本。运行
iblai --version
检查当前版本,然后直接升级:
  • pip:
    pip install --upgrade iblai-app-cli
  • npm:
    npm install -g @iblai/cli@latest
即使当前已是最新版本,运行该命令也不会产生问题。

Step 1: Check Environment

步骤1:检查环境

Before proceeding, check for a
iblai.env
in the project root. Look for
PLATFORM
,
DOMAIN
, and
TOKEN
variables. If the file does not exist or is missing these variables, tell the user: "You need an
iblai.env
with your platform configuration. Download the template and fill in your values:
curl -o iblai.env https://raw.githubusercontent.com/iblai/vibe/refs/heads/main/iblai.env
"
继续操作前,请检查项目根目录是否存在
iblai.env
文件,确认文件中包含
PLATFORM
DOMAIN
TOKEN
变量。如果文件不存在或缺失这些变量,请告知用户: "你需要一个包含平台配置的
iblai.env
文件。下载模板并填入你的配置值:
curl -o iblai.env https://raw.githubusercontent.com/iblai/vibe/refs/heads/main/iblai.env
"

Architecture

架构说明

The workflow feature has two pages and three custom components:
app/(app)/workflows/
├── page.tsx                          # List page (grid of workflows)
└── [id]/page.tsx                     # Editor page (sidebar + canvas)

components/workflows/
├── types.ts                          # Shared types (CanvasNode, CanvasEdge, NodeConfig)
├── workflow-canvas.tsx               # Custom canvas with SVG edges, pan/zoom, undo/redo
└── node-config-panel.tsx             # Context-aware node property editor
工作流功能包含2个页面和3个自定义组件:
app/(app)/workflows/
├── page.tsx                          # 列表页(工作流网格展示)
└── [id]/page.tsx                     # 编辑器页(侧边栏 + 画布)

components/workflows/
├── types.ts                          # 共享类型(CanvasNode、CanvasEdge、NodeConfig)
├── workflow-canvas.tsx               # 自定义画布,支持SVG连接边、平移/缩放、撤销/重做
└── node-config-panel.tsx             # 上下文感知的节点属性编辑器

Step 2: SDK Components

步骤2:导入 SDK 组件

Import SDK components directly -- there is no CLI generator:
typescript
// UI components
import {
  WorkflowSidebar,
  ConnectorManagementDialog,
  CreateWorkflowModal,
  DeleteWorkflowModal,
} from "@iblai/iblai-js/web-containers";

// RTK Query hooks
import {
  useGetWorkflowsQuery,
  useGetWorkflowQuery,
  useCreateWorkflowMutation,
  useDeleteWorkflowMutation,
  usePatchWorkflowMutation,
  usePublishWorkflowMutation,
  useValidateWorkflowMutation,
  workflowsApiSlice,
} from "@iblai/iblai-js/data-layer";
直接导入 SDK 组件即可,无需 CLI 生成器:
typescript
// UI 组件
import {
  WorkflowSidebar,
  ConnectorManagementDialog,
  CreateWorkflowModal,
  DeleteWorkflowModal,
} from "@iblai/iblai-js/web-containers";

// RTK Query 钩子
import {
  useGetWorkflowsQuery,
  useGetWorkflowQuery,
  useCreateWorkflowMutation,
  useDeleteWorkflowMutation,
  usePatchWorkflowMutation,
  usePublishWorkflowMutation,
  useValidateWorkflowMutation,
  workflowsApiSlice,
} from "@iblai/iblai-js/data-layer";

Step 3: Register workflowsApiSlice in Redux Store

步骤3:在 Redux Store 中注册 workflowsApiSlice

CRITICAL: Add
workflowsApiSlice
to the store in
store/iblai-store.ts
:
typescript
import {
  coreApiSlice,
  mentorReducer,
  mentorMiddleware,
  workflowsApiSlice,
} from "@iblai/iblai-js/data-layer";

export const store = configureStore({
  reducer: {
    [coreApiSlice.reducerPath]: coreApiSlice.reducer,
    [workflowsApiSlice.reducerPath]: workflowsApiSlice.reducer,
    mentor: mentorReducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware()
      .concat(coreApiSlice.middleware)
      .concat(workflowsApiSlice.middleware)
      .concat(mentorMiddleware),
});
Without this, all workflow API hooks will silently return
undefined
.
关键操作: 在
store/iblai-store.ts
中为 store 添加
workflowsApiSlice
typescript
import {
  coreApiSlice,
  mentorReducer,
  mentorMiddleware,
  workflowsApiSlice,
} from "@iblai/iblai-js/data-layer";

export const store = configureStore({
  reducer: {
    [coreApiSlice.reducerPath]: coreApiSlice.reducer,
    [workflowsApiSlice.reducerPath]: workflowsApiSlice.reducer,
    mentor: mentorReducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware()
      .concat(coreApiSlice.middleware)
      .concat(workflowsApiSlice.middleware)
      .concat(mentorMiddleware),
});
如果缺少该配置,所有工作流API钩子都会静默返回
undefined

SDK Component Props (VERIFIED)

SDK 组件属性(已验证)

IMPORTANT: The prop names below are the ACTUAL props accepted by the SDK. The skill docs in older versions had incorrect prop names. Do NOT use
isOpen
,
onClose
,
org
, or
onCreate
-- they will fail.
重要提示: 下方的属性名是 SDK 实际支持的属性,旧版本的 skill 文档存在属性名错误的问题。请勿使用
isOpen
onClose
org
onCreate
,这些属性无法正常工作。

<WorkflowSidebar>

<WorkflowSidebar>

Node type browser sidebar. Does NOT accept
org
or
tenant
.
PropTypeDescription
onDragStart
(item: {id, label, type}) => void
Called when user starts dragging a node type
onItemClick
(item: {id, label, type}) => void
Called when user clicks a node type
节点类型浏览器侧边栏,不支持
org
tenant
属性。
属性类型描述
onDragStart
(item: {id, label, type}) => void
用户开始拖拽节点类型时触发
onItemClick
(item: {id, label, type}) => void
用户点击节点类型时触发

<ConnectorManagementDialog>

<ConnectorManagementDialog>

PropTypeDescription
open
boolean
Whether the dialog is visible
onClose
() => void
Close callback
Note: Uses
open
(NOT
isOpen
). Does NOT accept
org
.
属性类型描述
open
boolean
弹窗是否可见
onClose
() => void
关闭回调
注意:使用
open
属性(而非
isOpen
),不支持
org
属性。

<CreateWorkflowModal>

<CreateWorkflowModal>

PropTypeDescription
open
boolean
Whether the modal is visible
onOpenChange
(open: boolean) => void
Open state setter
onCreateWorkflow
(name: string) => void
Callback with workflow name
isCreating
boolean
Loading state
WARNING: Does NOT use
isOpen
/
onClose
/
onCreate
. Those are wrong.
属性类型描述
open
boolean
弹窗是否可见
onOpenChange
(open: boolean) => void
打开状态设置器
onCreateWorkflow
(name: string) => void
携带工作流名称的创建回调
isCreating
boolean
加载状态
警告: 不要使用
isOpen
/
onClose
/
onCreate
,这些属性是错误的。

<DeleteWorkflowModal>

<DeleteWorkflowModal>

PropTypeDescription
isOpen
boolean
Whether the modal is visible
onClose
() => void
Close callback
onConfirm
() => void
Confirm deletion callback
isDeleting
boolean
Loading state
workflowName
string
Name displayed in confirmation
WARNING: Does NOT use
workflowId
or
onDelete
. Those are wrong.
属性类型描述
isOpen
boolean
弹窗是否可见
onClose
() => void
关闭回调
onConfirm
() => void
确认删除回调
isDeleting
boolean
加载状态
workflowName
string
确认弹窗中展示的工作流名称
警告: 不要使用
workflowId
onDelete
,这些属性是错误的。

Step 4: Create the Types File

步骤4:创建类型文件

Create
components/workflows/types.ts
with the shared data model. Node data is nested in
node.data
(not flat on the node):
typescript
// Workflow canvas types — matching mentorai's data model

export interface Variable {
  id: string;
  name: string;
  type: string;
  defaultValue?: string;
}

export interface Condition {
  id: string;
  caseName: string;
  expression: string;
}

export interface TransformExpression {
  id: string;
  key: string;
  value: string;
}

export interface SetStateAssignment {
  id: string;
  variable: string;
  value: string;
}

export interface NodeConfig {
  label: string;
  subtitle?: string;
  color?: string;
  content?: string;
  // Start node
  stateVariables?: Variable[];
  // Mentor node
  entry_mentor_id?: string;
  mentor_id?: string;
  instructions?: string;
  model?: string;
  // Conditional
  conditionCount?: number;
  conditions?: Condition[];
  // While
  whileExpression?: string;
  // User-approval
  userApprovalMessage?: string;
  // Transform
  transformMode?: "expressions" | "object";
  transformExpressions?: TransformExpression[];
  // Set-state
  setStateAssignments?: SetStateAssignment[];
  // End
  output?: string;
  // File-search
  datasetId?: string;
  datasetName?: string;
  maxResults?: number;
  fileSearchQuery?: string;
  // Shared
  continueOnError?: boolean;
  // MCP
  mcpConnectors?: { id: string; name: string; icon?: string }[];
}

export interface CanvasNode {
  id: string;
  type: string;
  position: { x: number; y: number };
  data: NodeConfig;
  width?: number;
  height?: number;
  selected?: boolean;
  dragging?: boolean;
  draggable?: boolean;
  selectable?: boolean;
  connectable?: boolean;
}

export interface CanvasEdge {
  id: string;
  source: string;
  target: string;
  sourceHandle?: string;
  targetHandle?: string;
}
创建
components/workflows/types.ts
存放共享数据模型。节点数据嵌套
node.data
中(不是平铺在节点对象上):
typescript
// 工作流画布类型 —— 与 mentorai 数据模型对齐

export interface Variable {
  id: string;
  name: string;
  type: string;
  defaultValue?: string;
}

export interface Condition {
  id: string;
  caseName: string;
  expression: string;
}

export interface TransformExpression {
  id: string;
  key: string;
  value: string;
}

export interface SetStateAssignment {
  id: string;
  variable: string;
  value: string;
}

export interface NodeConfig {
  label: string;
  subtitle?: string;
  color?: string;
  content?: string;
  // 开始节点
  stateVariables?: Variable[];
  // Mentor 节点
  entry_mentor_id?: string;
  mentor_id?: string;
  instructions?: string;
  model?: string;
  // 条件分支
  conditionCount?: number;
  conditions?: Condition[];
  // 循环
  whileExpression?: string;
  // 用户审批
  userApprovalMessage?: string;
  // 数据转换
  transformMode?: "expressions" | "object";
  transformExpressions?: TransformExpression[];
  // 状态设置
  setStateAssignments?: SetStateAssignment[];
  // 结束
  output?: string;
  // 文件搜索
  datasetId?: string;
  datasetName?: string;
  maxResults?: number;
  fileSearchQuery?: string;
  // 通用属性
  continueOnError?: boolean;
  // MCP
  mcpConnectors?: { id: string; name: string; icon?: string }[];
}

export interface CanvasNode {
  id: string;
  type: string;
  position: { x: number; y: number };
  data: NodeConfig;
  width?: number;
  height?: number;
  selected?: boolean;
  dragging?: boolean;
  draggable?: boolean;
  selectable?: boolean;
  connectable?: boolean;
}

export interface CanvasEdge {
  id: string;
  source: string;
  target: string;
  sourceHandle?: string;
  targetHandle?: string;
}

Step 5: Create the Workflow List Page

步骤5:创建工作流列表页

app/(app)/workflows/page.tsx
-- shows all workflows in a grid.
Key patterns:
  • Uses
    useGetWorkflowsQuery({ org: tenant, params: search ? { search } : undefined })
  • Workflow response has
    results
    array with
    unique_id
    ,
    name
    ,
    is_active
    ,
    description
    ,
    updated_at
  • Create: passes
    { name, definition: { nodes: DEFAULT_NODES, edges: DEFAULT_EDGES } }
  • Delete: passes
    { org: tenant, uniqueId: deleteTarget.id }
  • Navigates to
    /workflows/${result.unique_id}
    after create
Default nodes for new workflows:
typescript
const DEFAULT_NODES = [
  {
    id: "start",
    type: "start",
    position: { x: 300, y: 250 },
    data: { label: "Start" },
  },
  {
    id: "mentor-1",
    type: "mentor",
    position: { x: 550, y: 250 },
    data: { label: "My mentor", subtitle: "Mentor" },
  },
];

const DEFAULT_EDGES = [
  {
    id: "e-start-mentor-1",
    source: "start",
    target: "mentor-1",
    sourceHandle: "right",
    targetHandle: "left",
  },
];
app/(app)/workflows/page.tsx
—— 以网格形式展示所有工作流。
核心实现模式:
  • 使用
    useGetWorkflowsQuery({ org: tenant, params: search ? { search } : undefined })
  • 工作流响应的
    results
    数组包含
    unique_id
    name
    is_active
    description
    updated_at
    字段
  • 创建:传入
    { name, definition: { nodes: DEFAULT_NODES, edges: DEFAULT_EDGES } }
  • 删除:传入
    { org: tenant, uniqueId: deleteTarget.id }
  • 创建完成后跳转至
    /workflows/${result.unique_id}
新建工作流的默认节点:
typescript
const DEFAULT_NODES = [
  {
    id: "start",
    type: "start",
    position: { x: 300, y: 250 },
    data: { label: "开始" },
  },
  {
    id: "mentor-1",
    type: "mentor",
    position: { x: 550, y: 250 },
    data: { label: "我的mentor", subtitle: "Mentor" },
  },
];

const DEFAULT_EDGES = [
  {
    id: "e-start-mentor-1",
    source: "start",
    target: "mentor-1",
    sourceHandle: "right",
    targetHandle: "left",
  },
];

Step 6: Create the Workflow Editor Page

步骤6:创建工作流编辑器页

app/(app)/workflows/[id]/page.tsx
-- loads a workflow and renders the sidebar + canvas with save/publish controls.
Key patterns:
  • Uses
    useGetWorkflowQuery({ org: tenant, uniqueId: workflowId })
  • Save:
    usePatchWorkflowMutation
    with
    { org, uniqueId, data: { definition: { nodes, edges } } }
  • Publish: save first, then
    useValidateWorkflowMutation
    , then
    usePublishWorkflowMutation
  • Validation response:
    { errors: string[], warnings: string[] }
  • Cast
    workflow.definition?.nodes as CanvasNode[]
    (API types them as
    unknown[]
    )
app/(app)/workflows/[id]/page.tsx
—— 加载工作流并渲染侧边栏+画布,包含保存/发布控制按钮。
核心实现模式:
  • 使用
    useGetWorkflowQuery({ org: tenant, uniqueId: workflowId })
  • 保存:
    usePatchWorkflowMutation
    传入
    { org, uniqueId, data: { definition: { nodes, edges } } }
  • 发布:先保存,然后调用
    useValidateWorkflowMutation
    ,最后调用
    usePublishWorkflowMutation
  • 验证响应结构:
    { errors: string[], warnings: string[] }
  • workflow.definition?.nodes
    强制转换为
    CanvasNode[]
    (API 会将其类型标注为
    unknown[]

Auto-save pattern

自动保存实现模式

Use a 2-second debounced auto-save:
typescript
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isSavingRef = useRef(false);

const doSave = useCallback(async () => {
  if (isSavingRef.current) return;
  const { nodes, edges } = currentStateRef.current;
  const current = JSON.stringify({ nodes, edges });
  if (current === initialDataRef.current) return;
  isSavingRef.current = true;
  try {
    await patchWorkflow({ org: tenant, uniqueId: workflowId, data: { definition: { nodes, edges } } }).unwrap();
    initialDataRef.current = current;
    setHasUnsavedChanges(false);
    setSaveMessage("Saved");
    setTimeout(() => setSaveMessage(null), 2000);
  } catch {
    setSaveMessage("Save failed");
    setTimeout(() => setSaveMessage(null), 3000);
  } finally {
    isSavingRef.current = false;
  }
}, [patchWorkflow, tenant, workflowId]);

const scheduleAutoSave = useCallback(() => {
  if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current);
  autoSaveTimerRef.current = setTimeout(() => doSave(), 2000);
}, [doSave]);

const handleStateChange = useCallback((nodes, edges) => {
  currentStateRef.current = { nodes, edges };
  if (initialDataRef.current) {
    const changed = JSON.stringify({ nodes, edges }) !== initialDataRef.current;
    setHasUnsavedChanges(changed);
    if (changed) scheduleAutoSave();
  }
}, [scheduleAutoSave]);
Manual save should cancel any pending auto-save timer:
typescript
const handleSave = async () => {
  if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current);
  await doSave();
};
Clean up the timer on unmount:
typescript
useEffect(() => {
  return () => {
    if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current);
  };
}, []);
使用2秒防抖的自动保存:
typescript
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isSavingRef = useRef(false);

const doSave = useCallback(async () => {
  if (isSavingRef.current) return;
  const { nodes, edges } = currentStateRef.current;
  const current = JSON.stringify({ nodes, edges });
  if (current === initialDataRef.current) return;
  isSavingRef.current = true;
  try {
    await patchWorkflow({ org: tenant, uniqueId: workflowId, data: { definition: { nodes, edges } } }).unwrap();
    initialDataRef.current = current;
    setHasUnsavedChanges(false);
    setSaveMessage("已保存");
    setTimeout(() => setSaveMessage(null), 2000);
  } catch {
    setSaveMessage("保存失败");
    setTimeout(() => setSaveMessage(null), 3000);
  } finally {
    isSavingRef.current = false;
  }
}, [patchWorkflow, tenant, workflowId]);

const scheduleAutoSave = useCallback(() => {
  if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current);
  autoSaveTimerRef.current = setTimeout(() => doSave(), 2000);
}, [doSave]);

const handleStateChange = useCallback((nodes, edges) => {
  currentStateRef.current = { nodes, edges };
  if (initialDataRef.current) {
    const changed = JSON.stringify({ nodes, edges }) !== initialDataRef.current;
    setHasUnsavedChanges(changed);
    if (changed) scheduleAutoSave();
  }
}, [scheduleAutoSave]);
手动保存应当取消所有待执行的自动保存定时器:
typescript
const handleSave = async () => {
  if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current);
  await doSave();
};
组件卸载时清理定时器:
typescript
useEffect(() => {
  return () => {
    if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current);
  };
}, []);

Step 7: Build the Canvas Component

步骤7:构建画布组件

components/workflows/workflow-canvas.tsx
-- a custom canvas (no ReactFlow).
components/workflows/workflow-canvas.tsx
—— 自定义画布(不依赖 ReactFlow)。

Critical implementation details

关键实现细节

  1. Node dimension measurement: Nodes auto-size to content, so you MUST measure actual rendered dimensions with refs. Without this, edge lines won't connect to handle dots:
    typescript
    const nodeRefsMap = useRef<Map<string, HTMLDivElement>>(new Map());
    const nodeSizesRef = useRef<Map<string, { w: number; h: number }>>(new Map());
    
    useEffect(() => {
      let changed = false;
      nodeRefsMap.current.forEach((el, id) => {
        const rect = el.getBoundingClientRect();
        const w = rect.width / zoom;
        const h = rect.height / zoom;
        const prev = nodeSizesRef.current.get(id);
        if (!prev || Math.abs(prev.w - w) > 1 || Math.abs(prev.h - h) > 1) {
          nodeSizesRef.current.set(id, { w, h });
          changed = true;
        }
      });
      if (changed) {
        setNodes((prev) => prev.map((n) => {
          const measured = nodeSizesRef.current.get(n.id);
          if (measured && (n.width !== measured.w || n.height !== measured.h))
            return { ...n, width: measured.w, height: measured.h };
          return n;
        }));
      }
    });
    
    const nodeRef = useCallback((id: string) => (el: HTMLDivElement | null) => {
      if (el) nodeRefsMap.current.set(id, el);
      else nodeRefsMap.current.delete(id);
    }, []);
    Attach
    ref={nodeRef(node.id)}
    to every node div (Start, End, While, Conditional, User-Approval, Note, Mentor, and Default). Missing even one will cause edge misalignment for that node type.
  2. Handle position math: The handle dots are positioned with CSS (
    left: -HANDLE_RADIUS - 2
    ), so the edge endpoint must match:
    typescript
    const HANDLE_RADIUS = 6;
    const HANDLE_OFFSET = 2; // NOT 8! Must equal center of handle dot relative to node edge
    
    function getHandlePosition(node: CanvasNode, handle: string) {
      const w = node.width ?? NODE_WIDTH;
      const h = node.height ?? NODE_HEIGHT;
      switch (handle) {
        case "left":   return { x: node.position.x - HANDLE_OFFSET, y: node.position.y + h / 2 };
        case "right":  return { x: node.position.x + w + HANDLE_OFFSET, y: node.position.y + h / 2 };
        case "top":    return { x: node.position.x + w / 2, y: node.position.y - HANDLE_OFFSET };
        case "bottom": return { x: node.position.x + w / 2, y: node.position.y + h + HANDLE_OFFSET };
      }
    }
    The handle dot CSS is
    left: -(HANDLE_RADIUS + 2)
    = -8px from node edge. The dot is 12px wide, so its center is at -8 + 6 = -2px from node edge. Therefore
    HANDLE_OFFSET = 2
    makes the edge endpoint hit the center.
  3. Drop handler: Read from
    e.dataTransfer
    , NOT from a ref:
    typescript
    const handleDrop = (e: React.DragEvent) => {
      e.preventDefault();
      const raw = e.dataTransfer.getData("application/json") || e.dataTransfer.getData("text/plain");
      if (!raw) return;
      const item = JSON.parse(raw);
      // ... create node at drop position
    };
    Using a ref for the dragged item causes "Cannot read properties of null" because the sidebar's
    onDragStart
    may not fire before
    onDrop
    .
  4. Edge rendering: SVG layer for bezier curves with pan/zoom transform. Use two paths per edge -- one transparent for click target, one visible:
    tsx
    <path d={path} stroke="transparent" strokeWidth="12" fill="none" onClick={() => removeEdge(edge.id)} />
    <path d={path} stroke="#38A1E5" strokeWidth="2" fill="none" strokeLinecap="round" />
  5. Node type renderers: Each node type needs its own visual:
    • Start: play icon, right handle only
    • End: stop icon (red), left handle only
    • Mentor: bot icon, pencil config button, all 4 handles
    • Conditional: branch icon, dynamic condition rows with per-condition right handles + left handle
    • While: dashed border container, loop icon, left + right handles
    • User-Approval: thumbs-up icon, approve/reject rows with right handles + left handle
    • Note: amber sticky note background, no handles
    • Default (transform, set-state, etc.): bot icon, type label, all 4 handles
  6. Middle-click panning: Handle
    e.button === 1
    BEFORE the
    e.button !== 0
    guard:
    typescript
    const handleCanvasMouseDown = (e) => {
      if (e.button === 1) { // middle-click -> pan
        setIsPanning(true);
        setPanStart({ x: e.clientX - panOffset.x, y: e.clientY - panOffset.y });
        return;
      }
      if (e.button !== 0) return; // only left-click below
      // ...
    };
    If you check
    e.button !== 0
    first, TypeScript narrows the type to
    0
    and
    e.button === 1
    becomes a type error.
  7. Click vs drag distinction: The config panel must open on mouseup (click without drag), NOT on mousedown. Otherwise dragging a node also opens its config panel:
    typescript
    const handleNodeMouseDown = (e, nodeId) => {
      // Select the node, set up drag tracking
      dragStartRef.current = { x: e.clientX, y: e.clientY };
      dragMovedRef.current = false;
      setDraggedNode(nodeId);
      // Do NOT open config panel here
    };
    
    const handleMouseUp = () => {
      if (draggedNode) {
        setNodes((prev) => prev.map((n) => ({ ...n, dragging: false })));
        if (dragMovedRef.current) {
          saveToHistory(nodes, edges);
        } else {
          // Click without drag -- open config panel
          setSelectedNodeForConfig(draggedNode);
        }
      }
      // ... cleanup
    };
    Use
    DRAG_THRESHOLD = 3
    pixels to distinguish click from drag:
    typescript
    // In handleMouseMove:
    if (dragStartRef.current) {
      const dx = Math.abs(e.clientX - dragStartRef.current.x);
      const dy = Math.abs(e.clientY - dragStartRef.current.y);
      if (dx > DRAG_THRESHOLD || dy > DRAG_THRESHOLD) dragMovedRef.current = true;
    }
  1. 节点尺寸测量: 节点会根据内容自动调整大小,因此你必须使用 ref 测量实际渲染的尺寸。缺少这一步,连接线将无法正确连接到节点的连接点:
    typescript
    const nodeRefsMap = useRef<Map<string, HTMLDivElement>>(new Map());
    const nodeSizesRef = useRef<Map<string, { w: number; h: number }>>(new Map());
    
    useEffect(() => {
      let changed = false;
      nodeRefsMap.current.forEach((el, id) => {
        const rect = el.getBoundingClientRect();
        const w = rect.width / zoom;
        const h = rect.height / zoom;
        const prev = nodeSizesRef.current.get(id);
        if (!prev || Math.abs(prev.w - w) > 1 || Math.abs(prev.h - h) > 1) {
          nodeSizesRef.current.set(id, { w, h });
          changed = true;
        }
      });
      if (changed) {
        setNodes((prev) => prev.map((n) => {
          const measured = nodeSizesRef.current.get(n.id);
          if (measured && (n.width !== measured.w || n.height !== measured.h))
            return { ...n, width: measured.w, height: measured.h };
          return n;
        }));
      }
    });
    
    const nodeRef = useCallback((id: string) => (el: HTMLDivElement | null) => {
      if (el) nodeRefsMap.current.set(id, el);
      else nodeRefsMap.current.delete(id);
    }, []);
    所有节点 div(开始、结束、循环、条件分支、用户审批、注释、Mentor、默认节点)都添加
    ref={nodeRef(node.id)}
    ,哪怕遗漏一个都会导致对应节点类型的连接线对齐错误。
  2. 连接点位置计算: 连接点通过 CSS 定位(
    left: -HANDLE_RADIUS - 2
    ),因此连接线的端点必须与之匹配:
    typescript
    const HANDLE_RADIUS = 6;
    const HANDLE_OFFSET = 2; // 不是8!必须等于连接点中心相对于节点边缘的偏移量
    
    function getHandlePosition(node: CanvasNode, handle: string) {
      const w = node.width ?? NODE_WIDTH;
      const h = node.height ?? NODE_HEIGHT;
      switch (handle) {
        case "left":   return { x: node.position.x - HANDLE_OFFSET, y: node.position.y + h / 2 };
        case "right":  return { x: node.position.x + w + HANDLE_OFFSET, y: node.position.y + h / 2 };
        case "top":    return { x: node.position.x + w / 2, y: node.position.y - HANDLE_OFFSET };
        case "bottom": return { x: node.position.x + w / 2, y: node.position.y + h + HANDLE_OFFSET };
      }
    }
    连接点的 CSS 为
    left: -(HANDLE_RADIUS + 2)
    = 距离节点边缘-8px,连接点宽度为12px,因此其中心位置距离节点边缘为 -8 + 6 = -2px,因此设置
    HANDLE_OFFSET = 2
    可让连接线端点刚好命中连接点中心。
  3. 放置事件处理: 从
    e.dataTransfer
    读取数据,不要使用 ref:
    typescript
    const handleDrop = (e: React.DragEvent) => {
      e.preventDefault();
      const raw = e.dataTransfer.getData("application/json") || e.dataTransfer.getData("text/plain");
      if (!raw) return;
      const item = JSON.parse(raw);
      // ... 在放置位置创建节点
    };
    使用 ref 存储拖拽元素会导致“无法读取 null 的属性”错误,因为侧边栏的
    onDragStart
    可能在
    onDrop
    之后才触发。
  4. 连接线渲染: 使用 SVG 层渲染贝塞尔曲线,支持平移/缩放变换。每条连接线使用两个路径——一个透明路径作为点击区域,一个可见路径作为连接线:
    tsx
    <path d={path} stroke="transparent" strokeWidth="12" fill="none" onClick={() => removeEdge(edge.id)} />
    <path d={path} stroke="#38A1E5" strokeWidth="2" fill="none" strokeLinecap="round" />
  5. 节点类型渲染器: 每个节点类型需要对应的视觉样式:
    • 开始: 播放图标,仅右侧有连接点
    • 结束: 停止图标(红色),仅左侧有连接点
    • Mentor: 机器人图标,编辑配置按钮,4个方向都有连接点
    • 条件分支: 分支图标,动态条件行,每个条件对应右侧连接点 + 左侧连接点
    • 循环: 虚线边框容器,循环图标,左右侧有连接点
    • 用户审批: 点赞图标,审批/拒绝行对应右侧连接点 + 左侧连接点
    • 注释: 琥珀色便签背景,无连接点
    • 默认节点(转换、设置状态等): 机器人图标,类型标签,4个方向都有连接点
  6. 中键平移: 在
    e.button !== 0
    判断之前先处理
    e.button === 1
    typescript
    const handleCanvasMouseDown = (e) => {
      if (e.button === 1) { // 中键 -> 平移
        setIsPanning(true);
        setPanStart({ x: e.clientX - panOffset.x, y: e.clientY - panOffset.y });
        return;
      }
      if (e.button !== 0) return; // 下方仅处理左键事件
      // ...
    };
    如果你先判断
    e.button !== 0
    ,TypeScript 会将类型缩小为
    0
    ,导致
    e.button === 1
    出现类型错误。
  7. 点击与拖拽的区分: 配置面板必须在 mouseup 事件中打开(未发生拖拽的点击),不要在 mousedown 中打开,否则拖拽节点时也会打开配置面板:
    typescript
    const handleNodeMouseDown = (e, nodeId) => {
      // 选中节点,设置拖拽跟踪状态
      dragStartRef.current = { x: e.clientX, y: e.clientY };
      dragMovedRef.current = false;
      setDraggedNode(nodeId);
      // 不要在这里打开配置面板
    };
    
    const handleMouseUp = () => {
      if (draggedNode) {
        setNodes((prev) => prev.map((n) => ({ ...n, dragging: false })));
        if (dragMovedRef.current) {
          saveToHistory(nodes, edges);
        } else {
          // 未发生拖拽的点击——打开配置面板
          setSelectedNodeForConfig(draggedNode);
        }
      }
      // ... 清理状态
    };
    使用
    DRAG_THRESHOLD = 3
    像素的阈值区分点击和拖拽:
    typescript
    // 在 handleMouseMove 中:
    if (dragStartRef.current) {
      const dx = Math.abs(e.clientX - dragStartRef.current.x);
      const dy = Math.abs(e.clientY - dragStartRef.current.y);
      if (dx > DRAG_THRESHOLD || dy > DRAG_THRESHOLD) dragMovedRef.current = true;
    }

WorkflowCanvas props

WorkflowCanvas 属性

typescript
interface WorkflowCanvasProps {
  draggedItem: { id: string; label: string; type: string } | null;
  clickedItem: { id: string; label: string; type: string } | null;
  onStateChange?: (nodes: CanvasNode[], edges: CanvasEdge[]) => void;
  initialNodes?: CanvasNode[];
  initialEdges?: CanvasEdge[];
}
typescript
interface WorkflowCanvasProps {
  draggedItem: { id: string; label: string; type: string } | null;
  clickedItem: { id: string; label: string; type: string } | null;
  onStateChange?: (nodes: CanvasNode[], edges: CanvasEdge[]) => void;
  initialNodes?: CanvasNode[];
  initialEdges?: CanvasEdge[];
}

Step 8: Build the Node Config Panel

步骤8:构建节点配置面板

components/workflows/node-config-panel.tsx
-- opens when a node is clicked.
components/workflows/node-config-panel.tsx
—— 点击节点时打开的配置面板。

Critical: Event propagation

关键:事件传播阻止

The config panel MUST stop event propagation on both
onClick
AND
onMouseDown
. Without this, clicks inside the panel bubble to the canvas, which deselects the node and closes the panel:
typescript
const panelShell = (children: React.ReactNode) => (
  <div
    className="absolute top-4 right-4 z-20 flex max-h-[calc(100vh-14rem)] w-[320px] flex-col rounded-xl border border-zinc-200 bg-white shadow-xl"
    onClick={(e) => e.stopPropagation()}
    onMouseDown={(e) => e.stopPropagation()}
  >
    <div className="flex-1 space-y-3 overflow-y-auto p-3">{children}</div>
  </div>
);
Any modals spawned from the panel (e.g. "Add variable" modal) also need
onClick
and
onMouseDown
stopPropagation on their overlay:
tsx
{showAddVar && (
  <div
    className="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
    onClick={(e) => e.stopPropagation()}
    onMouseDown={(e) => e.stopPropagation()}
  >
    {/* modal content */}
  </div>
)}
配置面板必须同时在
onClick
onMouseDown
事件中阻止事件冒泡,否则面板内的点击会冒泡到画布,导致节点被取消选中、面板被关闭:
typescript
const panelShell = (children: React.ReactNode) => (
  <div
    className="absolute top-4 right-4 z-20 flex max-h-[calc(100vh-14rem)] w-[320px] flex-col rounded-xl border border-zinc-200 bg-white shadow-xl"
    onClick={(e) => e.stopPropagation()}
    onMouseDown={(e) => e.stopPropagation()}
  >
    <div className="flex-1 space-y-3 overflow-y-auto p-3">{children}</div>
  </div>
);
从面板中弹出的所有弹窗(例如“添加变量”弹窗)的遮罩层也需要添加
onClick
onMouseDown
阻止事件冒泡:
tsx
{showAddVar && (
  <div
    className="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
    onClick={(e) => e.stopPropagation()}
    onMouseDown={(e) => e.stopPropagation()}
  >
    {/* 弹窗内容 */}
  </div>
)}

Props

属性

typescript
interface NodeConfigPanelProps {
  nodeId: string;
  nodeType: string;
  nodeData: NodeConfig;
  onClose: () => void;
  onUpdateNode: (nodeId: string, updates: Partial<NodeConfig>) => void;
  org?: string;
}
typescript
interface NodeConfigPanelProps {
  nodeId: string;
  nodeType: string;
  nodeData: NodeConfig;
  onClose: () => void;
  onUpdateNode: (nodeId: string, updates: Partial<NodeConfig>) => void;
  org?: string;
}

Sync pattern with isLocalUpdate ref

使用 isLocalUpdate ref 实现同步模式

Use an
isLocalUpdate
ref to prevent sync loops. When the panel updates nodeData via
onUpdateNode
, the parent re-renders with new nodeData. The sync effect should skip that update to avoid overwriting the user's input cursor position:
typescript
const isLocalUpdate = useRef(false);

const update = useCallback((updates: Partial<NodeConfig>) => {
  isLocalUpdate.current = true;
  onUpdateNode(nodeId, updates);
}, [nodeId, onUpdateNode]);

// Sync from external changes (undo/redo, switching nodes)
useEffect(() => {
  if (isLocalUpdate.current) {
    isLocalUpdate.current = false;
    return;
  }
  setNodeName(nodeData.label);
  setInstructions(nodeData.instructions ?? "");
  setStateVariables(nodeData.stateVariables ?? []);
  setConditions(nodeData.conditions ?? [{ id: "c-1", caseName: "", expression: "" }]);
  setWhileExpr(nodeData.whileExpression ?? "");
  setTransformExprs(nodeData.transformExpressions ?? [{ id: "t-1", key: "", value: "" }]);
  setTransformMode(nodeData.transformMode ?? "expressions");
  setAssignments(nodeData.setStateAssignments ?? [{ id: "a-1", variable: "", value: "" }]);
  setEndOutput(nodeData.output ?? "");
  setApprovalMsg(nodeData.userApprovalMessage ?? "");
  setContinueOnError(nodeData.continueOnError ?? false);
}, [nodeData]);
使用
isLocalUpdate
ref 避免同步循环。当面板通过
onUpdateNode
更新 nodeData 时,父组件会使用新的 nodeData 重新渲染,同步 effect 应当跳过该次更新,避免覆盖用户的输入光标位置:
typescript
const isLocalUpdate = useRef(false);

const update = useCallback((updates: Partial<NodeConfig>) => {
  isLocalUpdate.current = true;
  onUpdateNode(nodeId, updates);
}, [nodeId, onUpdateNode]);

// 同步外部变更(撤销/重做、切换节点)
useEffect(() => {
  if (isLocalUpdate.current) {
    isLocalUpdate.current = false;
    return;
  }
  setNodeName(nodeData.label);
  setInstructions(nodeData.instructions ?? "");
  setStateVariables(nodeData.stateVariables ?? []);
  setConditions(nodeData.conditions ?? [{ id: "c-1", caseName: "", expression: "" }]);
  setWhileExpr(nodeData.whileExpression ?? "");
  setTransformExprs(nodeData.transformExpressions ?? [{ id: "t-1", key: "", value: "" }]);
  setTransformMode(nodeData.transformMode ?? "expressions");
  setAssignments(nodeData.setStateAssignments ?? [{ id: "a-1", variable: "", value: "" }]);
  setEndOutput(nodeData.output ?? "");
  setApprovalMsg(nodeData.userApprovalMessage ?? "");
  setContinueOnError(nodeData.continueOnError ?? false);
}, [nodeData]);

Node type panels

节点类型面板

Each node type gets its own panel:
  • start: State variable list + "Add variable" modal with type picker (String/Number/Boolean/Object/List)
  • mentor: Name, instructions textarea, model (read-only), "Continue on error" toggle
  • conditional (
    if-else
    or
    conditional
    ): Dynamic condition list with add/remove
  • while: Expression textarea
  • transform: Mode toggle (expressions/object) + key/value pairs
  • set-state: Variable/value assignment pairs
  • end: Output textarea
  • user-approval: Name + approval message textarea
  • guardrails: Checkbox list for PII, Moderation, Jailbreak, Hallucination
  • file-search: Max results + query
  • mcp: Placeholder for MCP server connections
  • fallback: Name field only
每个节点类型对应独立的配置面板:
  • start: 状态变量列表 + 带类型选择器的“添加变量”弹窗(字符串/数字/布尔值/对象/列表)
  • mentor: 名称、指令文本框、模型(只读)、“出错后继续”开关
  • conditional
    if-else
    conditional
    ): 支持增删的动态条件列表
  • while: 表达式文本框
  • transform: 模式切换(表达式/对象)+ 键值对配置
  • set-state: 变量/值赋值对配置
  • end: 输出文本框
  • user-approval: 名称 + 审批消息文本框
  • guardrails: PII、内容审核、越狱检测、幻觉检测的复选框列表
  • file-search: 最大结果数 + 查询语句配置
  • mcp: MCP 服务器连接占位符
  • fallback: 仅名称字段

Continue on error toggle (mentor panel)

出错后继续开关(Mentor 面板)

The mentor panel includes a "Continue on error" toggle switch:
tsx
<div className="flex items-center justify-between rounded-lg border border-zinc-100 bg-zinc-50/80 p-2.5">
  <span className="text-xs text-zinc-700">Continue on error</span>
  <button
    onClick={() => {
      const next = !continueOnError;
      setContinueOnError(next);
      update({ continueOnError: next });
    }}
    className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
      continueOnError ? "bg-[#38A1E5]" : "bg-zinc-300"
    }`}
  >
    <span
      className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white transition-transform ${
        continueOnError ? "translate-x-4" : "translate-x-0.5"
      }`}
    />
  </button>
</div>
Mentor 面板包含“出错后继续”开关组件:
tsx
<div className="flex items-center justify-between rounded-lg border border-zinc-100 bg-zinc-50/80 p-2.5">
  <span className="text-xs text-zinc-700">出错后继续</span>
  <button
    onClick={() => {
      const next = !continueOnError;
      setContinueOnError(next);
      update({ continueOnError: next });
    }}
    className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
      continueOnError ? "bg-[#38A1E5]" : "bg-zinc-300"
    }`}
  >
    <span
      className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white transition-transform ${
        continueOnError ? "translate-x-4" : "translate-x-0.5"
      }`}
    />
  </button>
</div>

handleUpdateNode in the canvas

画布中的 handleUpdateNode 实现

typescript
const handleUpdateNode = useCallback(
  (nid: string, updates: Partial<NodeConfig>) => {
    setNodes((prev) => {
      const newNodes = prev.map((n) => {
        if (n.id !== nid) return n;
        // Sync mentor_id when entry_mentor_id is set
        const shouldSync = n.type === "mentor" && updates.entry_mentor_id !== undefined;
        const normalized = shouldSync ? { ...updates, mentor_id: updates.entry_mentor_id } : updates;
        return { ...n, data: { ...n.data, ...normalized } };
      });
      saveToHistory(newNodes, edges);
      return newNodes;
    });
  },
  [edges, saveToHistory]
);
typescript
const handleUpdateNode = useCallback(
  (nid: string, updates: Partial<NodeConfig>) => {
    setNodes((prev) => {
      const newNodes = prev.map((n) => {
        if (n.id !== nid) return n;
        // 当设置 entry_mentor_id 时同步 mentor_id
        const shouldSync = n.type === "mentor" && updates.entry_mentor_id !== undefined;
        const normalized = shouldSync ? { ...updates, mentor_id: updates.entry_mentor_id } : updates;
        return { ...n, data: { ...n.data, ...normalized } };
      });
      saveToHistory(newNodes, edges);
      return newNodes;
    });
  },
  [edges, saveToHistory]
);

Step 9: Verify

步骤9:验证

Run
/iblai-test
before telling the user the work is ready:
  1. pnpm build
    -- must pass with zero errors
  2. Start dev server and verify the workflow pages render:
    bash
    pnpm dev &
    npx playwright screenshot http://localhost:3000/workflows /tmp/workflows.png
在告知用户工作完成前运行
/iblai-test
  1. pnpm build
    —— 必须零错误通过
  2. 启动开发服务器,验证工作流页面正常渲染:
    bash
    pnpm dev &
    npx playwright screenshot http://localhost:3000/workflows /tmp/workflows.png

Common Pitfalls

常见问题

  1. Wrong SDK prop names: The skill docs previously listed incorrect props.
    CreateWorkflowModal
    uses
    open
    /
    onOpenChange
    /
    onCreateWorkflow
    (NOT
    isOpen
    /
    onClose
    /
    onCreate
    ).
    ConnectorManagementDialog
    uses
    open
    (NOT
    isOpen
    ). Neither takes
    org
    .
    WorkflowSidebar
    uses
    onDragStart
    /
    onItemClick
    (NOT
    org
    ).
  2. Edge line gap: If edges don't connect flush to handle dots, check that
    HANDLE_OFFSET
    equals the handle dot's center offset from the node edge (typically 2, not 8). Also ensure ALL node types have
    ref={nodeRef(node.id)}
    on their outermost div so dimensions are measured correctly.
  3. "Cannot read properties of null": When dropping from sidebar, read
    e.dataTransfer.getData()
    instead of a ref. The ref may not be set.
  4. TypeScript
    e.button
    narrowing
    : Handle middle-click before the
    !== 0
    guard to avoid type narrowing issues.
  5. Node data model: Data must be nested in
    node.data
    (NodeConfig), not flat on the node. The API returns
    { id, type, position, data: {...} }
    .
  6. workflowsApiSlice not in store: All workflow hooks return
    undefined
    if the slice isn't registered. Add both reducer and middleware.
  7. Config panel closes on click: The
    panelShell
    div MUST have both
    onClick={(e) => e.stopPropagation()}
    and
    onMouseDown={(e) => e.stopPropagation()}
    . Without BOTH, clicks propagate to the canvas, deselecting the node and closing the panel. Same applies to any modals spawned inside the panel.
  8. Dragging opens config panel: Config panel must open on mouseup (when
    !dragMovedRef.current
    ), NOT on mousedown. Opening on mousedown means every drag also opens the panel.
  9. Missing continueOnError in sync effect: If you add
    continueOnError
    state, you MUST also add
    setContinueOnError(nodeData.continueOnError ?? false)
    to the sync effect. Otherwise undo/redo and node switching won't update the toggle state.
  1. SDK 属性名错误: 旧版 skill 文档列出了错误的属性。
    CreateWorkflowModal
    使用
    open
    /
    onOpenChange
    /
    onCreateWorkflow
    (而非
    isOpen
    /
    onClose
    /
    onCreate
    );
    ConnectorManagementDialog
    使用
    open
    (而非
    isOpen
    ),两者都不支持
    org
    属性;
    WorkflowSidebar
    使用
    onDragStart
    /
    onItemClick
    (不支持
    org
    )。
  2. 连接线间隙: 如果连接线没有与连接点完美对齐,请检查
    HANDLE_OFFSET
    是否等于连接点中心相对于节点边缘的偏移量(通常为2,不是8)。同时确保所有节点类型的最外层 div 都添加了
    ref={nodeRef(node.id)}
    ,以便正确测量尺寸。
  3. “无法读取 null 的属性”: 从侧边栏拖拽放置时,请从
    e.dataTransfer.getData()
    读取数据,不要使用 ref,ref 可能还未完成赋值。
  4. TypeScript
    e.button
    类型缩小问题
    : 在
    !== 0
    判断之前先处理中键点击,避免类型缩小导致的错误。
  5. 节点数据模型: 数据必须嵌套在
    node.data
    (NodeConfig)中,不要平铺在节点对象上。API 返回结构为
    { id, type, position, data: {...} }
  6. workflowsApiSlice 未注册到 store: 如果 slice 未注册,所有工作流钩子都会返回
    undefined
    ,请同时添加 reducer 和 middleware。
  7. 点击配置面板时面板自动关闭:
    panelShell
    div 必须同时添加
    onClick={(e) => e.stopPropagation()}
    onMouseDown={(e) => e.stopPropagation()}
    ,缺少任意一个都会导致点击事件冒泡到画布,取消节点选中并关闭面板。面板内弹出的所有弹窗也需要遵循该规则。
  8. 拖拽节点时打开配置面板: 配置面板必须在 mouseup 事件中打开(当
    !dragMovedRef.current
    时),不要在 mousedown 中打开,在 mousedown 中打开会导致每次拖拽都触发面板打开。
  9. 同步 effect 中缺少 continueOnError: 如果你添加了
    continueOnError
    状态,必须在同步 effect 中添加
    setContinueOnError(nodeData.continueOnError ?? false)
    ,否则撤销/重做和节点切换操作不会更新开关状态。

Important Notes

重要提示

  • Import:
    @iblai/iblai-js/web-containers
    -- framework-agnostic
  • Data hooks:
    @iblai/iblai-js/data-layer
    -- RTK Query hooks
  • Redux store: Must include
    mentorReducer
    ,
    mentorMiddleware
    , AND
    workflowsApiSlice
  • initializeDataLayer()
    : 5 args (v1.2+)
  • @reduxjs/toolkit
    : Deduplicated via webpack aliases in
    next.config.ts
  • Brand guidelines: BRAND.md
  • 导入路径:
    @iblai/iblai-js/web-containers
    —— 框架无关
  • 数据钩子:
    @iblai/iblai-js/data-layer
    —— RTK Query 钩子
  • Redux store: 必须包含
    mentorReducer
    mentorMiddleware
    workflowsApiSlice
  • initializeDataLayer()
    : 5个参数(v1.2+版本)
  • @reduxjs/toolkit
    : 通过
    next.config.ts
    中的 webpack 别名实现去重
  • 品牌规范: BRAND.md