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:
, Gradient:#0058cclinear-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 runbefore telling the user the work is ready./iblai-testAfter all work is complete, start a dev server () so the user can see the result at http://localhost:3000.pnpm devis NOT aiblai.envreplacement — it only holds the 3 shorthand variables (.env.local,DOMAIN,PLATFORM). Next.js still reads its runtime env vars fromTOKEN..env.localUseas the default package manager. Fall back topnpmif pnpm is not installed. The generated app should live in the current directory, not in a subdirectory.npm
添加AI工作流构建器功能——包含节点类型浏览器侧边栏、节点配置面板、连接器管理、工作流创建/删除弹窗的可视化工作流编辑器。还支持拖拽功能的自定义画布、贝塞尔曲线连接边、平移/缩放、撤销/重做以及自动保存功能。
AI 助手提示: 请勿为 ibl.ai SDK 组件添加自定义样式、颜色或 CSS 覆盖,它们自带默认样式,请保持组件原样。 除非用户明确要求,否则不要实现深色模式。当你在 SDK 组件外围构建自定义 UI 时,请遵循 ibl.ai 品牌规范:
- 主色:
, 渐变:#0058cclinear-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所有工作完成后,启动开发服务器(),用户即可在 http://localhost:3000 查看效果。pnpm dev不是iblai.env的替代品——它仅存储3个简写变量(.env.local、DOMAIN、PLATFORM),Next.js 仍会从TOKEN读取运行时环境变量。.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 command, ensure the CLI is
up to date. Run to check the current version, then
upgrade directly:
iblaiiblai --version- 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.
运行任何 命令前,请确保 CLI 是最新版本。运行 检查当前版本,然后直接升级:
iblaiiblai --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
in the project root. Look for , , and variables.
If the file does not exist or is missing these variables, tell the user:
"You need an with your platform configuration. Download the
template and fill in your values:
"
iblai.envPLATFORMDOMAINTOKENiblai.envcurl -o iblai.env https://raw.githubusercontent.com/iblai/vibe/refs/heads/main/iblai.env继续操作前,请检查项目根目录是否存在 文件,确认文件中包含 、 和 变量。如果文件不存在或缺失这些变量,请告知用户:
"你需要一个包含平台配置的 文件。下载模板并填入你的配置值:
"
iblai.envPLATFORMDOMAINTOKENiblai.envcurl -o iblai.env https://raw.githubusercontent.com/iblai/vibe/refs/heads/main/iblai.envArchitecture
架构说明
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 to the store in :
workflowsApiSlicestore/iblai-store.tstypescript
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 添加 :
store/iblai-store.tsworkflowsApiSlicetypescript
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钩子都会静默返回 。
undefinedSDK 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, ororg-- they will fail.onCreate
重要提示: 下方的属性名是 SDK 实际支持的属性,旧版本的 skill 文档存在属性名错误的问题。请勿使用、isOpen、onClose或org,这些属性无法正常工作。onCreate
<WorkflowSidebar>
<WorkflowSidebar><WorkflowSidebar>
<WorkflowSidebar>Node type browser sidebar. Does NOT accept or .
orgtenant| Prop | Type | Description |
|---|---|---|
| | Called when user starts dragging a node type |
| | Called when user clicks a node type |
节点类型浏览器侧边栏,不支持 或 属性。
orgtenant| 属性 | 类型 | 描述 |
|---|---|---|
| | 用户开始拖拽节点类型时触发 |
| | 用户点击节点类型时触发 |
<ConnectorManagementDialog>
<ConnectorManagementDialog><ConnectorManagementDialog>
<ConnectorManagementDialog>| Prop | Type | Description |
|---|---|---|
| | Whether the dialog is visible |
| | Close callback |
Note: Uses (NOT ). Does NOT accept .
openisOpenorg| 属性 | 类型 | 描述 |
|---|---|---|
| | 弹窗是否可见 |
| | 关闭回调 |
注意:使用 属性(而非 ),不支持 属性。
openisOpenorg<CreateWorkflowModal>
<CreateWorkflowModal><CreateWorkflowModal>
<CreateWorkflowModal>| Prop | Type | Description |
|---|---|---|
| | Whether the modal is visible |
| | Open state setter |
| | Callback with workflow name |
| | Loading state |
WARNING: Does NOT use //. Those are wrong.
isOpenonCloseonCreate| 属性 | 类型 | 描述 |
|---|---|---|
| | 弹窗是否可见 |
| | 打开状态设置器 |
| | 携带工作流名称的创建回调 |
| | 加载状态 |
警告: 不要使用 //,这些属性是错误的。
isOpenonCloseonCreate<DeleteWorkflowModal>
<DeleteWorkflowModal><DeleteWorkflowModal>
<DeleteWorkflowModal>| Prop | Type | Description |
|---|---|---|
| | Whether the modal is visible |
| | Close callback |
| | Confirm deletion callback |
| | Loading state |
| | Name displayed in confirmation |
WARNING: Does NOT use or . Those are wrong.
workflowIdonDelete| 属性 | 类型 | 描述 |
|---|---|---|
| | 弹窗是否可见 |
| | 关闭回调 |
| | 确认删除回调 |
| | 加载状态 |
| | 确认弹窗中展示的工作流名称 |
警告: 不要使用 或 ,这些属性是错误的。
workflowIdonDeleteStep 4: Create the Types File
步骤4:创建类型文件
Create with the shared data model.
Node data is nested in (not flat on the node):
components/workflows/types.tsnode.datatypescript
// 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.tsnode.datatypescript
// 工作流画布类型 —— 与 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.tsxKey patterns:
- Uses
useGetWorkflowsQuery({ org: tenant, params: search ? { search } : undefined }) - Workflow response has array with
results,unique_id,name,is_active,descriptionupdated_at - Create: passes
{ name, definition: { nodes: DEFAULT_NODES, edges: DEFAULT_EDGES } } - Delete: passes
{ org: tenant, uniqueId: deleteTarget.id } - Navigates to after create
/workflows/${result.unique_id}
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.tsxKey patterns:
- Uses
useGetWorkflowQuery({ org: tenant, uniqueId: workflowId }) - Save: with
usePatchWorkflowMutation{ org, uniqueId, data: { definition: { nodes, edges } } } - Publish: save first, then , then
useValidateWorkflowMutationusePublishWorkflowMutation - Validation response:
{ errors: string[], warnings: string[] } - Cast (API types them as
workflow.definition?.nodes as CanvasNode[])unknown[]
app/(app)/workflows/[id]/page.tsx核心实现模式:
- 使用
useGetWorkflowQuery({ org: tenant, uniqueId: workflowId }) - 保存:传入
usePatchWorkflowMutation{ org, uniqueId, data: { definition: { nodes, edges } } } - 发布:先保存,然后调用 ,最后调用
useValidateWorkflowMutationusePublishWorkflowMutation - 验证响应结构:
{ errors: string[], warnings: string[] } - 将 强制转换为
workflow.definition?.nodes(API 会将其类型标注为CanvasNode[])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.tsxcomponents/workflows/workflow-canvas.tsxCritical implementation details
关键实现细节
-
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); }, []);Attachto every node div (Start, End, While, Conditional, User-Approval, Note, Mentor, and Default). Missing even one will cause edge misalignment for that node type.ref={nodeRef(node.id)} -
Handle position math: The handle dots are positioned with CSS (), so the edge endpoint must match:
left: -HANDLE_RADIUS - 2typescriptconst 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= -8px from node edge. The dot is 12px wide, so its center is at -8 + 6 = -2px from node edge. Thereforeleft: -(HANDLE_RADIUS + 2)makes the edge endpoint hit the center.HANDLE_OFFSET = 2 -
Drop handler: Read from, NOT from a ref:
e.dataTransfertypescriptconst 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'smay not fire beforeonDragStart.onDrop -
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" /> -
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
-
Middle-click panning: HandleBEFORE the
e.button === 1guard:e.button !== 0typescriptconst 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 checkfirst, TypeScript narrows the type toe.button !== 0and0becomes a type error.e.button === 1 -
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 };Usepixels to distinguish click from drag:DRAG_THRESHOLD = 3typescript// 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; }
-
节点尺寸测量: 节点会根据内容自动调整大小,因此你必须使用 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)} -
连接点位置计算: 连接点通过 CSS 定位(),因此连接线的端点必须与之匹配:
left: -HANDLE_RADIUS - 2typescriptconst 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 为= 距离节点边缘-8px,连接点宽度为12px,因此其中心位置距离节点边缘为 -8 + 6 = -2px,因此设置left: -(HANDLE_RADIUS + 2)可让连接线端点刚好命中连接点中心。HANDLE_OFFSET = 2 -
放置事件处理: 从读取数据,不要使用 ref:
e.dataTransfertypescriptconst 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 -
连接线渲染: 使用 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" /> -
节点类型渲染器: 每个节点类型需要对应的视觉样式:
- 开始: 播放图标,仅右侧有连接点
- 结束: 停止图标(红色),仅左侧有连接点
- Mentor: 机器人图标,编辑配置按钮,4个方向都有连接点
- 条件分支: 分支图标,动态条件行,每个条件对应右侧连接点 + 左侧连接点
- 循环: 虚线边框容器,循环图标,左右侧有连接点
- 用户审批: 点赞图标,审批/拒绝行对应右侧连接点 + 左侧连接点
- 注释: 琥珀色便签背景,无连接点
- 默认节点(转换、设置状态等): 机器人图标,类型标签,4个方向都有连接点
-
中键平移: 在判断之前先处理
e.button !== 0:e.button === 1typescriptconst 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; // 下方仅处理左键事件 // ... };如果你先判断,TypeScript 会将类型缩小为e.button !== 0,导致0出现类型错误。e.button === 1 -
点击与拖拽的区分: 配置面板必须在 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 = 3typescript// 在 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.tsxcomponents/workflows/node-config-panel.tsxCritical: Event propagation
关键:事件传播阻止
The config panel MUST stop event propagation on both AND
. Without this, clicks inside the panel bubble to the canvas,
which deselects the node and closes the panel:
onClickonMouseDowntypescript
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
and stopPropagation on their overlay:
onClickonMouseDowntsx
{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>
)}配置面板必须同时在 和 事件中阻止事件冒泡,否则面板内的点击会冒泡到画布,导致节点被取消选中、面板被关闭:
onClickonMouseDowntypescript
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>
);从面板中弹出的所有弹窗(例如“添加变量”弹窗)的遮罩层也需要添加 和 阻止事件冒泡:
onClickonMouseDowntsx
{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 ref to prevent sync loops. When the panel updates
nodeData via , the parent re-renders with new nodeData.
The sync effect should skip that update to avoid overwriting the user's
input cursor position:
isLocalUpdateonUpdateNodetypescript
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]);使用 ref 避免同步循环。当面板通过 更新 nodeData 时,父组件会使用新的 nodeData 重新渲染,同步 effect 应当跳过该次更新,避免覆盖用户的输入光标位置:
isLocalUpdateonUpdateNodetypescript
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 (or
if-else): Dynamic condition list with add/removeconditional - 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 before telling the user the work is ready:
/iblai-test- -- must pass with zero errors
pnpm build - Start dev server and verify the workflow pages render:
bash
pnpm dev & npx playwright screenshot http://localhost:3000/workflows /tmp/workflows.png
在告知用户工作完成前运行 :
/iblai-test- —— 必须零错误通过
pnpm build - 启动开发服务器,验证工作流页面正常渲染:
bash
pnpm dev & npx playwright screenshot http://localhost:3000/workflows /tmp/workflows.png
Common Pitfalls
常见问题
-
Wrong SDK prop names: The skill docs previously listed incorrect props.uses
CreateWorkflowModal/open/onOpenChange(NOTonCreateWorkflow/isOpen/onClose).onCreateusesConnectorManagementDialog(NOTopen). Neither takesisOpen.orgusesWorkflowSidebar/onDragStart(NOTonItemClick).org -
Edge line gap: If edges don't connect flush to handle dots, check thatequals the handle dot's center offset from the node edge (typically 2, not 8). Also ensure ALL node types have
HANDLE_OFFSETon their outermost div so dimensions are measured correctly.ref={nodeRef(node.id)} -
"Cannot read properties of null": When dropping from sidebar, readinstead of a ref. The ref may not be set.
e.dataTransfer.getData() -
TypeScriptnarrowing: Handle middle-click before the
e.buttonguard to avoid type narrowing issues.!== 0 -
Node data model: Data must be nested in(NodeConfig), not flat on the node. The API returns
node.data.{ id, type, position, data: {...} } -
workflowsApiSlice not in store: All workflow hooks returnif the slice isn't registered. Add both reducer and middleware.
undefined -
Config panel closes on click: Thediv MUST have both
panelShellandonClick={(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.onMouseDown={(e) => e.stopPropagation()} -
Dragging opens config panel: Config panel must open on mouseup (when), NOT on mousedown. Opening on mousedown means every drag also opens the panel.
!dragMovedRef.current -
Missing continueOnError in sync effect: If you addstate, you MUST also add
continueOnErrorto the sync effect. Otherwise undo/redo and node switching won't update the toggle state.setContinueOnError(nodeData.continueOnError ?? false)
-
SDK 属性名错误: 旧版 skill 文档列出了错误的属性。使用
CreateWorkflowModal/open/onOpenChange(而非onCreateWorkflow/isOpen/onClose);onCreate使用ConnectorManagementDialog(而非open),两者都不支持isOpen属性;org使用WorkflowSidebar/onDragStart(不支持onItemClick)。org -
连接线间隙: 如果连接线没有与连接点完美对齐,请检查是否等于连接点中心相对于节点边缘的偏移量(通常为2,不是8)。同时确保所有节点类型的最外层 div 都添加了
HANDLE_OFFSET,以便正确测量尺寸。ref={nodeRef(node.id)} -
“无法读取 null 的属性”: 从侧边栏拖拽放置时,请从读取数据,不要使用 ref,ref 可能还未完成赋值。
e.dataTransfer.getData() -
TypeScript类型缩小问题: 在
e.button判断之前先处理中键点击,避免类型缩小导致的错误。!== 0 -
节点数据模型: 数据必须嵌套在(NodeConfig)中,不要平铺在节点对象上。API 返回结构为
node.data。{ id, type, position, data: {...} } -
workflowsApiSlice 未注册到 store: 如果 slice 未注册,所有工作流钩子都会返回,请同时添加 reducer 和 middleware。
undefined -
点击配置面板时面板自动关闭:div 必须同时添加
panelShell和onClick={(e) => e.stopPropagation()},缺少任意一个都会导致点击事件冒泡到画布,取消节点选中并关闭面板。面板内弹出的所有弹窗也需要遵循该规则。onMouseDown={(e) => e.stopPropagation()} -
拖拽节点时打开配置面板: 配置面板必须在 mouseup 事件中打开(当时),不要在 mousedown 中打开,在 mousedown 中打开会导致每次拖拽都触发面板打开。
!dragMovedRef.current -
同步 effect 中缺少 continueOnError: 如果你添加了状态,必须在同步 effect 中添加
continueOnError,否则撤销/重做和节点切换操作不会更新开关状态。setContinueOnError(nodeData.continueOnError ?? false)
Important Notes
重要提示
- Import: -- framework-agnostic
@iblai/iblai-js/web-containers - Data hooks: -- RTK Query hooks
@iblai/iblai-js/data-layer - Redux store: Must include ,
mentorReducer, ANDmentorMiddlewareworkflowsApiSlice - : 5 args (v1.2+)
initializeDataLayer() - : Deduplicated via webpack aliases in
@reduxjs/toolkitnext.config.ts - Brand guidelines: BRAND.md
- 导入路径: —— 框架无关
@iblai/iblai-js/web-containers - 数据钩子: —— RTK Query 钩子
@iblai/iblai-js/data-layer - Redux store: 必须包含 、
mentorReducer和mentorMiddlewareworkflowsApiSlice - : 5个参数(v1.2+版本)
initializeDataLayer() - : 通过
@reduxjs/toolkit中的 webpack 别名实现去重next.config.ts - 品牌规范: BRAND.md