refactor workflow

This commit is contained in:
mlogclub
2026-07-27 13:47:53 +08:00
parent 1464008741
commit bd8011dab8
44 changed files with 2785 additions and 3504 deletions
+20
View File
@@ -7,6 +7,7 @@ const SchemaVersion = 2
type Definition struct {
SchemaVersion int `json:"schemaVersion"`
Nodes []Node `json:"nodes"`
Annotations []Node `json:"annotations,omitempty"`
Edges []Edge `json:"edges"`
}
@@ -187,3 +188,22 @@ func (d *NodeData) UnmarshalJSON(data []byte) error {
}
return nil
}
func (d NodeData) MarshalJSON() ([]byte, error) {
type alias NodeData
base, err := json.Marshal(alias(d))
if err != nil {
return nil, err
}
values := make(map[string]json.RawMessage)
if err := json.Unmarshal(base, &values); err != nil {
return nil, err
}
for key, value := range d.Extra {
if _, exists := values[key]; exists {
continue
}
values[key] = value
}
return json.Marshal(values)
}
+30
View File
@@ -2,6 +2,7 @@ package dsl_test
import (
"encoding/json"
"strings"
"testing"
"agent-desk/internal/ai/workflow/dsl"
@@ -81,3 +82,32 @@ func TestDefinitionUnmarshalsFlowGramStyleSchema(t *testing.T) {
t.Fatalf("unexpected edge: %#v", edge)
}
}
func TestDefinitionPreservesCanvasAnnotations(t *testing.T) {
var def dsl.Definition
err := json.Unmarshal([]byte(`{
"schemaVersion": 2,
"nodes": [],
"annotations": [{
"id": "comment_1",
"type": "comment",
"meta": {"position": {"x": 12, "y": 34}},
"data": {"note": "check this branch", "size": {"width": 240, "height": 150}}
}],
"edges": []
}`), &def)
if err != nil {
t.Fatalf("unmarshal definition: %v", err)
}
if len(def.Annotations) != 1 || def.Annotations[0].ID != "comment_1" {
t.Fatalf("expected annotation to be preserved, got %#v", def.Annotations)
}
encoded, err := json.Marshal(def)
if err != nil {
t.Fatalf("marshal definition: %v", err)
}
if !strings.Contains(string(encoded), `"annotations"`) ||
!strings.Contains(string(encoded), `"check this branch"`) {
t.Fatalf("expected annotation JSON to round trip, got %s", encoded)
}
}
@@ -2,7 +2,6 @@
import { useMemo, useState } from "react"
import { EditorRenderer, FreeLayoutEditorProvider } from "@flowgram.ai/free-layout-editor"
import { AlertTriangleIcon, CheckCircle2Icon, TimerIcon } from "lucide-react"
import { JsonTreeViewer } from "@/components/json-tree-viewer"
@@ -11,26 +10,21 @@ import { ScrollArea } from "@/components/ui/scroll-area"
import type { AIWorkflowNodeRun, AIWorkflowRun } from "@/lib/api/admin"
import { cn } from "@/lib/utils"
import { useFlowgramEditorProps } from "../../ai-workflows/_components/flowgram-editor-provider"
import { WorkflowReadonlyCanvas } from "../../ai-workflows/_components/editor/workflow-editor"
export function WorkflowRunAuditGraph({ run }: { run: AIWorkflowRun }) {
const nodeRuns = run.nodes ?? []
const nodeRuns = useMemo(() => run.nodes ?? [], [run.nodes])
const firstNodeId = nodeRuns[0]?.nodeId ?? run.definition?.nodes?.[0]?.id ?? ""
const [selectedNodeId, setSelectedNodeId] = useState(firstNodeId)
const selectedNodeRun = nodeRuns.find((item) => item.nodeId === selectedNodeId) ?? nodeRuns[0]
const executedNodeIds = useMemo(() => new Set(nodeRuns.map((item) => item.nodeId)), [nodeRuns])
const editorProps = useFlowgramEditorProps({
definition: run.definition ?? { schemaVersion: 2, nodes: [], edges: [] },
nodeSpecs: [],
readonly: true,
})
return (
<div className="grid min-h-[520px] grid-cols-[minmax(0,1fr)_320px] overflow-hidden border">
<div className="relative min-w-0">
<FreeLayoutEditorProvider {...editorProps}>
<EditorRenderer className="h-full w-full" />
</FreeLayoutEditorProvider>
<WorkflowReadonlyCanvas
definition={run.definition ?? { schemaVersion: 2, nodes: [], edges: [] }}
/>
<div className="pointer-events-none absolute left-3 top-3 flex flex-wrap gap-2">
<Badge variant="secondary" className="gap-1">
<CheckCircle2Icon className="size-3" />
@@ -0,0 +1,76 @@
"use client"
/* eslint-disable react-hooks/refs -- FlowGram useNodeRender exposes reactive render state through a ref-backed adapter. */
import { useLayoutEffect, useState } from "react"
import {
WorkflowPortRender,
type WorkflowNodeProps,
useNodeRender,
} from "@flowgram.ai/free-layout-editor"
import { usePanelManager } from "@flowgram.ai/panel-manager-plugin"
import { cn } from "@/lib/utils"
import { WorkflowEditorSurfaceProvider } from "./editor-context"
import { usePortClick } from "./use-port-click"
export const NODE_FORM_PANEL = "workflow-node-form"
export function BaseNode(props: WorkflowNodeProps) {
const render = useNodeRender(props.node)
const panelManager = usePanelManager()
const onPortClick = usePortClick()
const [dragging, setDragging] = useState(false)
useLayoutEffect(() => {
if (String(render.node.flowNodeType) !== "condition") return
const frame = window.requestAnimationFrame(() => {
render.node.ports.updateDynamicPorts()
})
return () => window.cancelAnimationFrame(frame)
}, [render.data, render.node])
return (
<WorkflowEditorSurfaceProvider surface="canvas">
<div
ref={render.nodeRef}
className={cn(
"relative flex w-[360px] flex-col rounded-lg border bg-white",
"border-[rgba(6,7,9,0.15)] shadow-[0_2px_6px_rgba(0,0,0,0.04),0_4px_12px_rgba(0,0,0,0.02)]",
render.selected && "border-[#4e40e5]"
)}
draggable={!render.readonly}
onDragStart={(event) => {
render.startDrag(event)
setDragging(true)
}}
onTouchStart={(event) => {
render.startDrag(event as unknown as React.MouseEvent)
setDragging(true)
}}
onMouseUp={() => setDragging(false)}
onClick={(event) => {
render.selectNode(event)
if (!render.readonly && !dragging) {
panelManager.open(NODE_FORM_PANEL, "docked-right", {
props: { nodeId: render.node.id },
})
}
}}
onFocus={render.onFocus}
onBlur={render.onBlur}
>
{render.form?.render()}
</div>
{render.ports.map((port) => (
<WorkflowPortRender
key={port.id}
entity={port}
onClick={render.readonly ? undefined : onPortClick}
/>
))}
</WorkflowEditorSurfaceProvider>
)
}
@@ -0,0 +1,89 @@
"use client"
/* eslint-disable react-hooks/refs -- FlowGram useNodeRender exposes reactive render state through a ref-backed adapter. */
import { useLayoutEffect } from "react"
import {
Field,
FlowNodeFormData,
Form,
type FormModelV2,
type WorkflowNodeProps,
useNodeRender,
} from "@flowgram.ai/free-layout-editor"
import { Trash2Icon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
type CommentSize = {
width?: number
height?: number
}
export function CommentNode(props: WorkflowNodeProps) {
const render = useNodeRender(props.node)
const formModel = render.node
.getData(FlowNodeFormData)
.getFormModel<FormModelV2>()
const size = (formModel?.getValueIn("size") ?? {}) as CommentSize
const width = Math.max(120, Number(size.width) || 240)
const height = Math.max(80, Number(size.height) || 150)
useLayoutEffect(() => {
render.node.transform.update({
size: { width, height },
})
}, [height, render.node, width])
return (
<div
ref={render.nodeRef}
className={cn(
"group relative rounded-lg border border-[#f5c451] bg-[#fff9dc] p-2 text-[#594a16] shadow-sm",
render.selected && "border-[#f59e0b] ring-1 ring-[#f59e0b]/25"
)}
style={{ width, height }}
onMouseDown={render.selectNode}
onFocus={render.onFocus}
onBlur={render.onBlur}
>
<div
className="absolute inset-x-0 top-0 h-7 cursor-move"
draggable={!render.readonly}
onDragStart={render.startDrag}
/>
{!render.readonly ? (
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label="删除注释"
className="absolute right-1 top-1 z-10 text-[#a46b00] opacity-0 hover:bg-[#f5c451]/20 group-hover:opacity-100"
onClick={(event) => {
event.stopPropagation()
render.deleteNode()
}}
>
<Trash2Icon />
</Button>
) : null}
<Form control={formModel?.formControl}>
<Field<string> name="note">
{({ field }) => (
<textarea
value={field.value ?? ""}
readOnly={render.readonly}
aria-label="注释内容"
placeholder="输入注释..."
className="relative mt-5 h-[calc(100%-1.25rem)] w-full resize-none border-0 bg-transparent p-0 text-sm leading-6 outline-none placeholder:text-[#9a8650]"
onMouseDown={(event) => event.stopPropagation()}
onChange={(event) => field.onChange(event.target.value)}
/>
)}
</Field>
</Form>
</div>
)
}
@@ -0,0 +1,53 @@
"use client"
import { useEffect } from "react"
import { WorkflowNodePanelService } from "@flowgram.ai/free-node-panel-plugin"
import {
useClientContext,
useService,
WorkflowDragService,
WorkflowSelectService,
type WorkflowNodeEntity,
type WorkflowNodeJSON,
} from "@flowgram.ai/free-layout-editor"
export function EditorCanvasEvents() {
const context = useClientContext()
const nodePanel = useService(WorkflowNodePanelService)
const selection = useService(WorkflowSelectService)
const dragService = useService(WorkflowDragService)
useEffect(() => {
const element = context.playground.node
const handleContextMenu = (event: MouseEvent) => {
if (context.playground.config.readonlyOrDisabled) return
const position = context.playground.config.getPosFromMouseEvent(event)
event.preventDefault()
event.stopPropagation()
void nodePanel.callNodePanel({
position,
onSelect: (result) => {
if (!result) return
const nodePosition = dragService.adjustSubNodePosition(
result.nodeType,
undefined,
position
)
const node: WorkflowNodeEntity =
context.document.createWorkflowNodeByType(
result.nodeType,
nodePosition,
result.nodeJSON ?? ({} as WorkflowNodeJSON)
)
selection.selectNode(node)
},
onClose: () => undefined,
})
}
element.addEventListener("contextmenu", handleContextMenu)
return () => element.removeEventListener("contextmenu", handleContextMenu)
}, [context, dragService, nodePanel, selection])
return null
}
@@ -0,0 +1,49 @@
"use client"
import { createContext, useContext, type ReactNode } from "react"
import type { WorkflowEditorContextValue } from "./types"
const WorkflowEditorContext = createContext<WorkflowEditorContextValue | null>(null)
const WorkflowEditorSurfaceContext = createContext<"canvas" | "sidebar">("canvas")
export function WorkflowEditorContextProvider({
value,
children,
}: {
value: WorkflowEditorContextValue
children: ReactNode
}) {
return (
<WorkflowEditorContext.Provider value={value}>
{children}
</WorkflowEditorContext.Provider>
)
}
export function WorkflowEditorSurfaceProvider({
surface,
children,
}: {
surface: "canvas" | "sidebar"
children: ReactNode
}) {
return (
<WorkflowEditorSurfaceContext.Provider value={surface}>
{children}
</WorkflowEditorSurfaceContext.Provider>
)
}
export function useWorkflowEditorContext() {
const value = useContext(WorkflowEditorContext)
if (!value) {
throw new Error("WorkflowEditorContext is unavailable")
}
return value
}
export function useWorkflowEditorSurface() {
return useContext(WorkflowEditorSurfaceContext)
}
@@ -0,0 +1,151 @@
"use client"
import { useMemo } from "react"
import { createFreeAutoLayoutPlugin } from "@flowgram.ai/free-auto-layout-plugin"
import { createDownloadPlugin } from "@flowgram.ai/export-plugin"
import {
type FreeLayoutPluginContext,
type FreeLayoutProps,
type WorkflowJSON,
} from "@flowgram.ai/free-layout-editor"
import { createFreeLinesPlugin } from "@flowgram.ai/free-lines-plugin"
import { createFreeNodePanelPlugin } from "@flowgram.ai/free-node-panel-plugin"
import { createFreeSnapPlugin } from "@flowgram.ai/free-snap-plugin"
import { createFreeStackPlugin } from "@flowgram.ai/free-stack-plugin"
import { createMinimapPlugin } from "@flowgram.ai/minimap-plugin"
import {
createPanelManagerPlugin,
type PanelFactory,
} from "@flowgram.ai/panel-manager-plugin"
import type {
AIWorkflowDefinition,
AIWorkflowNodeSpec,
} from "@/lib/api/admin"
import { BaseNode, NODE_FORM_PANEL } from "./base-node"
import { CommentNode } from "./comment-node"
import { LineAddButton } from "./line-add-button"
import { NodeFormPanel } from "./node-form-panel"
import { NodePanel } from "./node-panel"
import { buildNodeRegistries } from "./node-registry"
import { onDragLineEnd } from "./on-drag-line-end"
import {
prepareDefinitionForEditor,
serializeDefinition,
} from "./workflow-model"
export function useWorkflowEditorProps({
definition,
nodeSpecs,
readonly = false,
onDefinitionChange,
}: {
definition: AIWorkflowDefinition
nodeSpecs: AIWorkflowNodeSpec[]
readonly?: boolean
onDefinitionChange?: (definition: AIWorkflowDefinition) => void
}): FreeLayoutProps {
return useMemo(() => {
const panelFactories: PanelFactory<{ nodeId: string }>[] = readonly
? []
: [
{
key: NODE_FORM_PANEL,
defaultSize: 500,
minSize: 300,
maxSize: 800,
render: (props) => <NodeFormPanel {...props} />,
},
]
return {
background: true,
readonly,
twoWayConnection: true,
enableReadonlyNodeDragging: false,
playground: { preventGlobalGesture: true },
scroll: { disableScrollBar: true, enableScrollLimit: false },
initialData: prepareDefinitionForEditor(definition) as WorkflowJSON,
nodeRegistries: buildNodeRegistries(nodeSpecs),
getNodeDefaultRegistry: (type) => ({
type,
meta: { defaultExpanded: true },
}),
fromNodeJSON: (_node, json) => json,
toNodeJSON: (_node, json) => json,
materials: {
renderDefaultNode: BaseNode,
renderNodes: { comment: CommentNode },
},
nodeEngine: { enable: true },
variableEngine: { enable: true },
history: {
enable: !readonly,
enableChangeNode: !readonly,
},
lineColor: {
hidden: "transparent",
default: "#94a3b8",
drawing: "#2563eb",
hovered: "#2563eb",
selected: "#2563eb",
error: "#dc2626",
flowing: "#2563eb",
},
canAddLine: (_ctx, fromPort, toPort) => {
if (readonly || fromPort.node === toPort.node) return false
return !fromPort.node.lines.allInputNodes.includes(toPort.node)
},
canDeleteLine: () => !readonly,
canDeleteNode: (_ctx, node) =>
!readonly && !["start", "end"].includes(String(node.flowNodeType)),
onContentChange: (ctx) => {
if (readonly || ctx.document.disposed) return
onDefinitionChange?.(
serializeDefinition(ctx.document.toJSON() as AIWorkflowDefinition)
)
},
onDragLineEnd: readonly ? undefined : onDragLineEnd,
onAllLayersRendered: (ctx: FreeLayoutPluginContext) => {
window.requestAnimationFrame(() => ctx.tools.fitView(false))
},
plugins: () => [
createFreeStackPlugin({}),
createFreeLinesPlugin({
renderInsideLine: readonly ? undefined : LineAddButton,
}),
createMinimapPlugin({
disableLayer: true,
canvasStyle: {
canvasWidth: 176,
canvasHeight: 104,
canvasPadding: 32,
canvasBackground: "#f8fafc",
viewportBackground: "rgba(255,255,255,.7)",
viewportBorderColor: "#cbd5e1",
nodeColor: "#cbd5e1",
},
}),
createFreeSnapPlugin({
edgeColor: "#2563eb",
alignColor: "#2563eb",
}),
createFreeAutoLayoutPlugin({}),
createDownloadPlugin({
getFilename: (format) => `workflow.${format}`,
}),
...(readonly
? []
: [
createFreeNodePanelPlugin({ renderer: NodePanel }),
createPanelManagerPlugin({
factories: panelFactories,
autoResize: true,
}),
]),
],
}
}, [definition, nodeSpecs, onDefinitionChange, readonly])
}
@@ -0,0 +1,483 @@
"use client"
import { useEffect, useRef, useState } from "react"
import {
FlowDownloadFormat,
FlowDownloadService,
} from "@flowgram.ai/export-plugin"
import { WorkflowNodePanelService } from "@flowgram.ai/free-node-panel-plugin"
import {
type InteractiveType,
useClientContext,
usePlayground,
usePlaygroundTools,
useRefresh,
useService,
WorkflowDocument,
WorkflowDragService,
WorkflowLinesManager,
WorkflowSelectService,
type WorkflowNodeEntity,
type WorkflowNodeJSON,
} from "@flowgram.ai/free-layout-editor"
import { MinimapRender } from "@flowgram.ai/minimap-plugin"
import {
AlertTriangleIcon,
CheckIcon,
DownloadIcon,
FocusIcon,
GitBranchIcon,
HandIcon,
LayoutDashboardIcon,
LockIcon,
MousePointer2Icon,
MessageSquareTextIcon,
PlusIcon,
Redo2Icon,
Undo2Icon,
UnlockIcon,
} from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import type { AIWorkflowValidationResult } from "@/lib/api/admin"
const INTERACTIVE_TYPE_KEY = "workflow_prefer_interactive_type"
export function EditorTools({
onValidate,
onValidation,
}: {
onValidate: () => Promise<AIWorkflowValidationResult>
onValidation: (result: AIWorkflowValidationResult) => void
}) {
const tools = usePlaygroundTools({ maxZoom: 2, minZoom: 0.25 })
const playground = usePlayground()
const refresh = useRefresh()
const { history } = useClientContext()
const document = useService(WorkflowDocument)
const linesManager = useService(WorkflowLinesManager)
const selection = useService(WorkflowSelectService)
const dragService = useService(WorkflowDragService)
const nodePanel = useService(WorkflowNodePanelService)
const downloadService = useService(FlowDownloadService)
const addButtonRef = useRef<HTMLButtonElement>(null)
const [minimapVisible, setMinimapVisible] = useState(true)
const [validating, setValidating] = useState(false)
const [interactiveType, setInteractiveType] =
useState<InteractiveType>("PAD" as InteractiveType)
const [historyState, setHistoryState] = useState({
undo: history.canUndo(),
redo: history.canRedo(),
})
useEffect(() => {
const preferred = readPreferredInteractiveType()
setInteractiveType(preferred)
tools.setInteractiveType(preferred)
}, [tools])
useEffect(() => {
const disposable = history.undoRedoService.onChange(() =>
setHistoryState({ undo: history.canUndo(), redo: history.canRedo() })
)
return () => disposable.dispose()
}, [history])
useEffect(() => {
const disposable = playground.config.onReadonlyOrDisabledChange(refresh)
return () => disposable.dispose()
}, [playground, refresh])
async function addNode() {
const rect = addButtonRef.current?.getBoundingClientRect()
if (!rect) return
const position = playground.config.getPosFromMouseEvent({
clientX: rect.left + 64,
clientY: rect.top - 7,
})
await nodePanel.callNodePanel({
position,
enableMultiAdd: true,
onSelect: (result) => {
if (!result) return
const node: WorkflowNodeEntity = document.createWorkflowNodeByType(
result.nodeType,
undefined,
result.nodeJSON ?? ({} as WorkflowNodeJSON)
)
selection.selectNode(node)
},
onClose: () => undefined,
})
}
async function validate(purpose: "problem" | "test" = "problem") {
setValidating(true)
try {
const result = await onValidate()
onValidation(result)
if (purpose === "test" && result.valid) {
toast.success(
"预运行检查已通过;实际工作流会由已关联 Agent 的会话触发"
)
}
return result
} finally {
setValidating(false)
}
}
async function createComment(event: React.MouseEvent<HTMLButtonElement>) {
const position = playground.config.getPosFromMouseEvent(event)
const node = document.createWorkflowNodeByType(
"comment",
{ x: position.x, y: position.y - 75 },
{
id: `comment_${Date.now()}`,
type: "comment",
data: {
size: { width: 240, height: 150 },
note: "",
},
} as WorkflowNodeJSON
)
await new Promise<void>((resolve) =>
window.requestAnimationFrame(() => resolve())
)
selection.selectNode(node)
if (event.detail !== 0) {
dragService.startDragSelectedNodes(event)
}
}
async function download(format: FlowDownloadFormat) {
await downloadService.download({ format })
toast.success(`已导出 ${format.toUpperCase()}`)
}
const readonly = playground.config.readonly
return (
<div className="pointer-events-none absolute bottom-4 left-4 z-30 flex min-w-[360px] gap-2">
<div className="pointer-events-auto flex h-10 items-center gap-0.5 rounded-[10px] border border-[rgba(68,83,130,0.25)] bg-white px-1 shadow-[0_2px_6px_rgba(0,0,0,0.04),0_4px_12px_rgba(0,0,0,0.02)]">
<DropdownMenu>
<Tooltip>
<TooltipTrigger
render={
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon-sm" aria-label="交互模式" />
}
/>
}
>
{interactiveType === ("MOUSE" as InteractiveType) ? (
<MousePointer2Icon />
) : (
<HandIcon />
)}
</TooltipTrigger>
<TooltipContent>
{interactiveType === ("MOUSE" as InteractiveType)
? "鼠标友好模式"
: "触控板友好模式"}
</TooltipContent>
</Tooltip>
<DropdownMenuContent side="top" align="start" className="w-[420px] p-3">
<div className="mb-3 text-base font-semibold"></div>
<div className="grid grid-cols-2 gap-2">
<InteractionOption
selected={interactiveType === ("MOUSE" as InteractiveType)}
icon={<MousePointer2Icon />}
title="鼠标友好"
description="按住鼠标左键拖动画布,滚轮缩放。"
onClick={() =>
changeInteractiveType(
"MOUSE" as InteractiveType,
tools.setInteractiveType,
setInteractiveType
)
}
/>
<InteractionOption
selected={interactiveType === ("PAD" as InteractiveType)}
icon={<HandIcon />}
title="触控板友好"
description="双指移动画布,双指捏合缩放。"
onClick={() =>
changeInteractiveType(
"PAD" as InteractiveType,
tools.setInteractiveType,
setInteractiveType
)
}
/>
</div>
</DropdownMenuContent>
</DropdownMenu>
<ToolButton
label="自动布局"
disabled={readonly}
onClick={() =>
void tools.autoLayout({
enableAnimation: true,
animationDuration: 1000,
layoutConfig: { rankdir: "LR", nodesep: 100, ranksep: 100 },
})
}
>
<LayoutDashboardIcon />
</ToolButton>
<ToolButton label="切换线型" onClick={() => linesManager.switchLineType()}>
<GitBranchIcon />
</ToolButton>
<DropdownMenu>
<DropdownMenuTrigger
render={
<button
type="button"
className="w-[50px] rounded-lg border border-[rgba(68,83,130,0.25)] px-1 py-1 text-xs hover:bg-muted"
/>
}
>
{Math.floor(tools.zoom * 100)}%
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
<DropdownMenuItem onClick={() => tools.zoomin()}>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => tools.zoomout()}>
</DropdownMenuItem>
<DropdownMenuSeparator />
{[0.5, 1, 1.5, 2].map((zoom) => (
<DropdownMenuItem
key={zoom}
onClick={() => playground.config.updateZoom(zoom)}
>
{zoom * 100}%
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<ToolButton label="适应画布" onClick={() => tools.fitView()}>
<FocusIcon />
</ToolButton>
<ToolButton
label="缩略图"
active={minimapVisible}
onClick={() => setMinimapVisible((value) => !value)}
>
<LayoutDashboardIcon />
</ToolButton>
{minimapVisible ? (
<div className="absolute bottom-[60px] left-0 w-[198px] overflow-hidden rounded-lg border bg-white shadow-sm">
<MinimapRender
panelStyles={{}}
containerStyles={{
pointerEvents: "auto",
position: "relative",
inset: "unset",
}}
inactiveStyle={{
opacity: 1,
scale: 1,
translateX: 0,
translateY: 0,
}}
/>
</div>
) : null}
<ToolButton
label={readonly ? "切换为可编辑" : "切换为只读"}
onClick={() => {
playground.config.readonly = !playground.config.readonly
}}
>
{readonly ? <LockIcon /> : <UnlockIcon />}
</ToolButton>
<ToolButton
label="添加注释"
disabled={readonly}
onClick={(event) => void createComment(event)}
>
<MessageSquareTextIcon />
</ToolButton>
<ToolButton
label="撤销"
disabled={!historyState.undo || readonly}
onClick={() => void history.undo()}
>
<Undo2Icon />
</ToolButton>
<ToolButton
label="重做"
disabled={!historyState.redo || readonly}
onClick={() => void history.redo()}
>
<Redo2Icon />
</ToolButton>
<ToolButton
label="问题"
disabled={validating}
onClick={() => void validate("problem")}
>
<AlertTriangleIcon />
</ToolButton>
<DropdownMenu>
<Tooltip>
<TooltipTrigger
render={
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon-sm" aria-label="下载" />
}
/>
}
>
<DownloadIcon />
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
<DropdownMenuContent side="top" align="start">
{Object.values(FlowDownloadFormat).map((format) => (
<DropdownMenuItem
key={format}
disabled={readonly}
onClick={() => void download(format)}
>
{format.toUpperCase()}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<div className="mx-1 h-4 w-px bg-border" />
<Button
ref={addButtonRef}
size="sm"
className="h-8 rounded-lg border-0 bg-[rgba(171,181,255,0.3)] text-[#4e40e5] shadow-none hover:bg-[rgba(171,181,255,0.45)]"
disabled={readonly}
onClick={() => void addNode()}
>
<PlusIcon />
</Button>
<div className="mx-1 h-4 w-px bg-border" />
<Button
size="sm"
className="h-8 rounded-lg bg-[rgba(171,181,255,0.3)] text-[#4e40e5] shadow-none hover:bg-[rgba(171,181,255,0.45)]"
disabled={readonly || validating}
onClick={() => void validate("test")}
>
<CheckIcon />
</Button>
</div>
</div>
)
}
function InteractionOption({
selected,
icon,
title,
description,
onClick,
}: {
selected: boolean
icon: React.ReactNode
title: string
description: string
onClick: () => void
}) {
return (
<button
type="button"
className={`rounded-lg border p-3 text-left ${
selected ? "border-[#4e40e5] bg-[#f5f3ff]" : "hover:bg-muted/50"
}`}
onClick={onClick}
>
<span className={selected ? "text-[#4e40e5]" : "text-muted-foreground"}>
{icon}
</span>
<span className="mt-2 block text-sm font-semibold">{title}</span>
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
{description}
</span>
</button>
)
}
function ToolButton({
label,
disabled,
active,
onClick,
children,
}: {
label: string
disabled?: boolean
active?: boolean
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void
children: React.ReactNode
}) {
return (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className={active ? "bg-muted" : undefined}
aria-label={label}
disabled={disabled}
onClick={onClick}
/>
}
>
{children}
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
)
}
function readPreferredInteractiveType() {
const stored = window.localStorage.getItem(INTERACTIVE_TYPE_KEY)
if (stored === "MOUSE" || stored === "PAD") {
return stored as InteractiveType
}
return /Macintosh|MacIntel|MacPPC|Mac68K|iPad/.test(navigator.userAgent)
? ("PAD" as InteractiveType)
: ("MOUSE" as InteractiveType)
}
function changeInteractiveType(
value: InteractiveType,
update: (value: InteractiveType) => void,
setValue: (value: InteractiveType) => void
) {
window.localStorage.setItem(INTERACTIVE_TYPE_KEY, value)
update(value)
setValue(value)
}
@@ -0,0 +1,96 @@
"use client"
import { useCallback } from "react"
import {
WorkflowNodePanelService,
WorkflowNodePanelUtils,
} from "@flowgram.ai/free-node-panel-plugin"
import type { LineRenderProps } from "@flowgram.ai/free-lines-plugin"
import {
delay,
HistoryService,
useService,
WorkflowDocument,
WorkflowDragService,
WorkflowLinesManager,
type WorkflowNodeEntity,
type WorkflowNodeJSON,
} from "@flowgram.ai/free-layout-editor"
import { PlusIcon } from "lucide-react"
export function LineAddButton({
line,
selected,
hovered,
color,
}: LineRenderProps) {
const nodePanel = useService(WorkflowNodePanelService)
const document = useService(WorkflowDocument)
const dragService = useService(WorkflowDragService)
const linesManager = useService(WorkflowLinesManager)
const history = useService(HistoryService)
const { fromPort, toPort } = line
const addNode = useCallback(async () => {
if (!fromPort || !toPort) return
const position = {
x: (line.position.from.x + line.position.to.x) / 2,
y: (line.position.from.y + line.position.to.y) / 2,
}
const containerNode = fromPort.node.parent
const result = await nodePanel.singleSelectNodePanel({
position,
containerNode,
panelProps: { fromPort, enableScrollClose: true },
})
if (!result) return
const nodePosition = WorkflowNodePanelUtils.adjustNodePosition({
nodeType: result.nodeType,
position,
fromPort,
toPort,
containerNode,
document,
dragService,
})
const node: WorkflowNodeEntity = document.createWorkflowNodeByType(
result.nodeType,
nodePosition,
result.nodeJSON ?? ({} as WorkflowNodeJSON),
containerNode?.id
)
WorkflowNodePanelUtils.subNodesAutoOffset({
node,
fromPort,
toPort,
containerNode,
historyService: history,
dragService,
linesManager,
})
await delay(20)
WorkflowNodePanelUtils.buildLine({ fromPort, node, toPort, linesManager })
line.dispose()
}, [document, dragService, fromPort, history, line, linesManager, nodePanel, toPort])
if (!selected && !hovered) return null
return (
<button
type="button"
className="absolute z-10 flex size-6 items-center justify-center rounded-full border-2 bg-white shadow-sm hover:scale-110"
style={{
color,
borderColor: color,
transform: `translate(-50%, -50%) translate(${line.center.labelX}px, ${line.center.labelY}px)`,
}}
onClick={(event) => {
event.stopPropagation()
void addNode()
}}
aria-label="在线路中插入节点"
>
<PlusIcon className="size-3.5" />
</button>
)
}
@@ -0,0 +1,438 @@
"use client"
import { useEffect, useState } from "react"
import {
PlaygroundEntityContext,
type WorkflowNodeEntity,
useClientContext,
useNodeRender,
} from "@flowgram.ai/free-layout-editor"
import { usePanelManager } from "@flowgram.ai/panel-manager-plugin"
import { PlusIcon, Trash2Icon, XIcon } from "lucide-react"
import { OptionCombobox } from "@/components/option-combobox"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
fetchKnowledgeBasesAll,
type AIWorkflowDefinition,
type AIWorkflowNodeSpec,
type AIWorkflowValue,
type KnowledgeBase,
} from "@/lib/api/admin"
import { Status } from "@/lib/generated/enums"
import { NODE_FORM_PANEL } from "./base-node"
import {
WorkflowEditorSurfaceProvider,
useWorkflowEditorContext,
} from "./editor-context"
import { WorkflowNodeIcon } from "./node-icon"
import {
buildAvailableVariables,
nextBranchID,
normalizeConditionBranches,
parseRefKey,
refKey,
} from "./workflow-model"
import type { WorkflowConditionBranch } from "./types"
const operatorOptions = [
{ value: "eq", label: "等于" },
{ value: "neq", label: "不等于" },
{ value: "contains", label: "包含" },
{ value: "not_contains", label: "不包含" },
{ value: "gt", label: "大于" },
{ value: "gte", label: "大于等于" },
{ value: "lt", label: "小于" },
{ value: "lte", label: "小于等于" },
{ value: "exists", label: "存在" },
{ value: "empty", label: "为空" },
]
export function NodeFormPanel({ nodeId }: { nodeId: string }) {
const { document } = useClientContext()
const node = document.getNode(nodeId)
if (!node) return null
return (
<PlaygroundEntityContext.Provider value={node}>
<WorkflowEditorSurfaceProvider surface="sidebar">
<NodeForm node={node} />
</WorkflowEditorSurfaceProvider>
</PlaygroundEntityContext.Provider>
)
}
function NodeForm({ node }: { node: WorkflowNodeEntity }) {
const panelManager = usePanelManager()
const render = useNodeRender(node)
const { document } = useClientContext()
const { nodeSpecs } = useWorkflowEditorContext()
const spec = nodeSpecs.find((item) => item.type === String(node.flowNodeType))
const data = render.data ?? {}
const definition = document.toJSON() as AIWorkflowDefinition
const variables = buildAvailableVariables(definition, node.id, nodeSpecs)
const canDelete = !["start", "end"].includes(String(node.flowNodeType))
function updateData(next: Record<string, unknown>) {
render.updateData({ ...data, ...next })
}
return (
<div className="flex h-full min-h-0 flex-col bg-[#fbfbfb]">
<div className="flex h-[58px] shrink-0 items-center gap-3 border-b border-[rgba(82,100,154,0.13)] px-4">
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-[#f2f3ff] text-[#4e40e5]">
<WorkflowNodeIcon name={spec?.icon} className="size-4" />
</span>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-[#060709]">
{String(data.title || spec?.title || node.flowNodeType)}
</div>
</div>
<Button
variant="ghost"
size="icon-sm"
onClick={() => panelManager.close(NODE_FORM_PANEL)}
aria-label="关闭配置"
>
<XIcon className="size-4" />
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
<FormSection title="基本信息">
<FormField label="节点名称">
<Input
value={String(data.title ?? "")}
placeholder={spec?.title}
onChange={(event) => updateData({ title: event.target.value })}
/>
</FormField>
</FormSection>
<InputSection
spec={spec}
inputsValues={data.inputsValues ?? {}}
variables={variables}
onChange={(inputsValues) => updateData({ inputsValues })}
/>
{String(node.flowNodeType) === "knowledge_retrieve" ? (
<KnowledgeSection
config={asRecord(data.config)}
onChange={(config) => updateData({ config })}
/>
) : null}
{String(node.flowNodeType) === "condition" ? (
<ConditionSection
branches={normalizeConditionBranches({ data })}
variables={variables}
onChange={(branches) =>
updateData({
config: { ...asRecord(data.config), branches },
portKeys: branches.map((branch) => branch.id),
ports: branches.map((branch) => branch.id),
})
}
/>
) : null}
<OutputSection spec={spec} />
</div>
{canDelete ? (
<div className="shrink-0 border-t p-4">
<Button
variant="outline"
className="w-full text-destructive hover:text-destructive"
onClick={() => {
render.deleteNode()
panelManager.close(NODE_FORM_PANEL)
}}
>
<Trash2Icon className="size-4" />
</Button>
</div>
) : null}
</div>
)
}
function InputSection({
spec,
inputsValues,
variables,
onChange,
}: {
spec?: AIWorkflowNodeSpec
inputsValues: Record<string, AIWorkflowValue>
variables: ReturnType<typeof buildAvailableVariables>
onChange: (value: Record<string, AIWorkflowValue>) => void
}) {
if (!spec?.inputSchema?.length) return null
const options = variables.map((variable) => ({
value: `${variable.nodeId}.${variable.name}`,
label: variable.label || variable.name,
group: variable.nodeTitle,
subtitle: `${variable.nodeId}.${variable.name}`,
description: variable.description,
}))
return (
<FormSection title="输入">
{spec.inputSchema.map((input) => (
<FormField
key={input.name}
label={input.label || input.name}
required={input.required}
hint={input.description}
>
<OptionCombobox
value={refKey(inputsValues[input.name])}
options={options}
placeholder="选择上游变量"
searchPlaceholder="搜索变量"
preserveExternalSelection
onChange={(value) => {
const parsed = parseRefKey(value)
if (!parsed) return
onChange({ ...inputsValues, [input.name]: parsed })
}}
/>
</FormField>
))}
</FormSection>
)
}
function KnowledgeSection({
config,
onChange,
}: {
config: Record<string, unknown>
onChange: (value: Record<string, unknown>) => void
}) {
const [items, setItems] = useState<KnowledgeBase[]>([])
useEffect(() => {
let active = true
fetchKnowledgeBasesAll({ status: Status.Ok })
.then((result) => active && setItems(result ?? []))
.catch(() => active && setItems([]))
return () => {
active = false
}
}, [])
const values = normalizeIDs(config.knowledgeBaseIds).map(String)
return (
<FormSection title="知识库">
<FormField label="检索范围" required hint="可选择多个已启用知识库。">
<OptionCombobox
multiple
values={values}
options={items.map((item) => ({
value: String(item.id),
label: item.name,
}))}
placeholder="选择知识库"
searchPlaceholder="搜索知识库"
onValuesChange={(next) =>
onChange({
...config,
knowledgeBaseIds: next.map(Number).filter((id) => id > 0),
})
}
/>
</FormField>
</FormSection>
)
}
function ConditionSection({
branches,
variables,
onChange,
}: {
branches: WorkflowConditionBranch[]
variables: ReturnType<typeof buildAvailableVariables>
onChange: (branches: WorkflowConditionBranch[]) => void
}) {
const variableOptions = variables.map((variable) => ({
value: `${variable.nodeId}.${variable.name}`,
label: variable.label || variable.name,
group: variable.nodeTitle,
subtitle: `${variable.nodeId}.${variable.name}`,
}))
const fallback = branches.find((branch) => branch.default)
const regular = branches.filter((branch) => !branch.default)
function update(branch: WorkflowConditionBranch) {
onChange(branches.map((item) => (item.id === branch.id ? branch : item)))
}
return (
<FormSection
title="条件分支"
action={
<Button
variant="ghost"
size="sm"
onClick={() => {
const next: WorkflowConditionBranch = {
id: nextBranchID(branches),
name: `条件 ${regular.length + 1}`,
targetNodeId: "",
condition: { operator: "eq" },
}
onChange([...regular, next, fallback].filter(Boolean) as WorkflowConditionBranch[])
}}
>
<PlusIcon className="size-4" />
</Button>
}
>
{[...regular, ...(fallback ? [fallback] : [])].map((branch) => (
<div key={branch.id} className="rounded-md bg-slate-50 p-3">
<div className="flex items-center gap-2">
<Input
value={branch.name ?? ""}
onChange={(event) => update({ ...branch, name: event.target.value })}
/>
{!branch.default ? (
<Button
variant="ghost"
size="icon-sm"
className="shrink-0 text-slate-500 hover:text-destructive"
onClick={() => onChange(branches.filter((item) => item.id !== branch.id))}
>
<Trash2Icon className="size-4" />
</Button>
) : null}
</div>
{branch.default ? (
<p className="mt-2 text-xs text-slate-500"></p>
) : (
<div className="mt-3 grid gap-2">
<OptionCombobox
value={refKey(branch.condition?.left)}
options={variableOptions}
placeholder="选择变量"
preserveExternalSelection
onChange={(value) =>
update({
...branch,
condition: {
...branch.condition,
left: parseRefKey(value),
},
})
}
/>
<OptionCombobox
value={branch.condition?.operator ?? "eq"}
options={operatorOptions}
placeholder="选择运算符"
onChange={(operator) =>
update({
...branch,
condition: { ...branch.condition, operator },
})
}
/>
{!["exists", "empty"].includes(branch.condition?.operator ?? "") ? (
<Input
value={String(branch.condition?.right ?? "")}
placeholder="比较值"
onChange={(event) =>
update({
...branch,
condition: {
...branch.condition,
right: event.target.value,
},
})
}
/>
) : null}
</div>
)}
</div>
))}
</FormSection>
)
}
function OutputSection({ spec }: { spec?: AIWorkflowNodeSpec }) {
if (!spec?.outputSchema?.length) return null
return (
<FormSection title="输出">
<div className="divide-y rounded-md border">
{spec.outputSchema.map((output) => (
<div key={output.name} className="px-3 py-2.5">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="font-medium">{output.label || output.name}</span>
<span className="font-mono text-xs text-slate-500">{output.type}</span>
</div>
{output.description ? (
<p className="mt-1 text-xs leading-5 text-slate-500">{output.description}</p>
) : null}
</div>
))}
</div>
</FormSection>
)
}
function FormSection({
title,
action,
children,
}: {
title: string
action?: React.ReactNode
children: React.ReactNode
}) {
return (
<section className="border-b px-5 py-5 last:border-b-0">
<div className="mb-4 flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-slate-900">{title}</h3>
{action}
</div>
<div className="space-y-4">{children}</div>
</section>
)
}
function FormField({
label,
required,
hint,
children,
}: {
label: string
required?: boolean
hint?: string
children: React.ReactNode
}) {
return (
<div className="space-y-2">
<Label>
{label}
{required ? <span className="ml-1 text-destructive">*</span> : null}
</Label>
{children}
{hint ? <p className="text-xs leading-5 text-slate-500">{hint}</p> : null}
</div>
)
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
function normalizeIDs(value: unknown) {
if (!Array.isArray(value)) return []
return Array.from(
new Set(value.map(Number).filter((item) => Number.isInteger(item) && item > 0))
)
}
@@ -0,0 +1,46 @@
import {
BookOpenIcon,
BotIcon,
CircleHelpIcon,
ClipboardListIcon,
FlagIcon,
GitBranchIcon,
HeadphonesIcon,
MessageCircleIcon,
PlayCircleIcon,
SearchIcon,
SendIcon,
ShieldCheckIcon,
TicketIcon,
UserCheckIcon,
type LucideIcon,
} from "lucide-react"
const icons: Record<string, LucideIcon> = {
PlayCircleIcon,
MessageCircleIcon,
ShieldCheckIcon,
BookOpenIcon,
HelpCircleIcon: CircleHelpIcon,
BotIcon,
GitBranchIcon,
SearchIcon,
ClipboardListIcon,
UserCheckIcon,
TicketIcon,
HeadphonesIcon,
SendIcon,
FlagIcon,
}
export function WorkflowNodeIcon({
name,
className,
}: {
name?: string
className?: string
}) {
const Icon = icons[name ?? ""] ?? GitBranchIcon
return <Icon className={className} />
}
@@ -0,0 +1,66 @@
"use client"
import type {
NodePanelRenderProps,
NodePanelResult,
} from "@flowgram.ai/free-node-panel-plugin"
import { useClientContext } from "@flowgram.ai/free-layout-editor"
import { useWorkflowEditorContext } from "./editor-context"
import { WorkflowNodeIcon } from "./node-icon"
import { createNodeJSON } from "./workflow-model"
export function NodePanel({
position,
onSelect,
onClose,
}: NodePanelRenderProps) {
const { nodeSpecs } = useWorkflowEditorContext()
const { document } = useClientContext()
const visibleSpecs = nodeSpecs.filter((spec) => spec.type !== "start")
function select(spec: (typeof nodeSpecs)[number], event: React.MouseEvent) {
onSelect({
nodeType: spec.type,
nodeJSON: createNodeJSON(
spec,
document.getAllNodes().map((node) => node.id)
),
selectEvent: event,
} satisfies Exclude<NodePanelResult, undefined>)
}
return (
<>
<button
type="button"
className="fixed inset-0 z-40 cursor-default"
onClick={onClose}
aria-label="关闭节点面板"
/>
<div
className="absolute z-50 w-[180px] overflow-hidden rounded-lg border border-[rgba(68,83,130,0.25)] bg-white p-2 shadow-[0_4px_12px_rgba(0,0,0,0.02),0_2px_6px_rgba(0,0,0,0.04)]"
style={{
left: position.x + 30,
top: position.y,
transform: "translateY(-100%)",
}}
>
<div className="max-h-[500px] overflow-y-auto [scrollbar-width:none]">
{visibleSpecs.map((spec) => (
<button
key={spec.type}
type="button"
data-testid={`demo-free-node-list-${spec.type}`}
className="flex h-8 w-full items-center rounded-[5px] px-[15px] text-left hover:bg-[hsla(252,62%,55%,0.09)] hover:text-[hsl(252,62%,55%)]"
onClick={(event) => select(spec, event)}
>
<WorkflowNodeIcon name={spec.icon} className="size-3.5 shrink-0" />
<span className="ml-2.5 truncate text-xs">{spec.title}</span>
</button>
))}
</div>
</div>
</>
)
}
@@ -0,0 +1,114 @@
"use client"
import {
Field,
type WorkflowNodeRegistry,
} from "@flowgram.ai/free-layout-editor"
import type { AIWorkflowNodeSpec } from "@/lib/api/admin"
import { WorkflowNodeIcon } from "./node-icon"
import { normalizeConditionBranches } from "./workflow-model"
export function buildNodeRegistries(
nodeSpecs: AIWorkflowNodeSpec[]
): WorkflowNodeRegistry[] {
return [
...nodeSpecs.map((spec) => ({
type: spec.type,
meta: {
defaultExpanded: true,
isStart: spec.type === "start",
deleteDisable: spec.type === "start" || spec.type === "end",
copyDisable: spec.type === "start" || spec.type === "end",
nodePanelVisible: spec.type !== "start",
useDynamicPort: spec.type === "condition",
defaultPorts: getDefaultPorts(spec.type),
},
formMeta: {
render: () => <CanvasNodeContent spec={spec} />,
},
})),
{
type: "comment",
meta: {
sidebarDisabled: true,
nodePanelVisible: false,
defaultPorts: [],
renderKey: "comment",
size: { width: 240, height: 150 },
},
formMeta: {
render: () => <></>,
},
getInputPoints: () => [],
getOutputPoints: () => [],
},
]
}
function CanvasNodeContent({ spec }: { spec: AIWorkflowNodeSpec }) {
return (
<Field<string> name="title">
{({ field }) => (
<div className="w-[360px] select-none">
<div className="flex items-center gap-2 border-b border-[rgba(82,100,154,0.13)] px-4 py-3">
<span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-[#f2f3ff] text-[#4e40e5]">
<WorkflowNodeIcon name={spec.icon} className="size-3.5" />
</span>
<div className="min-w-0 flex-1 truncate text-sm font-semibold text-[#060709]">
{field.value || spec.title}
</div>
</div>
<div className="px-4 py-3">
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<div className="line-clamp-2 text-xs leading-5 text-[rgba(6,7,9,0.5)]">
{spec.description}
</div>
</div>
</div>
{spec.type === "condition" ? (
<Field<Record<string, unknown>> name="config">
{({ field: configField }) => {
const branches = normalizeConditionBranches({
data: { config: configField.value },
})
return (
<div className="mt-3 space-y-1.5 border-t border-[rgba(82,100,154,0.13)] pt-2.5">
{branches.map((branch, index) => (
<div
key={branch.id}
className="relative flex items-center gap-2 rounded-md bg-[#f7f7fa] px-2 py-1.5 text-xs"
>
<span className="w-8 shrink-0 font-medium uppercase text-[#4e40e5]">
{branch.default ? "else" : index === 0 ? "if" : "elif"}
</span>
<span className="min-w-0 flex-1 truncate text-[rgba(6,7,9,0.65)]">
{branch.name || branch.id}
</span>
<span
data-port-id={branch.id}
data-port-type="output"
className="absolute -right-4 top-1/2 size-0"
/>
</div>
))}
</div>
)
}}
</Field>
) : null}
</div>
</div>
)}
</Field>
)
}
function getDefaultPorts(type: string) {
if (type === "start") return [{ type: "output" as const }]
if (type === "end") return [{ type: "input" as const }]
if (type === "condition") return [{ type: "input" as const }]
return [{ type: "input" as const }, { type: "output" as const }]
}
@@ -0,0 +1,57 @@
import {
WorkflowNodePanelService,
WorkflowNodePanelUtils,
} from "@flowgram.ai/free-node-panel-plugin"
import {
delay,
type FreeLayoutPluginContext,
type onDragLineEndParams,
WorkflowDragService,
WorkflowLinesManager,
type WorkflowNodeEntity,
type WorkflowNodeJSON,
} from "@flowgram.ai/free-layout-editor"
export async function onDragLineEnd(
context: FreeLayoutPluginContext,
params: onDragLineEndParams
) {
const { fromPort, toPort, mousePos, line, originLine } = params
if (originLine || !line || toPort || !fromPort) return
const nodePanel = context.get(WorkflowNodePanelService)
const dragService = context.get(WorkflowDragService)
const linesManager = context.get(WorkflowLinesManager)
const containerNode = fromPort.node.parent
const result = await nodePanel.singleSelectNodePanel({
position:
fromPort.location === "bottom"
? { x: mousePos.x - 165, y: mousePos.y + 60 }
: mousePos,
containerNode,
panelProps: {
enableNodePlaceholder: true,
enableScrollClose: true,
fromPort,
},
})
if (!result) return
const position = WorkflowNodePanelUtils.adjustNodePosition({
nodeType: result.nodeType,
position: mousePos,
fromPort,
toPort,
containerNode,
document: context.document,
dragService,
})
const node: WorkflowNodeEntity = context.document.createWorkflowNodeByType(
result.nodeType,
position,
result.nodeJSON ?? ({} as WorkflowNodeJSON),
containerNode?.id
)
await delay(20)
WorkflowNodePanelUtils.buildLine({ fromPort, node, linesManager })
}
@@ -0,0 +1,34 @@
import type {
AIWorkflowDefinition,
AIWorkflowNodeSpec,
AIWorkflowValue,
AIWorkflowVariableSpec,
} from "@/lib/api/admin"
export type WorkflowNode = AIWorkflowDefinition["nodes"][number]
export type WorkflowEdge = AIWorkflowDefinition["edges"][number]
export type WorkflowCondition = {
left?: AIWorkflowValue
operator?: string
right?: unknown
}
export type WorkflowConditionBranch = {
id: string
name?: string
targetNodeId: string
condition?: WorkflowCondition
default?: boolean
}
export type WorkflowVariable = AIWorkflowVariableSpec & {
nodeId: string
nodeTitle: string
}
export type WorkflowEditorContextValue = {
nodeSpecs: AIWorkflowNodeSpec[]
readonly: boolean
}
@@ -0,0 +1,69 @@
"use client"
import { useCallback } from "react"
import {
WorkflowNodePanelService,
WorkflowNodePanelUtils,
} from "@flowgram.ai/free-node-panel-plugin"
import {
delay,
usePlayground,
useService,
WorkflowDocument,
WorkflowDragService,
WorkflowLinesManager,
type WorkflowNodeEntity,
type WorkflowNodeJSON,
type WorkflowPortEntity,
} from "@flowgram.ai/free-layout-editor"
export function usePortClick() {
const playground = usePlayground()
const nodePanel = useService(WorkflowNodePanelService)
const document = useService(WorkflowDocument)
const dragService = useService(WorkflowDragService)
const linesManager = useService(WorkflowLinesManager)
return useCallback(
async (event: React.MouseEvent, port: WorkflowPortEntity) => {
if (port.portType === "input") return
const mousePosition = playground.config.getPosFromMouseEvent(event)
const containerNode = port.node.parent
const result = await nodePanel.singleSelectNodePanel({
position: mousePosition,
containerNode,
panelProps: {
enableScrollClose: true,
fromPort: port,
},
})
if (!result) return
const nodePosition = WorkflowNodePanelUtils.adjustNodePosition({
nodeType: result.nodeType,
position:
port.location === "bottom"
? { x: mousePosition.x, y: mousePosition.y + 100 }
: { x: mousePosition.x + 100, y: mousePosition.y },
fromPort: port,
containerNode,
document,
dragService,
})
const node: WorkflowNodeEntity = document.createWorkflowNodeByType(
result.nodeType,
nodePosition,
result.nodeJSON ?? ({} as WorkflowNodeJSON),
containerNode?.id
)
await delay(20)
WorkflowNodePanelUtils.buildLine({
fromPort: port,
node,
linesManager,
})
},
[document, dragService, linesManager, nodePanel, playground]
)
}
@@ -0,0 +1,155 @@
"use client"
import { useState } from "react"
import {
EditorRenderer,
FreeLayoutEditorProvider,
} from "@flowgram.ai/free-layout-editor"
import { DockedPanelLayer } from "@flowgram.ai/panel-manager-plugin"
import { AlertCircleIcon, CheckCircle2Icon, XIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import type {
AIWorkflowDefinition,
AIWorkflowNodeSpec,
AIWorkflowValidationResult,
} from "@/lib/api/admin"
import { EditorTools } from "./editor-tools"
import { EditorCanvasEvents } from "./editor-canvas-events"
import { WorkflowEditorContextProvider } from "./editor-context"
import { useWorkflowEditorProps } from "./editor-provider"
export function WorkflowEditor({
definition,
nodeSpecs,
onDefinitionChange,
onValidate,
}: {
definition: AIWorkflowDefinition
nodeSpecs: AIWorkflowNodeSpec[]
onDefinitionChange: (definition: AIWorkflowDefinition) => void
onValidate: () => Promise<AIWorkflowValidationResult>
}) {
const [validation, setValidation] =
useState<AIWorkflowValidationResult | null>(null)
const props = useWorkflowEditorProps({
definition,
nodeSpecs,
onDefinitionChange,
})
return (
<WorkflowEditorContextProvider value={{ nodeSpecs, readonly: false }}>
<div className="relative h-full min-h-[560px] overflow-hidden bg-slate-50">
<FreeLayoutEditorProvider {...props}>
<DockedPanelLayer>
<EditorRenderer className="h-full w-full" />
<EditorCanvasEvents />
<EditorTools
onValidate={onValidate}
onValidation={setValidation}
/>
</DockedPanelLayer>
{validation ? (
<ProblemPanel
validation={validation}
onClose={() => setValidation(null)}
/>
) : null}
</FreeLayoutEditorProvider>
</div>
</WorkflowEditorContextProvider>
)
}
function ProblemPanel({
validation,
onClose,
}: {
validation: AIWorkflowValidationResult
onClose: () => void
}) {
return (
<div className="absolute inset-x-0 bottom-0 z-40 h-[210px] border-t border-[rgba(82,100,154,0.13)] bg-[#fbfbfb] shadow-[0_-4px_12px_rgba(0,0,0,0.04)]">
<div className="flex h-[50px] items-center justify-between px-3">
<div className="flex items-center gap-2 text-sm font-semibold">
{validation.valid ? (
<CheckCircle2Icon className="size-4 text-emerald-600" />
) : (
<span className="rounded-full bg-destructive px-1.5 py-0.5 text-[10px] leading-none text-white">
{validation.errors.length}
</span>
)}
</div>
<Button variant="ghost" size="icon-sm" onClick={onClose}>
<XIcon />
</Button>
</div>
<div className="h-[160px] overflow-y-auto px-3 pb-3">
{validation.valid ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
</div>
) : (
<div className="space-y-1">
{validation.errors.map((error, index) => (
<div
key={`${error.field}-${index}`}
className="flex items-start gap-2 rounded border border-border px-3 py-2 text-sm"
>
<AlertCircleIcon className="mt-0.5 size-4 shrink-0 text-destructive" />
<div>
<div className="font-mono text-xs text-muted-foreground">
{error.field}
</div>
<div className="mt-0.5">{error.message}</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}
export function WorkflowReadonlyCanvas({
definition,
nodeSpecs = [],
}: {
definition: AIWorkflowDefinition
nodeSpecs?: AIWorkflowNodeSpec[]
}) {
const resolvedSpecs =
nodeSpecs.length > 0
? nodeSpecs
: Array.from(new Set(definition.nodes.map((node) => node.type))).map(
(type) => ({
type,
title:
String(
definition.nodes.find((node) => node.type === type)?.data?.title
) || type,
description: "工作流节点",
icon: type === "start" ? "PlayCircleIcon" : type === "end" ? "FlagIcon" : "GitBranchIcon",
riskLevel: "low" as const,
interruptible: false,
requiresConfirmationPredecessor: false,
})
)
const props = useWorkflowEditorProps({
definition,
nodeSpecs: resolvedSpecs,
readonly: true,
})
return (
<WorkflowEditorContextProvider value={{ nodeSpecs: resolvedSpecs, readonly: true }}>
<FreeLayoutEditorProvider {...props}>
<EditorRenderer className="h-full w-full" />
</FreeLayoutEditorProvider>
</WorkflowEditorContextProvider>
)
}
@@ -0,0 +1,273 @@
import type {
AIWorkflowDefinition,
AIWorkflowNodeSpec,
AIWorkflowValue,
} from "@/lib/api/admin"
import type {
WorkflowConditionBranch,
WorkflowNode,
WorkflowVariable,
} from "./types"
const defaultBranch: WorkflowConditionBranch = {
id: "default",
name: "默认分支",
targetNodeId: "",
default: true,
}
export function prepareDefinitionForEditor(
definition: AIWorkflowDefinition
): AIWorkflowDefinition {
const branchIDsByNode = new Map<string, Set<string>>()
const executableNodes = (definition.nodes ?? []).map((node) => {
if (node.type !== "condition") {
return node
}
const branches = normalizeConditionBranches(node)
branchIDsByNode.set(node.id, new Set(branches.map((branch) => branch.id)))
return {
...node,
data: {
...node.data,
config: {
...asRecord(node.data?.config),
branches,
},
portKeys: branches.map((branch) => branch.id),
ports: branches.map((branch) => branch.id),
},
}
})
const nodes = normalizeEditorPositions([
...executableNodes,
...(definition.annotations ?? []),
])
return {
schemaVersion: definition.schemaVersion || 2,
nodes,
annotations: undefined,
edges: (definition.edges ?? [])
.filter((edge) => {
if (!edge.sourcePortID) return true
const branchIDs = branchIDsByNode.get(edge.sourceNodeID)
return !branchIDs || branchIDs.has(edge.sourcePortID)
})
.map((edge) => {
if (edge.sourcePortID) return edge
const source = nodes.find((node) => node.id === edge.sourceNodeID)
if (source?.type !== "condition") return edge
const branch = normalizeConditionBranches(source).find(
(item) => item.targetNodeId === edge.targetNodeID
)
return branch ? { ...edge, sourcePortID: branch.id } : edge
}),
}
}
export function serializeDefinition(
definition: AIWorkflowDefinition
): AIWorkflowDefinition {
const edges = definition.edges ?? []
const annotations = (definition.nodes ?? []).filter(
(node) => node.type === "comment"
)
return {
schemaVersion: definition.schemaVersion || 2,
nodes: (definition.nodes ?? []).filter((node) => node.type !== "comment").map((node) => {
const data = { ...node.data }
delete data.portKeys
delete data.ports
if (node.type === "condition") {
const branches = normalizeConditionBranches(node).map((branch) => {
const edge = edges.find(
(item) =>
item.sourceNodeID === node.id &&
item.sourcePortID === branch.id
)
return {
...branch,
targetNodeId: edge?.targetNodeID ?? branch.targetNodeId ?? "",
}
})
data.config = { ...asRecord(data.config), branches }
}
return { ...node, data }
}),
annotations,
edges,
}
}
export function createNodeJSON(
spec: AIWorkflowNodeSpec,
existingNodeIDs: string[] = []
): WorkflowNode {
const id = uniqueNodeID(spec.type, existingNodeIDs)
const config =
spec.type === "condition" ? { branches: [defaultBranch] } : {}
return {
id,
type: spec.type,
meta: { position: { x: 0, y: 0 } },
data: {
title: spec.title || spec.type,
config,
inputsValues: spec.defaultInputs ?? {},
...(spec.type === "condition"
? { portKeys: [defaultBranch.id], ports: [defaultBranch.id] }
: {}),
},
}
}
export function normalizeConditionBranches(
node: Pick<WorkflowNode, "data">
): WorkflowConditionBranch[] {
const config = asRecord(node.data?.config)
const values = Array.isArray(config.branches) ? config.branches : []
const branches = values
.map(normalizeBranch)
.filter((item): item is WorkflowConditionBranch => Boolean(item))
const nonDefault = branches.filter((branch) => !branch.default)
const fallback =
branches.find((branch) => branch.default) ?? defaultBranch
return [...nonDefault, fallback]
}
export function nextBranchID(branches: WorkflowConditionBranch[]) {
const used = new Set(branches.map((branch) => branch.id))
for (let index = 1; index < 10000; index += 1) {
const id = `branch_${index}`
if (!used.has(id)) return id
}
return `branch_${Date.now()}`
}
export function buildAvailableVariables(
definition: AIWorkflowDefinition,
nodeID: string,
specs: AIWorkflowNodeSpec[]
): WorkflowVariable[] {
const ancestors = collectAncestorIDs(definition, nodeID)
const specByType = new Map(specs.map((spec) => [spec.type, spec]))
return ancestors.flatMap((ancestorID) => {
const node = definition.nodes.find((item) => item.id === ancestorID)
if (!node) return []
const spec = specByType.get(node.type)
return (spec?.outputSchema ?? []).map((output) => ({
...output,
nodeId: node.id,
nodeTitle: String(node.data?.title || spec?.title || node.type),
}))
})
}
export function refValue(nodeID: string, field: string): AIWorkflowValue {
return { type: "ref", content: [nodeID, field] }
}
export function refKey(value: AIWorkflowValue | undefined) {
if (value?.type !== "ref" || !Array.isArray(value.content)) return ""
return `${value.content[0]}.${value.content[1]}`
}
export function parseRefKey(value: string): AIWorkflowValue | undefined {
const separator = value.indexOf(".")
if (separator <= 0 || separator === value.length - 1) return undefined
return refValue(value.slice(0, separator), value.slice(separator + 1))
}
function uniqueNodeID(type: string, existingNodeIDs: string[]) {
const normalized = type.replace(/[^a-zA-Z0-9_]/g, "_") || "node"
const used = new Set(existingNodeIDs)
for (let index = 1; index < 10000; index += 1) {
const id = `${normalized}_${index}`
if (!used.has(id)) return id
}
return `${normalized}_${Date.now()}`
}
function normalizeEditorPositions(
nodes: AIWorkflowDefinition["nodes"]
): AIWorkflowDefinition["nodes"] {
const executableXs = Array.from(
new Set(
nodes
.filter((node) => node.type !== "comment")
.map((node) => node.meta.position.x)
)
).sort((left, right) => left - right)
const positiveGaps = executableXs
.slice(1)
.map((x, index) => x - executableXs[index])
.filter((gap) => gap > 0)
const minimumGap = positiveGaps.length ? Math.min(...positiveGaps) : 0
if (!minimumGap || minimumGap >= 460) return nodes
const origin = executableXs[0]
const scale = 460 / minimumGap
return nodes.map((node) =>
node.type === "comment"
? node
: {
...node,
meta: {
...node.meta,
position: {
...node.meta.position,
x: origin + (node.meta.position.x - origin) * scale,
},
},
}
)
}
function normalizeBranch(value: unknown): WorkflowConditionBranch | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null
const item = value as Record<string, unknown>
const id = String(item.id ?? "").trim()
if (!id) return null
return {
id,
name: String(item.name ?? "").trim(),
targetNodeId: String(item.targetNodeId ?? "").trim(),
default: Boolean(item.default),
condition:
item.condition && typeof item.condition === "object"
? (item.condition as WorkflowConditionBranch["condition"])
: undefined,
}
}
function collectAncestorIDs(
definition: AIWorkflowDefinition,
nodeID: string
) {
const incoming = new Map<string, string[]>()
for (const edge of definition.edges ?? []) {
incoming.set(edge.targetNodeID, [
...(incoming.get(edge.targetNodeID) ?? []),
edge.sourceNodeID,
])
}
const queue = [...(incoming.get(nodeID) ?? [])]
const visited = new Set<string>()
while (queue.length) {
const current = queue.shift()
if (!current || visited.has(current)) continue
visited.add(current)
queue.push(...(incoming.get(current) ?? []))
}
return definition.nodes
.map((node) => node.id)
.filter((id) => visited.has(id))
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
@@ -1,134 +0,0 @@
"use client"
import { useMemo } from "react"
import {
type FreeLayoutPluginContext,
type FreeLayoutProps,
type WorkflowNodeEntity,
type WorkflowJSON,
} from "@flowgram.ai/free-layout-editor"
import { createFreeLinesPlugin } from "@flowgram.ai/free-lines-plugin"
import { createFreeSnapPlugin } from "@flowgram.ai/free-snap-plugin"
import { createMinimapPlugin } from "@flowgram.ai/minimap-plugin"
import type { AIWorkflowDefinition, AIWorkflowNodeSpec } from "@/lib/api/admin"
import { FlowgramNodeRenderer } from "./flowgram-node-renderer"
import { buildFlowgramNodeRegistries } from "./flowgram-node-registries"
import { WorkflowLineAddButton } from "./workflow-line-add-button"
import {
normalizeConditionPortsForFlowgram,
syncConditionBranchTargetsFromEdges,
} from "./workflow-utils"
export function useFlowgramEditorProps({
definition,
nodeSpecs,
readonly = false,
onDefinitionChange,
}: {
definition: AIWorkflowDefinition
nodeSpecs: AIWorkflowNodeSpec[]
readonly?: boolean
onDefinitionChange?: (definition: AIWorkflowDefinition) => void
}) {
return useMemo<FreeLayoutProps>(
() => {
const initialData = normalizeConditionPortsForFlowgram(definition)
return {
background: true,
readonly,
scroll: {
disableScrollBar: true,
},
initialData: initialData as WorkflowJSON,
fromNodeJSON(_node, json) {
return json
},
toNodeJSON(_node, json) {
return json
},
materials: {
renderDefaultNode: FlowgramNodeRenderer,
},
nodeRegistries: buildFlowgramNodeRegistries(nodeSpecs),
nodeEngine: {
enable: true,
},
history: {
enable: !readonly,
enableChangeNode: !readonly,
},
canDeleteNode: (_ctx, node) => {
const type = String(node.flowNodeType ?? "")
return type !== "start"
},
canDeleteLine: () => !readonly,
onContentChange: (ctx) => {
if (readonly) {
return
}
const next = normalizeConditionPortsForFlowgram(
syncConditionBranchTargetsFromEdges(ctx.document.toJSON() as AIWorkflowDefinition)
)
onDefinitionChange?.(next)
},
onAllLayersRendered: (ctx) => {
scrollToInitialNode(ctx)
},
getNodeDefaultRegistry(type) {
return {
type,
meta: {
defaultExpanded: true,
},
}
},
plugins: () => [
createFreeLinesPlugin({
renderInsideLine: WorkflowLineAddButton,
}),
createMinimapPlugin({
disableLayer: true,
}),
createFreeSnapPlugin({}),
],
}
},
[definition, nodeSpecs, onDefinitionChange, readonly]
)
}
function scrollToInitialNode(ctx: FreeLayoutPluginContext) {
const nodes = ctx.document.getAllNodes()
const startNode = nodes.find((node) => String(node.flowNodeType ?? "") === "start")
const targetNode = startNode ?? findLeftTopNode(nodes)
if (!targetNode) {
return
}
window.requestAnimationFrame(() => {
const viewport = ctx.playground.config.getViewport(false)
void ctx.playground.scrollToView({
bounds: targetNode.transform.bounds,
scrollDelta: {
x: Math.max(viewport.width / 2 - 250, 0),
y: Math.max(viewport.height / 2 - 180, 0),
},
zoom: 1,
scrollToCenter: true,
})
})
}
function findLeftTopNode(nodes: WorkflowNodeEntity[]) {
return [...nodes].sort((left, right) => {
const leftBounds = left.transform.bounds
const rightBounds = right.transform.bounds
if (leftBounds.left !== rightBounds.left) {
return leftBounds.left - rightBounds.left
}
return leftBounds.top - rightBounds.top
})[0]
}
@@ -1,109 +0,0 @@
import { Field, type WorkflowNodeRegistry, useNodeRender } from "@flowgram.ai/free-layout-editor"
import type { AIWorkflowNodeSpec } from "@/lib/api/admin"
import { WorkflowConditionNodeContent } from "./workflow-condition-node-content"
import { WorkflowNodeCard } from "./workflow-node-card"
export function buildFlowgramNodeRegistries(nodeSpecs: AIWorkflowNodeSpec[]): WorkflowNodeRegistry[] {
const seen = new Set<string>()
const specs = nodeSpecs.length > 0
? nodeSpecs
: [
{
type: "start",
title: "开始",
description: "流程入口",
icon: "PlayCircleIcon",
riskLevel: "low" as const,
interruptible: false,
requiresConfirmationPredecessor: false,
},
{
type: "end",
title: "结束",
description: "流程结束",
icon: "FlagIcon",
riskLevel: "low" as const,
interruptible: false,
requiresConfirmationPredecessor: false,
},
]
return specs
.filter((spec) => {
if (!spec.type || seen.has(spec.type)) {
return false
}
seen.add(spec.type)
return true
})
.map((spec) => ({
type: spec.type,
meta: {
defaultExpanded: true,
isStart: spec.type === "start",
deleteDisable: spec.type === "start",
copyDisable: spec.type === "start",
defaultPorts: defaultPortsForNodeType(spec.type),
},
formMeta: {
render: () => (
<FlowgramNodeForm
nodeType={spec.type}
fallbackTitle={spec.title || spec.type}
icon={spec.icon}
/>
),
},
}))
}
function FlowgramNodeForm({
nodeType,
fallbackTitle,
icon,
}: {
nodeType: string
fallbackTitle: string
icon: string
}) {
const { node, selected } = useNodeRender()
const nodeId = String(node.id ?? "")
return (
<Field<string> name="title">
{({ field }) => (
<WorkflowNodeCard
title={field.value || fallbackTitle}
icon={icon}
selected={selected}
>
{nodeType === "condition" ? (
<Field<Record<string, unknown>> name="config">
{({ field: configField }) => (
<WorkflowConditionNodeContent
configValue={configField.value}
nodeId={nodeId}
onChange={configField.onChange}
/>
)}
</Field>
) : null}
</WorkflowNodeCard>
)}
</Field>
)
}
function defaultPortsForNodeType(type: string) {
if (type === "start") {
return [{ type: "output" as const }]
}
if (type === "end") {
return [{ type: "input" as const }]
}
if (type === "condition") {
return [{ type: "input" as const }]
}
return [{ type: "input" as const }, { type: "output" as const }]
}
@@ -1,49 +0,0 @@
import "@flowgram.ai/free-layout-editor/index.css"
import {
useNodeRender,
WorkflowNodeRenderer,
type WorkflowNodeProps,
} from "@flowgram.ai/free-layout-editor"
import { useLayoutEffect } from "react"
import { cn } from "@/lib/utils"
import { useWorkflowPortAdd } from "./workflow-port-add-context"
export function FlowgramNodeRenderer(props: WorkflowNodeProps) {
const { selected, node, form } = useNodeRender()
const requestPortAdd = useWorkflowPortAdd()
const nodeType = String(node.flowNodeType ?? "")
useLayoutEffect(() => {
if (nodeType !== "condition") return
const frame = window.requestAnimationFrame(() => {
node.ports.updateDynamicPorts()
})
return () => window.cancelAnimationFrame(frame)
})
return (
<WorkflowNodeRenderer
node={props.node}
className={cn(
"overflow-visible rounded-lg border transition-colors",
selected ? "border-(--g-selection-background)" : "border-transparent"
)}
style={{ padding: 0 }}
portClassName="workflow-node-port"
portPrimaryColor="#2575FC"
portSecondaryColor="#c9cdd4"
portBackgroundColor="#FFFFFF"
onPortClick={(port, event) => {
if (port.portType !== "output" || typeof event === "function") {
return
}
event.stopPropagation()
requestPortAdd?.({ sourcePort: port, event })
}}
>
{form?.render()}
</WorkflowNodeRenderer>
)
}
@@ -1,793 +0,0 @@
"use client"
import { useEffect, useMemo, useState, type ReactNode } from "react"
import { CheckIcon, ChevronsUpDownIcon, Trash2Icon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import { Input } from "@/components/ui/input"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { OptionCombobox } from "@/components/option-combobox"
import { fetchKnowledgeBasesAll, type AIWorkflowDefinition, type AIWorkflowNodeSpec, type KnowledgeBase } from "@/lib/api/admin"
import { Status } from "@/lib/generated/enums"
import { cn } from "@/lib/utils"
import { VariableSelector } from "./variable-selector"
import {
buildVariableSpecDisplay,
createConditionBranchID,
isRefValue,
normalizeNodeConfig,
refField,
refNodeId,
type WorkflowConditionBranch,
type WorkflowVariableRef,
} from "./workflow-utils"
export type WorkflowBranchSummary = {
branchId: string
targetNodeId?: string
targetName?: string
}
const CONDITION_OPERATOR_OPTIONS = [
{ value: "eq", label: "等于" },
{ value: "neq", label: "不等于" },
{ value: "contains", label: "包含" },
{ value: "not_contains", label: "不包含" },
{ value: "gt", label: "大于" },
{ value: "gte", label: "大于等于" },
{ value: "lt", label: "小于" },
{ value: "lte", label: "小于等于" },
{ value: "exists", label: "存在" },
{ value: "empty", label: "为空" },
]
const inspectorInputClassName = "h-8 rounded-sm border-slate-200 bg-white px-2 text-sm shadow-none"
const inspectorComboboxClassName = "h-8 rounded-sm border-slate-200 bg-white text-sm shadow-none"
export function NodeConfigPanel({
node,
nodeSpec,
nodes,
availableVariables,
showHeader = true,
showConditionBranches = true,
onChange,
onDelete,
}: {
node: AIWorkflowDefinition["nodes"][number] | null
nodeSpec?: AIWorkflowNodeSpec
nodes: AIWorkflowDefinition["nodes"]
availableVariables?: WorkflowVariableRef[]
branchSummaries?: WorkflowBranchSummary[]
showHeader?: boolean
showConditionBranches?: boolean
onChange: (nodeId: string, data: AIWorkflowDefinition["nodes"][number]["data"]) => void
onDelete?: (nodeId: string) => void
}) {
if (!node) {
return (
<div className="flex h-full items-center justify-center p-6 text-sm text-muted-foreground">
</div>
)
}
const inputsValues = node.data?.inputsValues ?? {}
const inputSchema = nodeSpec?.inputSchema ?? []
const outputSchema = nodeSpec?.outputSchema ?? []
const canDelete = node.type !== "start" && node.type !== "end"
const config = normalizeNodeConfig(node.data?.config)
const branches = config.branches ?? []
const updateData = (data: Partial<AIWorkflowDefinition["nodes"][number]["data"]>) => {
onChange(node.id, {
...(node.data ?? {}),
...data,
})
}
const updateConfig = (nextConfig: Record<string, unknown>) => updateData({ config: nextConfig })
const inputFields = inputSchema.map((input) => {
const value = inputsValues[input.name]
return (
<InspectorField
key={input.name}
label={input.label || input.name}
required={input.required}
fieldName={input.name}
fieldType={input.type}
>
<VariableSelector
value={isRefValue(value) ? value : undefined}
variables={availableVariables ?? []}
placeholder="选择变量"
triggerClassName={inspectorComboboxClassName}
onChange={(next) => {
updateData({
inputsValues: {
...inputsValues,
[input.name]: next,
},
})
}}
/>
{input.description ? <InspectorHint>{input.description}</InspectorHint> : null}
</InspectorField>
)
})
const outputFields = outputSchema.map((output) => {
const item = buildVariableSpecDisplay(output)
return (
<InspectorField key={item.key} label={item.label} detail={item.subtitle}>
{item.description ? <InspectorHint>{item.description}</InspectorHint> : null}
</InspectorField>
)
})
const updateBranch = (branch: WorkflowConditionBranch) => {
const nextBranches = branches.some((item) => item.id === branch.id)
? branches.map((item) => (item.id === branch.id ? branch : item))
: [...branches, branch]
updateConfig({ ...config, branches: nextBranches })
}
const deleteBranch = (branchId: string) => {
updateConfig({ ...config, branches: branches.filter((branch) => branch.id !== branchId) })
}
const addBranch = () => {
const targetNodeId = nodes.find((item) => item.id !== node.id && item.type !== "start")?.id ?? ""
updateBranch({
id: createConditionBranchID(branches),
name: "新分支",
targetNodeId,
condition: {
operator: "eq",
},
})
}
return (
<div className="pb-3">
{showHeader ? (
<div className="border-b px-4 py-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium">
{node.data?.title || nodeSpec?.title || node.type}
</div>
<div className="mt-1 truncate text-sm text-muted-foreground">{node.id}</div>
</div>
{canDelete ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => onDelete?.(node.id)}
aria-label="删除节点"
>
<Trash2Icon className="size-4" />
</Button>
) : null}
</div>
</div>
) : null}
<div className="overflow-hidden border-y border-slate-200 bg-white">
<InspectorParameterTabs
key={node.id}
inputCount={inputSchema.length}
outputCount={outputSchema.length}
inputContent={inputFields}
outputContent={outputFields}
/>
{node.type === "knowledge_retrieve" ? (
<KnowledgeRetrieveConfigPanel
config={config}
onChange={(nextConfig) => updateConfig(nextConfig)}
/>
) : null}
{showConditionBranches && (node.type === "condition" || branches.length > 0) ? (
<ConditionBranchesEditor
branches={branches}
nodes={nodes}
currentNodeId={node.id}
variables={availableVariables ?? []}
onAdd={addBranch}
onChange={updateBranch}
onDelete={deleteBranch}
/>
) : null}
</div>
</div>
)
}
export function ConditionBranchConfigPanel({
node,
nodes,
branchId,
variables,
onChange,
}: {
node: AIWorkflowDefinition["nodes"][number]
nodes: AIWorkflowDefinition["nodes"]
branchId: string
variables: WorkflowVariableRef[]
onChange: (nodeId: string, data: AIWorkflowDefinition["nodes"][number]["data"]) => void
}) {
const config = normalizeNodeConfig(node.data?.config)
const branches = config.branches ?? []
const branch = branches.find((item) => item.id === branchId)
const targetOptions = buildTargetOptions(nodes, node.id)
if (!branch) {
return (
<div className="flex h-full items-center justify-center p-6 text-sm text-muted-foreground">
</div>
)
}
const updateBranch = (nextBranch: WorkflowConditionBranch) => {
onChange(node.id, {
...(node.data ?? {}),
config: {
...config,
branches: branches.map((item) => (item.id === nextBranch.id ? nextBranch : item)),
},
})
}
return (
<div className="pb-3">
<div className="overflow-hidden border-y border-slate-200 bg-white">
<InspectorSection title="分支" meta={branch.default ? "默认" : "条件"}>
<InspectorRow label="目标节点">
<OptionCombobox
value={branch.targetNodeId}
options={targetOptions}
placeholder="选择目标节点"
triggerClassName={inspectorComboboxClassName}
preserveExternalSelection
onChange={(targetNodeId) => updateBranch({ ...branch, targetNodeId })}
/>
</InspectorRow>
<InspectorRow label="默认分支">
<label className="inline-flex h-8 items-center gap-2 text-sm text-slate-700">
<input
type="checkbox"
checked={branch.default === true}
className="size-3.5"
onChange={(event) => updateBranch({
...branch,
default: event.target.checked,
condition: event.target.checked ? undefined : branch.condition,
})}
/>
</label>
</InspectorRow>
</InspectorSection>
{branch.default ? (
<InspectorSection title="条件表达式">
<div className="px-3 py-2 text-sm text-slate-500">
</div>
</InspectorSection>
) : (
<InspectorSection title="条件表达式">
<ConditionFields branch={branch} variables={variables} onChange={updateBranch} />
</InspectorSection>
)}
</div>
</div>
)
}
function KnowledgeRetrieveConfigPanel({
config,
onChange,
}: {
config: Record<string, unknown>
onChange: (config: Record<string, unknown>) => void
}) {
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([])
const [open, setOpen] = useState(false)
const selectedKnowledgeIds = normalizeKnowledgeBaseIds(config.knowledgeBaseIds)
const knowledgeOptions = useMemo(
() => knowledgeBases.map((item) => ({ value: String(item.id), label: item.name })),
[knowledgeBases]
)
const selectedKnowledgeOptions = selectedKnowledgeIds
.map((id) => knowledgeOptions.find((option) => Number(option.value) === id))
.filter((option): option is { value: string; label: string } => Boolean(option))
useEffect(() => {
let cancelled = false
fetchKnowledgeBasesAll({ status: Status.Ok })
.then((items) => {
if (!cancelled) {
setKnowledgeBases(items ?? [])
}
})
.catch(() => {
if (!cancelled) {
setKnowledgeBases([])
}
})
return () => {
cancelled = true
}
}, [])
const updateKnowledgeBaseIds = (ids: number[]) => {
onChange({ ...config, knowledgeBaseIds: uniquePositiveNumbers(ids) })
}
const toggleKnowledgeBase = (value: string) => {
const id = Number(value)
if (!Number.isFinite(id) || id <= 0) return
if (selectedKnowledgeIds.includes(id)) {
updateKnowledgeBaseIds(selectedKnowledgeIds.filter((item) => item !== id))
return
}
updateKnowledgeBaseIds([...selectedKnowledgeIds, id])
}
return (
<InspectorSection title="节点配置" meta={`${selectedKnowledgeIds.length} 个知识库`}>
<InspectorRow label="知识库" required>
<div className="space-y-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<Button
type="button"
variant="outline"
role="combobox"
className={cn("m-0 w-full justify-between font-normal", inspectorComboboxClassName)}
/>
}
>
<span className="truncate">
{selectedKnowledgeOptions.length === 0
? "选择知识库"
: selectedKnowledgeOptions.length === 1
? selectedKnowledgeOptions[0].label
: `已选择 ${selectedKnowledgeOptions.length} 个知识库`}
</span>
<ChevronsUpDownIcon className="ml-2 size-4 shrink-0 opacity-50" />
</PopoverTrigger>
<PopoverContent
className="w-(--radix-popover-trigger-width) p-0"
align="start"
data-workflow-preserve-selection
>
<Command>
<CommandInput placeholder="搜索知识库" />
<CommandList>
<CommandEmpty></CommandEmpty>
<CommandGroup>
{knowledgeOptions.map((option) => {
const selected = selectedKnowledgeIds.includes(Number(option.value))
return (
<CommandItem
key={option.value}
value={`${option.label} ${option.value}`}
onSelect={() => toggleKnowledgeBase(option.value)}
>
<CheckIcon
className={cn(
"mr-2 size-4 shrink-0",
selected ? "opacity-100" : "opacity-0"
)}
/>
<span className="truncate">{option.label}</span>
</CommandItem>
)
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{selectedKnowledgeOptions.length === 0 ? (
<div className="text-sm text-slate-500">
</div>
) : null}
</div>
</InspectorRow>
</InspectorSection>
)
}
function ConditionBranchesEditor({
branches,
nodes,
currentNodeId,
variables,
onAdd,
onChange,
onDelete,
}: {
branches: WorkflowConditionBranch[]
nodes: AIWorkflowDefinition["nodes"]
currentNodeId: string
variables: WorkflowVariableRef[]
onAdd: () => void
onChange: (branch: WorkflowConditionBranch) => void
onDelete: (branchId: string) => void
}) {
const targetOptions = buildTargetOptions(nodes, currentNodeId)
return (
<InspectorSection
title="条件分支"
meta={`${branches.length}`}
action={
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-sm text-slate-600" onClick={onAdd}>
</Button>
}
>
{branches.length === 0 ? (
<div className="px-3 py-3 text-sm text-slate-500">
</div>
) : null}
<div className="divide-y divide-slate-100">
{branches.map((branch) => {
return (
<div key={branch.id} className="bg-white px-3 py-2">
<div className="grid grid-cols-[44px_minmax(0,1fr)_auto] items-center gap-2">
<span className="inline-flex h-5 shrink-0 items-center justify-center rounded-sm border border-slate-200 bg-slate-50 px-1.5 font-mono text-xs font-semibold text-slate-600">
{branch.default ? "ELSE" : "IF"}
</span>
<Input
value={branch.name ?? ""}
placeholder={branch.id}
className={cn(inspectorInputClassName, "min-w-0 border-transparent bg-transparent px-1 font-medium")}
onChange={(event) => onChange({ ...branch, name: event.target.value })}
/>
{branch.default ? null : (
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2 text-sm text-slate-500 hover:text-destructive"
onClick={() => onDelete(branch.id)}
>
</Button>
)}
</div>
<div className="mt-2 grid grid-cols-[72px_minmax(0,1fr)] items-center gap-2">
<div className="text-sm text-slate-500"></div>
<OptionCombobox
value={branch.targetNodeId}
options={targetOptions}
placeholder="选择目标节点"
triggerClassName={inspectorComboboxClassName}
preserveExternalSelection
onChange={(targetNodeId) => onChange({ ...branch, targetNodeId })}
/>
</div>
<label className="mt-2 flex items-center gap-2 pl-[72px] text-sm text-slate-500">
<input
type="checkbox"
checked={branch.default === true}
className="size-3.5"
onChange={(event) => onChange({
...branch,
default: event.target.checked,
condition: event.target.checked ? undefined : branch.condition,
})}
/>
</label>
{branch.default ? null : (
<div className="border-t border-slate-200 pt-2">
<ConditionFields branch={branch} variables={variables} onChange={onChange} compact />
</div>
)}
</div>
)
})}
</div>
</InspectorSection>
)
}
function ConditionFields({
branch,
variables,
onChange,
compact = false,
}: {
branch: WorkflowConditionBranch
variables: WorkflowVariableRef[]
onChange: (branch: WorkflowConditionBranch) => void
compact?: boolean
}) {
const condition = branch.condition ?? {}
const selectedVariable = isRefValue(condition.left)
? variables.find((item) => item.nodeId === refNodeId(condition.left) && item.field === refField(condition.left))
: undefined
const valueOptions = selectedVariable?.valueOptions ?? []
const rightDisabled = ["exists", "empty"].includes(condition.operator ?? "")
return (
<div className={cn("divide-y divide-slate-100", compact && "divide-y-0")}>
<div className={cn("grid grid-cols-[92px_minmax(0,1fr)] items-start gap-2 px-3 py-2", compact && "grid-cols-[72px_minmax(0,1fr)] px-0 py-1")}>
<div className="pt-1.5 text-sm text-slate-500"></div>
<VariableSelector
value={isRefValue(condition.left) ? condition.left : undefined}
variables={variables}
placeholder="选择变量"
triggerClassName={inspectorComboboxClassName}
onChange={(left) => onChange({
...branch,
condition: { ...condition, left },
})}
/>
</div>
<div className={cn("grid grid-cols-[92px_minmax(0,1fr)_minmax(0,1fr)] items-start gap-2 px-3 py-2", compact && "grid-cols-[72px_minmax(0,1fr)_96px] px-0 py-1")}>
<div className="pt-1.5 text-sm text-slate-500"></div>
<div>
<OptionCombobox
value={condition.operator ?? ""}
options={CONDITION_OPERATOR_OPTIONS}
placeholder="选择操作符"
triggerClassName={inspectorComboboxClassName}
preserveExternalSelection
onChange={(operator) => onChange({
...branch,
condition: { ...condition, operator },
})}
/>
</div>
<div className={cn(rightDisabled && "opacity-50")}>
{valueOptions.length > 0 && !rightDisabled ? (
<OptionCombobox
value={stringifyConditionRight(condition.right)}
options={valueOptions.map((option) => ({
value: stringifyConditionRight(option.value),
label: option.label || stringifyConditionRight(option.value),
description: option.description,
}))}
placeholder="选择取值"
triggerClassName={inspectorComboboxClassName}
preserveExternalSelection
onChange={(nextValue) => {
const selectedOption = valueOptions.find((option) => stringifyConditionRight(option.value) === nextValue)
onChange({
...branch,
condition: { ...condition, right: selectedOption?.value ?? nextValue },
})
}}
/>
) : (
<Input
value={stringifyConditionRight(condition.right)}
disabled={rightDisabled}
className={inspectorInputClassName}
onChange={(event) => onChange({
...branch,
condition: { ...condition, right: event.target.value },
})}
/>
)}
</div>
</div>
</div>
)
}
function InspectorParameterTabs({
inputCount,
outputCount,
inputContent,
outputContent,
}: {
inputCount: number
outputCount: number
inputContent: ReactNode
outputContent: ReactNode
}) {
const tabs = [
inputCount > 0 ? { value: "input", label: "输入", count: inputCount, content: inputContent } : null,
outputCount > 0 ? { value: "output", label: "输出", count: outputCount, content: outputContent } : null,
].filter((item): item is { value: string; label: string; count: number; content: ReactNode } => Boolean(item))
if (tabs.length === 0) {
return null
}
if (tabs.length === 1) {
return (
<InspectorSection title={tabs[0].label} meta={`${tabs[0].count}`}>
{tabs[0].content}
</InspectorSection>
)
}
return (
<section className="border-b border-slate-200 last:border-b-0">
<Tabs defaultValue={tabs[0].value} className="gap-0">
<div className="flex min-h-9 items-center border-b border-slate-100 bg-slate-100 px-3 py-1">
<TabsList variant="line" className="h-6 gap-1 rounded-none bg-transparent p-0">
{tabs.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
>
<span>{tab.label}</span>
<span className="ml-1.5 font-mono text-xs text-slate-400 data-active:text-slate-500">{tab.count}</span>
</TabsTrigger>
))}
</TabsList>
</div>
{tabs.map((tab) => (
<TabsContent key={tab.value} value={tab.value} className="m-0">
{tab.content}
</TabsContent>
))}
</Tabs>
</section>
)
}
function InspectorSection({
title,
meta,
action,
children,
}: {
title: string
meta?: string
action?: ReactNode
children: ReactNode
}) {
return (
<section className="border-b border-slate-200 last:border-b-0">
<div className="flex min-h-8 items-center justify-between gap-2 border-b border-slate-100 bg-slate-50/80 px-3 py-1">
<div className="min-w-0">
<div className="truncate text-sm font-semibold uppercase tracking-wide text-slate-500">{title}</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{meta ? <span className="font-mono text-xs text-slate-400">{meta}</span> : null}
{action}
</div>
</div>
<div>{children}</div>
</section>
)
}
function InspectorRow({
label,
detail,
required,
children,
}: {
label: string
detail?: string
required?: boolean
children: ReactNode
}) {
return (
<div className="grid grid-cols-[112px_minmax(0,1fr)] gap-3 border-b border-slate-100 px-3 py-2 last:border-b-0">
<div className="min-w-0 pt-1">
<div className="flex min-w-0 items-center gap-1">
<span className="truncate text-sm font-medium text-slate-700">{label}</span>
{required ? <span className="text-xs text-destructive">*</span> : null}
</div>
{detail ? <div className="mt-0.5 truncate font-mono text-xs leading-5 text-slate-400">{detail}</div> : null}
</div>
<div className="min-w-0">{children}</div>
</div>
)
}
function InspectorField({
label,
detail,
fieldName,
fieldType,
required,
children,
}: {
label: string
detail?: string
fieldName?: string
fieldType?: string
required?: boolean
children: ReactNode
}) {
const metaItems = [
fieldName ? { label: "字段", value: fieldName } : null,
fieldType ? { label: "类型", value: fieldType } : null,
].filter((item): item is { label: string; value: string } => Boolean(item))
return (
<div className="border-b border-slate-100 px-3 py-2.5 last:border-b-0">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex min-w-0 items-start gap-1">
<span className="min-w-0 break-words text-sm font-medium leading-5 text-slate-700">{label}</span>
{required ? <span className="shrink-0 text-xs text-destructive">*</span> : null}
</div>
{detail ? <div className="mt-1 break-all font-mono text-xs leading-5 text-slate-400">{detail}</div> : null}
</div>
</div>
{metaItems.length > 0 ? (
<div className="mt-1.5 flex flex-wrap gap-1">
{metaItems.map((item) => (
<span
key={item.label}
className="inline-flex min-w-0 max-w-full items-center gap-1 rounded-sm border border-slate-200 bg-slate-50 px-1.5 py-0.5 font-mono text-xs leading-5 text-slate-500"
>
<span className="shrink-0 text-slate-400">{item.label}</span>
<span className="min-w-0 break-all">{item.value}</span>
</span>
))}
</div>
) : null}
<div className="mt-2 min-w-0">{children}</div>
</div>
)
}
function InspectorHint({ children }: { children: ReactNode }) {
return <div className="mt-1.5 text-sm leading-5 text-slate-500">{children}</div>
}
function buildTargetOptions(nodes: AIWorkflowDefinition["nodes"], currentNodeId: string) {
return nodes
.filter((node) => node.id !== currentNodeId && node.type !== "start")
.map((node) => ({
value: node.id,
label: node.data?.title || node.type || node.id,
}))
}
function stringifyConditionRight(value: unknown) {
if (value === undefined || value === null) {
return ""
}
if (typeof value === "string") {
return value
}
return JSON.stringify(value)
}
function normalizeKnowledgeBaseIds(value: unknown) {
if (!Array.isArray(value)) {
return []
}
return uniquePositiveNumbers(
value
.map((item) => Number(item))
.filter((item) => Number.isFinite(item))
)
}
function uniquePositiveNumbers(input: number[]) {
return Array.from(new Set(input.filter((item) => item > 0)))
}
@@ -1,45 +0,0 @@
"use client"
import { OptionCombobox } from "@/components/option-combobox"
import {
buildVariableOption,
createRefValue,
refField,
refNodeId,
type WorkflowVariableRef,
type WorkflowVariableSelector,
} from "./workflow-utils"
export function VariableSelector({
value,
variables,
onChange,
placeholder = "选择变量",
triggerClassName,
}: {
value?: WorkflowVariableSelector
variables: WorkflowVariableRef[]
onChange: (value: WorkflowVariableSelector) => void
placeholder?: string
triggerClassName?: string
}) {
const selected = value ? `${refNodeId(value)}.${refField(value)}` : ""
const options = variables.map(buildVariableOption)
return (
<OptionCombobox
value={selected}
options={options}
placeholder={placeholder}
triggerClassName={triggerClassName}
preserveExternalSelection
onChange={(next) => {
const variable = variables.find((item) => `${item.nodeId}.${item.field}` === next)
if (variable) {
onChange(createRefValue(variable.nodeId, variable.field))
}
}}
/>
)
}
@@ -1,41 +0,0 @@
"use client"
import { createContext, useContext, useMemo, type ReactNode } from "react"
export type SelectedWorkflowBranch = {
nodeId: string
branchId: string
}
type WorkflowBranchSelectionContextValue = {
selectedBranch: SelectedWorkflowBranch | null
onSelectBranch: (branch: SelectedWorkflowBranch | null) => void
}
const WorkflowBranchSelectionContext = createContext<WorkflowBranchSelectionContextValue>({
selectedBranch: null,
onSelectBranch: () => {},
})
export function WorkflowBranchSelectionProvider({
selectedBranch,
onSelectBranch,
children,
}: WorkflowBranchSelectionContextValue & {
children: ReactNode
}) {
const value = useMemo(
() => ({ selectedBranch, onSelectBranch }),
[onSelectBranch, selectedBranch]
)
return (
<WorkflowBranchSelectionContext.Provider value={value}>
{children}
</WorkflowBranchSelectionContext.Provider>
)
}
export function useWorkflowBranchSelection() {
return useContext(WorkflowBranchSelectionContext)
}
@@ -1,94 +0,0 @@
"use client"
import type { ReactNode } from "react"
import { Maximize2Icon, MinusIcon, PlusIcon, SparklesIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
export function WorkflowCanvasControls({
zoomPercent,
onZoomIn,
onZoomOut,
onResetZoom,
onFitView,
onAutoLayout,
autoLayoutDisabled = false,
}: {
zoomPercent?: string
onZoomIn?: () => void
onZoomOut?: () => void
onResetZoom?: () => void
onFitView?: () => void
onAutoLayout?: () => void
autoLayoutDisabled?: boolean
}) {
return (
<div className="inline-flex h-9 items-center gap-0.5 rounded-md border border-slate-200/80 bg-white/95 p-1 shadow-sm backdrop-blur">
<CanvasControlButton label="缩小" onClick={onZoomOut}>
<MinusIcon className="size-3.5" />
</CanvasControlButton>
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-12 px-1 text-xs tabular-nums text-slate-700 hover:bg-slate-100 hover:text-slate-950"
aria-label="重置为 100%"
disabled={!onResetZoom}
onClick={onResetZoom}
/>
}
>
{zoomPercent ?? "100%"}
</TooltipTrigger>
<TooltipContent> 100%</TooltipContent>
</Tooltip>
<CanvasControlButton label="放大" onClick={onZoomIn}>
<PlusIcon className="size-3.5" />
</CanvasControlButton>
<span className="mx-1 h-4 w-px shrink-0 bg-slate-200" />
<CanvasControlButton label="适配画布" onClick={onFitView}>
<Maximize2Icon className="size-3.5" />
</CanvasControlButton>
<CanvasControlButton label="自动布局" onClick={onAutoLayout} disabled={autoLayoutDisabled}>
<SparklesIcon className="size-3.5" />
</CanvasControlButton>
</div>
)
}
function CanvasControlButton({
label,
onClick,
disabled = false,
children,
}: {
label: string
onClick?: () => void
disabled?: boolean
children: ReactNode
}) {
return (
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-xs"
className="size-7 text-slate-700 hover:bg-slate-100 hover:text-slate-950"
aria-label={label}
disabled={disabled || !onClick}
onClick={onClick}
/>
}
>
{children}
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
)
}
@@ -1,165 +0,0 @@
import { PlusIcon, XIcon } from "lucide-react"
import { useService, WorkflowLinesManager } from "@flowgram.ai/free-layout-editor"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { useWorkflowBranchSelection } from "./workflow-branch-selection"
import {
createConditionBranchID,
isConditionBranchEdge,
isBranchRowActionTarget,
normalizeNodeConfig,
type WorkflowConditionBranch,
} from "./workflow-utils"
export function WorkflowConditionNodeContent({
configValue,
nodeId,
onChange,
}: {
configValue: Record<string, unknown> | undefined
nodeId: string
onChange: (value: Record<string, unknown>) => void
}) {
const linesManager = useService(WorkflowLinesManager)
const { selectedBranch, onSelectBranch } = useWorkflowBranchSelection()
const config = normalizeNodeConfig(configValue)
const branches = ensureConditionBranches(config.branches ?? [])
const updateBranches = (nextBranches: WorkflowConditionBranch[]) => {
onChange({
...config,
branches: ensureConditionBranches(nextBranches),
})
}
const deleteBranch = (branchId: string) => {
linesManager.getAllLines().forEach((line) => {
if (isConditionBranchEdge(line.toJSON(), nodeId, branchId)) {
line.dispose()
}
})
updateBranches(branches.filter((branch) => branch.id !== branchId))
if (selectedBranch?.nodeId === nodeId && selectedBranch.branchId === branchId) {
onSelectBranch?.(null)
}
}
return (
<div className="space-y-2">
<div className="space-y-1.5">
{branches.map((branch, index) => (
<WorkflowConditionBranchRow
key={branch.id}
branch={branch}
index={index}
selected={selectedBranch?.nodeId === nodeId && selectedBranch.branchId === branch.id}
onSelect={() => onSelectBranch?.({ nodeId, branchId: branch.id })}
onDelete={() => deleteBranch(branch.id)}
/>
))}
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-muted-foreground"
onClick={(event) => {
event.stopPropagation()
updateBranches([
...branches,
{
id: createConditionBranchID(branches),
name: "新条件",
targetNodeId: "",
condition: { operator: "eq" },
},
])
}}
>
<PlusIcon className="size-3.5" />
</Button>
</div>
)
}
function WorkflowConditionBranchRow({
branch,
index,
selected,
onSelect,
onDelete,
}: {
branch: WorkflowConditionBranch
index: number
selected: boolean
onSelect: () => void
onDelete: () => void
}) {
const branchType = branch.default ? "else" : index === 0 ? "if" : "elseif"
return (
<div
className={cn(
"relative flex min-h-10 cursor-pointer items-center gap-2 rounded-lg border px-2.5 py-1.5 text-xs transition-colors",
selected
? "border-(--g-selection-background) bg-background shadow-sm"
: "border-border/50 bg-background/70 hover:border-border hover:bg-background"
)}
onPointerDownCapture={(event) => {
event.stopPropagation()
if (isBranchRowActionTarget(event.target)) {
return
}
onSelect()
}}
onMouseDownCapture={(event) => {
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
}}
>
<span className="shrink-0 rounded-md border bg-muted px-1.5 py-0.5 text-[10px] font-semibold text-muted-foreground">
{branchType}
</span>
<span className="min-w-0 flex-1 truncate font-medium text-foreground/90">
{branch.name || (branch.default ? "默认分支" : branch.id)}
</span>
{branch.default ? null : (
<button
type="button"
className="flex size-5 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
aria-label={`删除条件 ${branch.name || branch.id}`}
onPointerDownCapture={(event) => {
event.stopPropagation()
}}
onMouseDownCapture={(event) => {
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
onDelete()
}}
>
<XIcon className="size-3.5" />
</button>
)}
<span
data-port-id={branch.id}
data-port-type="output"
className="absolute -right-4 top-1/2 size-0"
/>
</div>
)
}
function ensureConditionBranches(branches: WorkflowConditionBranch[]) {
const normalized = branches.some((branch) => branch.default)
? branches
: [...branches, { id: "default", name: "默认分支", targetNodeId: "", default: true }]
return [
...normalized.filter((branch) => !branch.default),
...normalized.filter((branch) => branch.default).slice(0, 1),
]
}
@@ -1,179 +0,0 @@
"use client"
import { useState, type PointerEvent as ReactPointerEvent } from "react"
import { XIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area"
import type { AIWorkflowDefinition, AIWorkflowNodeSpec } from "@/lib/api/admin"
import type { SelectedWorkflowBranch } from "./workflow-branch-selection"
import { ConditionBranchConfigPanel, NodeConfigPanel } from "./node-config-panel"
import { WorkflowNodeIcon } from "./workflow-node-icon"
import {
getAvailableVariables,
normalizeNodeConfig,
type WorkflowNodeData,
} from "./workflow-utils"
const PANEL_DEFAULT_WIDTH = 460
const PANEL_MIN_WIDTH = 320
const PANEL_MAX_WIDTH = 600
export function WorkflowConfigPanel({
definition,
nodeSpecs,
selectedNodeId,
selectedBranch,
onClose,
onChangeNodeData,
onDeleteNode,
}: {
definition: AIWorkflowDefinition
nodeSpecs: AIWorkflowNodeSpec[]
selectedNodeId: string
selectedBranch: SelectedWorkflowBranch | null
onClose: () => void
onChangeNodeData: (nodeId: string, data: WorkflowNodeData) => void
onDeleteNode: (nodeId: string) => void
}) {
const [panelWidth, setPanelWidth] = useState(PANEL_DEFAULT_WIDTH)
const selectedNode = definition.nodes.find((node) => node.id === selectedNodeId) ?? null
const selectedNodeSpec = selectedNode
? nodeSpecs.find((spec) => spec.type === selectedNode.type)
: undefined
const availableVariables = selectedNode
? getAvailableVariables(definition, selectedNode.id, nodeSpecs)
: []
const selectedBranchItem = selectedNode && selectedBranch?.nodeId === selectedNode.id
? normalizeNodeConfig(selectedNode.data?.config).branches?.find((branch) => branch.id === selectedBranch.branchId) ?? null
: null
const panelTitle = selectedBranchItem
? selectedBranchItem.name || selectedBranchItem.id
: selectedNode?.data?.title || selectedNodeSpec?.title || selectedNode?.type || ""
const panelDescription = selectedBranchItem
? ""
: selectedNodeSpec?.description || ""
const panelIcon = selectedBranchItem ? "GitBranchIcon" : selectedNodeSpec?.icon
if (!selectedNode) {
return null
}
const updatePanelTitle = (title: string) => {
if (selectedBranchItem && selectedBranch) {
const config = normalizeNodeConfig(selectedNode.data?.config)
onChangeNodeData(selectedNode.id, {
...(selectedNode.data ?? {}),
config: {
...config,
branches: (config.branches ?? []).map((branch) => (
branch.id === selectedBranch.branchId ? { ...branch, name: title } : branch
)),
},
})
return
}
onChangeNodeData(selectedNode.id, {
...(selectedNode.data ?? {}),
title,
})
}
const startResize = (event: ReactPointerEvent<HTMLDivElement>) => {
event.preventDefault()
const startX = event.clientX
const startWidth = panelWidth
const maxWidth = Math.max(PANEL_MIN_WIDTH, Math.min(PANEL_MAX_WIDTH, window.innerWidth - 320))
const resize = (moveEvent: PointerEvent) => {
const nextWidth = startWidth + startX - moveEvent.clientX
setPanelWidth(Math.min(Math.max(nextWidth, PANEL_MIN_WIDTH), maxWidth))
}
const stopResize = () => {
window.removeEventListener("pointermove", resize)
window.removeEventListener("pointerup", stopResize)
}
window.addEventListener("pointermove", resize)
window.addEventListener("pointerup", stopResize)
}
return (
<div
data-workflow-preserve-selection
className="pointer-events-none absolute inset-y-3 right-3 z-50 flex max-w-[calc(100%-1.5rem)]"
style={{ width: panelWidth }}
>
<div
role="separator"
aria-orientation="vertical"
aria-label="调整属性面板宽度"
className="group pointer-events-auto absolute inset-y-2 left-0 z-10 flex w-3 -translate-x-1.5 cursor-col-resize items-center justify-center"
onPointerDown={startResize}
>
<span className="h-12 w-1 rounded-full bg-slate-300/80 transition-colors group-hover:bg-blue-400" />
</div>
<section className="pointer-events-auto flex min-h-0 w-full flex-col overflow-hidden rounded-lg bg-white shadow-[0_18px_45px_rgba(15,23,42,0.18)] backdrop-blur">
<div className="shrink-0 border-slate-200 px-4 py-3">
<div className="flex min-w-0 items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2.5">
<WorkflowNodeIcon
icon={panelIcon}
size="sm"
className="rounded-md shadow-none"
/>
<div className="min-w-0 flex-1">
<Input
value={panelTitle}
placeholder={selectedBranchItem ? selectedBranchItem.id : selectedNodeSpec?.title || selectedNode.type}
className="h-7 w-full rounded-md border-transparent bg-transparent px-1 text-sm font-semibold leading-5 text-slate-900 shadow-none transition-colors hover:border-slate-200 hover:bg-slate-50 focus-visible:border-blue-300 focus-visible:bg-white focus-visible:ring-2 focus-visible:ring-blue-100"
onChange={(event) => updatePanelTitle(event.target.value)}
/>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 shrink-0 rounded-md text-slate-500 hover:bg-slate-100 hover:text-slate-700"
aria-label="关闭属性面板"
onClick={onClose}
>
<XIcon className="size-4" />
</Button>
</div>
{panelDescription ? (
<div className="mt-1 text-sm leading-5 text-slate-500">
{panelDescription}
</div>
) : null}
</div>
<ScrollArea className="min-h-0 flex-1">
<div>
{selectedBranchItem && selectedBranch ? (
<ConditionBranchConfigPanel
node={selectedNode}
nodes={definition.nodes}
branchId={selectedBranch.branchId}
variables={availableVariables}
onChange={onChangeNodeData}
/>
) : (
<NodeConfigPanel
node={selectedNode}
nodeSpec={selectedNodeSpec}
nodes={definition.nodes}
availableVariables={availableVariables}
showHeader={false}
showConditionBranches={selectedNode.type !== "condition"}
onChange={onChangeNodeData}
onDelete={onDeleteNode}
/>
)}
</div>
</ScrollArea>
</section>
</div>
)
}
@@ -1,39 +0,0 @@
"use client"
import { AlertTriangleIcon, CheckCircle2Icon } from "lucide-react"
import { cn } from "@/lib/utils"
import type { WorkflowDraftValidation } from "./workflow-utils"
export function WorkflowEditorStatus({
validation,
nodeCount,
edgeCount,
}: {
validation: WorkflowDraftValidation
nodeCount: number
edgeCount: number
}) {
return (
<div className="pointer-events-none inline-flex w-fit max-w-full items-center gap-2 px-1 text-xs text-slate-500">
<span
className={cn(
"inline-flex shrink-0 items-center gap-1",
validation.valid ? "text-emerald-600" : "text-amber-700"
)}
>
{validation.valid ? (
<CheckCircle2Icon className="size-3" />
) : (
<AlertTriangleIcon className="size-3" />
)}
{validation.valid ? "检查通过" : `${validation.errors.length} 个问题`}
</span>
<span className="shrink-0 text-slate-300">/</span>
<span className="shrink-0">{nodeCount} </span>
<span className="shrink-0 text-slate-300">·</span>
<span className="shrink-0">{edgeCount} 线</span>
</div>
)
}
@@ -1,78 +0,0 @@
"use client"
import type { ReactNode } from "react"
import {
CheckIcon,
Redo2Icon,
RotateCcwIcon,
SaveIcon,
SendIcon,
Undo2Icon,
} from "lucide-react"
import { Button } from "@/components/ui/button"
export function WorkflowEditorToolbar({
toolbarExtra,
onUndo,
undoDisabled = false,
onRedo,
redoDisabled = false,
onRestoreDefault,
restoreDefaultDisabled = false,
onValidate,
validateDisabled = false,
onSaveDraft,
saveDraftDisabled = false,
onPublish,
publishDisabled = false,
}: {
toolbarExtra?: ReactNode
onUndo?: () => void
undoDisabled?: boolean
onRedo?: () => void
redoDisabled?: boolean
onRestoreDefault?: () => void
restoreDefaultDisabled?: boolean
onValidate?: () => void
validateDisabled?: boolean
onSaveDraft?: () => void
saveDraftDisabled?: boolean
onPublish?: () => void
publishDisabled?: boolean
}) {
return (
<div className="inline-flex min-h-9 w-fit max-w-full items-center rounded-md bg-white/95 px-1.5 py-1 shadow-sm backdrop-blur">
<div className="flex min-w-0 items-center gap-0.5 overflow-x-auto overflow-y-hidden">
{toolbarExtra}
<span className="mx-1 h-4 w-px shrink-0 bg-slate-200" />
<Button type="button" variant="ghost" size="sm" className="h-7 shrink-0 px-2 text-xs text-slate-700 hover:bg-slate-100 hover:text-slate-950" disabled={undoDisabled} onClick={onUndo}>
<Undo2Icon className="size-3.5" />
</Button>
<Button type="button" variant="ghost" size="sm" className="h-7 shrink-0 px-2 text-xs text-slate-700 hover:bg-slate-100 hover:text-slate-950" disabled={redoDisabled} onClick={onRedo}>
<Redo2Icon className="size-3.5" />
</Button>
<span className="mx-1 h-4 w-px shrink-0 bg-slate-200" />
<Button type="button" variant="ghost" size="sm" className="h-7 shrink-0 px-2 text-xs text-slate-700 hover:bg-slate-100 hover:text-slate-950" disabled={restoreDefaultDisabled} onClick={onRestoreDefault}>
<RotateCcwIcon className="size-3.5" />
</Button>
<span className="mx-1 h-4 w-px shrink-0 bg-slate-200" />
<Button type="button" variant="ghost" size="sm" className="h-7 shrink-0 px-2 text-xs text-slate-700 hover:bg-slate-100 hover:text-slate-950" disabled={validateDisabled} onClick={onValidate}>
<CheckIcon className="size-3.5" />
</Button>
<Button type="button" variant="ghost" size="sm" className="h-7 shrink-0 px-2 text-xs text-slate-700 hover:bg-slate-100 hover:text-slate-950" disabled={saveDraftDisabled} onClick={onSaveDraft}>
<SaveIcon className="size-3.5" />
</Button>
<Button type="button" size="sm" className="h-7 shrink-0 bg-[#2575FC] px-2 text-xs hover:bg-[#1b63d8]" disabled={publishDisabled} onClick={onPublish}>
<SendIcon className="size-3.5" />
</Button>
</div>
</div>
)
}
@@ -1,425 +0,0 @@
"use client"
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"
import {
EditorRenderer,
FreeLayoutEditorProvider,
WorkflowDocument,
type WorkflowLineEntity,
WorkflowLinesManager,
WorkflowSelectService,
type WorkflowJSON,
type WorkflowNodeJSON,
type WorkflowPortEntity,
useClientContext,
useUndoRedo,
usePlaygroundTools,
useService,
} from "@flowgram.ai/free-layout-editor"
import type { AIWorkflowDefinition, AIWorkflowNodeSpec } from "@/lib/api/admin"
import { cn } from "@/lib/utils"
import { useFlowgramEditorProps } from "./flowgram-editor-provider"
import {
WorkflowBranchSelectionProvider,
type SelectedWorkflowBranch,
} from "./workflow-branch-selection"
import { WorkflowCanvasControls } from "./workflow-canvas-controls"
import { WorkflowConfigPanel } from "./workflow-config-sidebar"
import { WorkflowEditorStatus } from "./workflow-editor-status"
import { WorkflowEditorToolbar } from "./workflow-editor-toolbar"
import {
WorkflowPortAddProvider,
type WorkflowPortAddRequest,
} from "./workflow-port-add-context"
import {
WorkflowPortNodeMenu,
type WorkflowPortNodeMenuState,
} from "./workflow-port-node-menu"
import {
createWorkflowNodeFromSpec,
deleteWorkflowNode,
shouldClearWorkflowSelectionOnPointerDown,
updateWorkflowNodeData,
validateWorkflowDefinition,
type WorkflowNodeData,
} from "./workflow-utils"
export function WorkflowEditor({
definition,
nodeSpecs,
onDefinitionChange,
onRestoreDefault,
restoreDefaultDisabled = false,
historyDisabled = false,
onValidate,
validateDisabled = false,
onSaveDraft,
saveDraftDisabled = false,
onPublish,
publishDisabled = false,
toolbarExtra,
}: {
definition: AIWorkflowDefinition
nodeSpecs: AIWorkflowNodeSpec[]
onDefinitionChange: (definition: AIWorkflowDefinition) => void
onRestoreDefault?: () => void
restoreDefaultDisabled?: boolean
historyDisabled?: boolean
onValidate?: () => void
validateDisabled?: boolean
onSaveDraft?: () => void
saveDraftDisabled?: boolean
onPublish?: () => void
publishDisabled?: boolean
toolbarExtra?: ReactNode
}) {
const [localDefinition, setLocalDefinition] = useState(definition)
const [selectedNodeId, setSelectedNodeId] = useState("")
const [selectedBranch, setSelectedBranch] = useState<SelectedWorkflowBranch | null>(null)
const branchSelectAtRef = useRef(0)
const validation = useMemo(
() => validateWorkflowDefinition(localDefinition, nodeSpecs),
[localDefinition, nodeSpecs]
)
const editorProps = useFlowgramEditorProps({
definition: localDefinition,
nodeSpecs,
onDefinitionChange: (next) => {
setLocalDefinition(next)
onDefinitionChange(next)
},
})
const handleSelectBranch = useCallback(
(branch: SelectedWorkflowBranch | null) => {
if (!branch) {
setSelectedBranch(null)
return
}
branchSelectAtRef.current = Date.now()
setSelectedNodeId(branch.nodeId)
setSelectedBranch(branch)
},
[]
)
return (
<WorkflowBranchSelectionProvider
selectedBranch={selectedBranch}
onSelectBranch={handleSelectBranch}
>
<FreeLayoutEditorProvider {...editorProps}>
<WorkflowEditorInner
definition={localDefinition}
nodeSpecs={nodeSpecs}
selectedNodeId={selectedNodeId}
selectedBranch={selectedBranch}
validation={validation}
toolbarExtra={toolbarExtra}
onDefinitionChange={(next) => {
setLocalDefinition(next)
onDefinitionChange(next)
}}
onSelectNode={(nodeId) => {
setSelectedNodeId(nodeId)
if (!nodeId || Date.now() - branchSelectAtRef.current > 160) {
setSelectedBranch(null)
}
}}
onSelectBranch={handleSelectBranch}
historyDisabled={historyDisabled}
onRestoreDefault={onRestoreDefault}
restoreDefaultDisabled={restoreDefaultDisabled}
onValidate={onValidate}
validateDisabled={validateDisabled}
onSaveDraft={onSaveDraft}
saveDraftDisabled={saveDraftDisabled}
onPublish={onPublish}
publishDisabled={publishDisabled}
/>
</FreeLayoutEditorProvider>
</WorkflowBranchSelectionProvider>
)
}
function WorkflowEditorInner({
definition,
nodeSpecs,
selectedNodeId,
selectedBranch,
validation,
toolbarExtra,
onDefinitionChange,
onSelectNode,
onSelectBranch,
historyDisabled,
onRestoreDefault,
restoreDefaultDisabled,
onValidate,
validateDisabled,
onSaveDraft,
saveDraftDisabled,
onPublish,
publishDisabled,
}: {
definition: AIWorkflowDefinition
nodeSpecs: AIWorkflowNodeSpec[]
selectedNodeId: string
selectedBranch: SelectedWorkflowBranch | null
validation: ReturnType<typeof validateWorkflowDefinition>
toolbarExtra?: ReactNode
onDefinitionChange: (definition: AIWorkflowDefinition) => void
onSelectNode: (nodeId: string) => void
onSelectBranch: (branch: SelectedWorkflowBranch | null) => void
historyDisabled?: boolean
onRestoreDefault?: () => void
restoreDefaultDisabled?: boolean
onValidate?: () => void
validateDisabled?: boolean
onSaveDraft?: () => void
saveDraftDisabled?: boolean
onPublish?: () => void
publishDisabled?: boolean
}) {
const context = useClientContext()
const playgroundTools = usePlaygroundTools()
const undoRedo = useUndoRedo()
const workflowDocument = useService(WorkflowDocument)
const linesManager = useService(WorkflowLinesManager)
const selectService = useService(WorkflowSelectService)
const editorRootRef = useRef<HTMLDivElement>(null)
const [autoLayouting, setAutoLayouting] = useState(false)
const [nodeMenu, setNodeMenu] = useState<(
WorkflowPortNodeMenuState & {
sourcePort: WorkflowPortEntity
targetPort?: WorkflowPortEntity
line?: WorkflowLineEntity
}
) | null>(null)
const zoomPercent = `${Math.round(playgroundTools.zoom * 100)}%`
useEffect(() => {
const disposable = selectService.onSelectionChanged(() => {
const selectedNode = selectService.selectedNodes.length === 1
? selectService.selectedNodes[0]
: null
onSelectNode(selectedNode?.id ?? "")
})
return () => disposable.dispose()
}, [onSelectNode, selectService])
const emitCurrentDefinition = () => {
onDefinitionChange(context.document.toJSON() as AIWorkflowDefinition)
}
const undo = async () => {
await undoRedo.undo()
emitCurrentDefinition()
}
const redo = async () => {
await undoRedo.redo()
emitCurrentDefinition()
}
const openNodeMenuFromPort = useCallback((request: WorkflowPortAddRequest) => {
const rootRect = editorRootRef.current?.getBoundingClientRect()
setNodeMenu({
sourcePort: request.sourcePort,
targetPort: request.targetPort,
line: request.line,
x: rootRect ? request.event.clientX - rootRect.left + 10 : request.event.clientX,
y: rootRect ? request.event.clientY - rootRect.top - 10 : request.event.clientY,
})
}, [])
const addNodeFromPort = async (spec: AIWorkflowNodeSpec) => {
if (!nodeMenu) {
return
}
const sourcePort = nodeMenu.sourcePort
const nextNode = createWorkflowNodeFromSpec(
spec,
context.document.toJSON().nodes ?? definition.nodes,
nextNodePositionFromAddMenu(nodeMenu)
)
const created = workflowDocument.createWorkflowNodeByType(
spec.type,
nextNode.meta?.position,
nextNode as WorkflowNodeJSON
)
linesManager.createLine({
from: sourcePort.node.id,
fromPort: sourcePort.portID,
to: created.id,
toPort: "",
})
if (nodeMenu.targetPort) {
linesManager.createLine({
from: created.id,
fromPort: "",
to: nodeMenu.targetPort.node.id,
toPort: nodeMenu.targetPort.portID,
})
if (nodeMenu.line && !nodeMenu.line.disposed) {
nodeMenu.line.dispose()
}
}
setNodeMenu(null)
await selectService.selectNodeAndScrollToView(created)
onSelectNode(created.id)
emitCurrentDefinition()
}
const updateNodeData = (nodeId: string, data: WorkflowNodeData) => {
const next = updateWorkflowNodeData(definition, nodeId, data)
context.operation.fromJSON(next as WorkflowJSON)
onDefinitionChange(context.document.toJSON() as AIWorkflowDefinition)
}
const removeNode = (nodeId: string) => {
const next = deleteWorkflowNode(definition, nodeId)
context.operation.fromJSON(next as WorkflowJSON)
const nextSelectedNodeId = next.nodes[0]?.id ?? ""
onSelectNode(nextSelectedNodeId)
onDefinitionChange(context.document.toJSON() as AIWorkflowDefinition)
}
const autoLayout = async () => {
if (autoLayouting || definition.nodes.length < 2) {
return
}
setAutoLayouting(true)
try {
await playgroundTools.autoLayout({
enableAnimation: true,
animationDuration: 240,
disableFitView: true,
})
playgroundTools.fitView(true)
emitCurrentDefinition()
} finally {
setAutoLayouting(false)
}
}
const resetZoom = () => {
context.playground.config.updateConfig({
zoom: 1,
})
}
const closeConfigPanel = () => {
selectService.clear()
onSelectNode("")
onSelectBranch(null)
}
const clearSelectionFromCanvas = () => {
setNodeMenu(null)
closeConfigPanel()
}
return (
<div
ref={editorRootRef}
data-workflow-editor-root
className="relative isolate h-full min-h-0 w-full flex-1 overflow-hidden border bg-[var(--g-editor-background)]"
onPointerDownCapture={(event) => {
if (shouldClearWorkflowSelectionOnPointerDown(event.target)) {
clearSelectionFromCanvas()
}
}}
>
<div
data-workflow-preserve-selection
className={cn(
"absolute left-3 top-3 z-50 flex max-w-[calc(100%-1.5rem)] flex-col items-start gap-1.5",
selectedNodeId && "max-w-[calc(100%-25rem)]"
)}
>
<WorkflowEditorToolbar
toolbarExtra={toolbarExtra}
onUndo={() => void undo()}
undoDisabled={historyDisabled || !undoRedo.canUndo}
onRedo={() => void redo()}
redoDisabled={historyDisabled || !undoRedo.canRedo}
onRestoreDefault={onRestoreDefault}
restoreDefaultDisabled={restoreDefaultDisabled}
onValidate={onValidate}
validateDisabled={validateDisabled}
onSaveDraft={onSaveDraft}
saveDraftDisabled={saveDraftDisabled}
onPublish={onPublish}
publishDisabled={publishDisabled}
/>
</div>
<div data-workflow-preserve-selection className="absolute bottom-4 left-4 z-50">
<WorkflowCanvasControls
zoomPercent={zoomPercent}
onZoomIn={() => playgroundTools.zoomin(true)}
onZoomOut={() => playgroundTools.zoomout(true)}
onResetZoom={resetZoom}
onFitView={() => playgroundTools.fitView(true)}
onAutoLayout={() => void autoLayout()}
autoLayoutDisabled={autoLayouting || definition.nodes.length < 2}
/>
</div>
<div data-workflow-preserve-selection className="absolute bottom-4 right-4 z-50">
<WorkflowEditorStatus
validation={validation}
nodeCount={definition.nodes.length}
edgeCount={definition.edges.length}
/>
</div>
<WorkflowPortAddProvider onRequestAdd={openNodeMenuFromPort}>
<EditorRenderer className="h-full w-full" />
</WorkflowPortAddProvider>
<WorkflowPortNodeMenu
open={Boolean(nodeMenu)}
position={nodeMenu}
nodeSpecs={nodeSpecs}
onSelect={(spec) => void addNodeFromPort(spec)}
onClose={() => setNodeMenu(null)}
/>
<WorkflowConfigPanel
definition={definition}
nodeSpecs={nodeSpecs}
selectedNodeId={selectedNodeId}
selectedBranch={selectedBranch}
onClose={closeConfigPanel}
onChangeNodeData={updateNodeData}
onDeleteNode={removeNode}
/>
</div>
)
}
function nextNodePositionFromAddMenu(
menu: WorkflowPortNodeMenuState & {
sourcePort: WorkflowPortEntity
targetPort?: WorkflowPortEntity
line?: WorkflowLineEntity
}
) {
if (menu.line && !menu.line.disposed) {
return {
x: menu.line.center.labelX,
y: menu.line.center.labelY - 40,
}
}
return {
x: menu.sourcePort.point.x + 120,
y: menu.sourcePort.point.y - 40,
}
}
@@ -1,51 +0,0 @@
"use client"
import type { LineRenderProps } from "@flowgram.ai/free-lines-plugin"
import { PlusIcon } from "lucide-react"
import { usePlayground } from "@flowgram.ai/free-layout-editor"
import { useWorkflowPortAdd } from "./workflow-port-add-context"
export function WorkflowLineAddButton({
line,
selected,
hovered,
color,
}: LineRenderProps) {
const playground = usePlayground()
const requestPortAdd = useWorkflowPortAdd()
const { fromPort, toPort } = line
const visible = !line.disposed && !playground.config.readonly && (selected || hovered)
if (!visible) {
return null
}
return (
<button
type="button"
className="absolute flex size-6 items-center justify-center rounded-full border border-white bg-white text-[#2575FC] shadow-sm transition-transform" // hover:scale-105
style={{
transform: `translate(-50%, -50%) translate(${line.center.labelX}px, ${line.center.labelY}px)`,
color,
pointerEvents: "all",
}}
aria-label="添加节点"
data-line-id={line.id}
onClick={(event) => {
event.stopPropagation()
if (!fromPort || !toPort) {
return
}
requestPortAdd?.({
sourcePort: fromPort,
targetPort: toPort,
line,
event,
})
}}
>
<PlusIcon className="size-3.5" strokeWidth={2.4} />
</button>
)
}
@@ -1,41 +0,0 @@
import type { ReactNode } from "react"
import { cn } from "@/lib/utils"
import { WorkflowNodeIcon } from "./workflow-node-icon"
export function WorkflowNodeCard({
title,
icon,
selected,
children,
}: {
title: string
icon: string
selected: boolean
children?: ReactNode
}) {
return (
<div
data-workflow-preserve-selection
className={cn(
"group relative rounded-lg bg-[#FFFFFF] p-0.5 transition-all",
"w-[242px]",
selected
? "border-[var(--g-selection-background)] shadow-[0_8px_24px_rgba(20,24,38,0.14)]"
: "border-border/80 shadow-sm hover:border-border hover:shadow-md"
)}
>
<div className="overflow-visible rounded-lg border border-transparent bg-[#FFFFFF]">
<div className="flex min-h-12 items-center gap-2 px-3 py-2.5">
<WorkflowNodeIcon icon={icon} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold leading-5 text-foreground">
{title}
</div>
</div>
</div>
{children ? <div className="border-t bg-muted/20 px-3 py-2.5">{children}</div> : null}
</div>
</div>
)
}
@@ -1,29 +0,0 @@
import { cn } from "@/lib/utils"
import { FileTextIcon, type LucideIcon } from "lucide-react"
import * as LucideIcons from "lucide-react"
const lucideIconComponents = LucideIcons as unknown as Record<string, LucideIcon>
export function WorkflowNodeIcon({
icon,
size = "md",
className,
}: {
icon?: string
size?: "sm" | "md"
className?: string
}) {
const Icon = icon ? lucideIconComponents[icon] ?? FileTextIcon : FileTextIcon
return (
<span
className={cn(
"flex shrink-0 items-center justify-center rounded-lg bg-[#2575FC] text-white shadow-sm",
size === "md" ? "size-7" : "size-6",
className
)}
>
<Icon className={size === "md" ? "size-4" : "size-3.5"} />
</span>
)
}
@@ -1,32 +0,0 @@
"use client"
import { createContext, useContext } from "react"
import type { WorkflowLineEntity, WorkflowPortEntity } from "@flowgram.ai/free-layout-editor"
export type WorkflowPortAddRequest = {
sourcePort: WorkflowPortEntity
targetPort?: WorkflowPortEntity
line?: WorkflowLineEntity
event: React.MouseEvent
}
const WorkflowPortAddContext = createContext<((request: WorkflowPortAddRequest) => void) | null>(null)
export function WorkflowPortAddProvider({
onRequestAdd,
children,
}: {
onRequestAdd: (request: WorkflowPortAddRequest) => void
children: React.ReactNode
}) {
return (
<WorkflowPortAddContext.Provider value={onRequestAdd}>
{children}
</WorkflowPortAddContext.Provider>
)
}
export function useWorkflowPortAdd() {
return useContext(WorkflowPortAddContext)
}
@@ -1,104 +0,0 @@
"use client"
import { useEffect, useMemo, useRef } from "react"
import { ScrollArea } from "@/components/ui/scroll-area"
import type { AIWorkflowNodeSpec } from "@/lib/api/admin"
import { WorkflowNodeIcon } from "./workflow-node-icon"
export type WorkflowPortNodeMenuState = {
x: number
y: number
}
export function WorkflowPortNodeMenu({
open,
position,
nodeSpecs,
onSelect,
onClose,
}: {
open: boolean
position: WorkflowPortNodeMenuState | null
nodeSpecs: AIWorkflowNodeSpec[]
onSelect: (spec: AIWorkflowNodeSpec) => void
onClose: () => void
}) {
const menuRef = useRef<HTMLDivElement>(null)
const insertableNodeSpecs = useMemo(
() => nodeSpecs.filter((spec) => spec.type !== "start"),
[nodeSpecs]
)
useEffect(() => {
if (!open) {
return
}
const closeOnPointerDown = (event: PointerEvent) => {
const target = event.target
if (target instanceof Node && menuRef.current?.contains(target)) {
return
}
onClose()
}
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose()
}
}
window.addEventListener("pointerdown", closeOnPointerDown)
window.addEventListener("keydown", closeOnEscape)
return () => {
window.removeEventListener("pointerdown", closeOnPointerDown)
window.removeEventListener("keydown", closeOnEscape)
}
}, [onClose, open])
if (!open || !position) {
return null
}
return (
<div
ref={menuRef}
data-workflow-preserve-selection
className="pointer-events-auto absolute z-[80] w-64 overflow-hidden rounded-lg border border-slate-200 bg-white py-1.5 shadow-[0_14px_35px_rgba(15,23,42,0.16)]"
style={{
left: position.x,
top: position.y,
}}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="border-b border-slate-100 px-3 pb-2 pt-1 text-xs font-medium text-slate-500">
</div>
<ScrollArea className="h-80 max-h-[min(20rem,calc(100vh-8rem))]">
<div className="p-1.5">
{insertableNodeSpecs.map((spec) => (
<button
key={spec.type}
type="button"
className="flex w-full items-start gap-2 rounded-md px-2 py-2 text-left text-sm transition-colors hover:bg-slate-50"
onClick={(event) => {
event.stopPropagation()
onSelect(spec)
}}
>
<WorkflowNodeIcon icon={spec.icon} size="sm" className="mt-0.5" />
<span className="min-w-0">
<span className="block truncate font-medium text-slate-900">
{spec.title || spec.type}
</span>
{spec.description ? (
<span className="line-clamp-2 text-xs text-slate-500">
{spec.description}
</span>
) : null}
</span>
</button>
))}
</div>
</ScrollArea>
</div>
)
}
@@ -1,488 +0,0 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { readFile } from "node:fs/promises"
import vm from "node:vm"
import ts from "typescript"
function plain(value) {
return JSON.parse(JSON.stringify(value))
}
async function loadModule() {
const source = await readFile(new URL("./workflow-utils.ts", import.meta.url), "utf8")
const compiled = ts.transpileModule(source, {
compilerOptions: {
target: ts.ScriptTarget.ES2017,
module: ts.ModuleKind.CommonJS,
},
fileName: "workflow-utils.ts",
})
const sandbox = {
exports: {},
module: { exports: {} },
}
sandbox.exports = sandbox.module.exports
vm.runInNewContext(compiled.outputText, sandbox)
return sandbox.module.exports
}
function workflowNode(id, type, position = { x: 0, y: 0 }, data = {}) {
return {
id,
type,
meta: { position },
data: {
title: type,
config: {},
inputsValues: {},
...data,
},
}
}
function workflowEdge(sourceNodeID, targetNodeID, extra = {}) {
return {
sourceNodeID,
targetNodeID,
...extra,
}
}
describe("FlowGram value helpers", () => {
it("creates and reads reference values", async () => {
const { createRefValue, isRefValue, refField, refNodeId } = await loadModule()
const value = createRefValue("start_1", "userMessage")
assert.deepEqual(plain(value), { type: "ref", content: ["start_1", "userMessage"] })
assert.equal(isRefValue(value), true)
assert.equal(refNodeId(value), "start_1")
assert.equal(refField(value), "userMessage")
assert.equal(isRefValue({ type: "constant", content: "hello" }), false)
})
})
describe("validateWorkflowDefinition", () => {
it("rejects a workflow without exactly one start node", async () => {
const { validateWorkflowDefinition } = await loadModule()
const result = validateWorkflowDefinition({
schemaVersion: 2,
nodes: [workflowNode("end_1", "end")],
edges: [],
})
assert.equal(result.valid, false)
assert.match(result.errors.join("\n"), /exactly one start/)
})
it("rejects dangling FlowGram edges", async () => {
const { validateWorkflowDefinition } = await loadModule()
const result = validateWorkflowDefinition({
schemaVersion: 2,
nodes: [workflowNode("start_1", "start"), workflowNode("end_1", "end")],
edges: [workflowEdge("start_1", "missing_1")],
})
assert.equal(result.valid, false)
assert.match(result.errors.join("\n"), /target node does not exist: missing_1/)
})
it("rejects missing required inputs from node specs", async () => {
const { validateWorkflowDefinition } = await loadModule()
const result = validateWorkflowDefinition(
{
schemaVersion: 2,
nodes: [
workflowNode("start_1", "start"),
workflowNode("reply_1", "send_reply", { x: 240, y: 0 }, { title: "发送回复" }),
workflowNode("end_1", "end", { x: 480, y: 0 }),
],
edges: [workflowEdge("start_1", "reply_1"), workflowEdge("reply_1", "end_1")],
},
[
{
type: "send_reply",
title: "发送回复",
inputSchema: [{ name: "replyText", label: "回复内容", type: "string", required: true }],
},
]
)
assert.equal(result.valid, false)
assert.match(result.errors.join("\n"), /发送回复 missing required input: 回复内容/)
})
it("accepts a valid schema v2 workflow", async () => {
const { createRefValue, validateWorkflowDefinition } = await loadModule()
const result = validateWorkflowDefinition(
{
schemaVersion: 2,
nodes: [
workflowNode("start_1", "start"),
workflowNode("reply_1", "send_reply", { x: 240, y: 0 }, {
inputsValues: { replyText: createRefValue("start_1", "userMessage") },
}),
workflowNode("end_1", "end", { x: 480, y: 0 }),
],
edges: [workflowEdge("start_1", "reply_1"), workflowEdge("reply_1", "end_1")],
},
[
{
type: "send_reply",
inputSchema: [{ name: "replyText", type: "string", required: true }],
},
]
)
assert.deepEqual(plain(result), { valid: true, errors: [] })
})
it("rejects knowledge retrieve nodes without node knowledge bases", async () => {
const { createRefValue, validateWorkflowDefinition } = await loadModule()
const result = validateWorkflowDefinition({
schemaVersion: 2,
nodes: [
workflowNode("start_1", "start"),
workflowNode("retrieve_1", "knowledge_retrieve", { x: 240, y: 0 }, {
title: "知识检索",
inputsValues: { query: createRefValue("start_1", "userMessage") },
config: { knowledgeBaseIds: [] },
}),
workflowNode("end_1", "end", { x: 480, y: 0 }),
],
edges: [workflowEdge("start_1", "retrieve_1"), workflowEdge("retrieve_1", "end_1")],
})
assert.equal(result.valid, false)
assert.match(result.errors.join("\n"), /需要选择至少一个知识库/)
})
})
describe("createWorkflowNodeFromSpec", () => {
it("creates a FlowGram schema v2 node with default inputs", async () => {
const { createWorkflowNodeFromSpec } = await loadModule()
const node = createWorkflowNodeFromSpec(
{
type: "llm_reply",
title: "AI 回复",
defaultInputs: {
userMessage: { type: "ref", content: ["start_1", "userMessage"] },
},
},
[{ id: "llm_reply_1" }],
{ x: 120, y: 240 }
)
assert.deepEqual(plain(node), {
id: "llm_reply_2",
type: "llm_reply",
meta: { position: { x: 120, y: 240 } },
data: {
title: "AI 回复",
config: {},
inputsValues: {
userMessage: { type: "ref", content: ["start_1", "userMessage"] },
},
},
})
})
})
describe("getAvailableVariables", () => {
it("returns upstream output variables in dependency order", async () => {
const { getAvailableVariables } = await loadModule()
const variables = getAvailableVariables(
{
schemaVersion: 2,
nodes: [
workflowNode("start_1", "start", { x: 0, y: 0 }, { title: "开始" }),
workflowNode("retrieve_1", "knowledge_retrieve", { x: 240, y: 0 }, { title: "知识检索" }),
workflowNode("reply_1", "llm_reply", { x: 480, y: 0 }, { title: "AI 回复" }),
workflowNode("end_1", "end", { x: 720, y: 0 }),
],
edges: [
workflowEdge("start_1", "retrieve_1"),
workflowEdge("retrieve_1", "reply_1"),
workflowEdge("reply_1", "end_1"),
],
},
"reply_1",
[
{
type: "start",
outputSchema: [{ name: "userMessage", label: "用户消息", type: "string", description: "input" }],
},
{
type: "knowledge_retrieve",
outputSchema: [{ name: "documents", label: "文档", type: "array<object>", description: "docs" }],
},
]
)
assert.deepEqual(plain(variables), [
{
nodeId: "start_1",
nodeName: "开始",
field: "userMessage",
label: "用户消息",
type: "string",
description: "input",
},
{
nodeId: "retrieve_1",
nodeName: "知识检索",
field: "documents",
label: "文档",
type: "array<object>",
description: "docs",
},
])
})
})
describe("workflow variable display helpers", () => {
it("builds business-first variable options with technical details", async () => {
const { buildVariableOption } = await loadModule()
assert.deepEqual(plain(buildVariableOption({
nodeId: "start_1",
nodeName: "开始",
field: "userMessage",
label: "用户消息",
type: "string",
description: "客户本轮发送的消息内容",
})), {
value: "start_1.userMessage",
label: "开始 / 用户消息",
subtitle: "start_1.userMessage · string",
description: "客户本轮发送的消息内容",
})
})
it("builds variable spec display rows for readonly node outputs", async () => {
const { buildVariableSpecDisplay } = await loadModule()
assert.deepEqual(plain(buildVariableSpecDisplay({
name: "replyText",
label: "回复内容",
type: "string",
description: "发送给客户的最终回复文本",
})), {
key: "replyText",
label: "回复内容",
subtitle: "replyText · string",
description: "发送给客户的最终回复文本",
})
})
})
describe("workflow branch interaction helpers", () => {
it("detects branch row action targets inside buttons", async () => {
const { isBranchRowActionTarget } = await loadModule()
assert.equal(isBranchRowActionTarget({
closest(selector) {
return selector === "button" ? {} : null
},
}), true)
assert.equal(isBranchRowActionTarget({
closest() {
return null
},
}), false)
})
it("clears workflow selection only when clicking outside preserved regions", async () => {
const { shouldClearWorkflowSelectionOnPointerDown } = await loadModule()
assert.equal(shouldClearWorkflowSelectionOnPointerDown({
closest(selector) {
return selector === "[data-workflow-preserve-selection]" ? {} : null
},
}), false)
assert.equal(shouldClearWorkflowSelectionOnPointerDown({
closest() {
return null
},
}), true)
})
})
describe("workflow definition mutations", () => {
it("updates node data without changing unrelated nodes", async () => {
const { updateWorkflowNodeData } = await loadModule()
const definition = {
schemaVersion: 2,
nodes: [
workflowNode("start_1", "start"),
workflowNode("reply_1", "send_reply", { x: 240, y: 0 }),
],
edges: [workflowEdge("start_1", "reply_1")],
}
const next = updateWorkflowNodeData(definition, "reply_1", {
title: "发送回复",
config: { staticReply: "hello" },
inputsValues: {},
})
assert.equal(next.nodes[0].data.title, "start")
assert.deepEqual(plain(next.nodes[1].data), {
title: "发送回复",
config: { staticReply: "hello" },
inputsValues: {},
})
})
it("deletes non-start nodes and related edges while keeping start protected", async () => {
const { deleteWorkflowNode } = await loadModule()
const definition = {
schemaVersion: 2,
nodes: [
workflowNode("start_1", "start"),
workflowNode("reply_1", "send_reply", { x: 240, y: 0 }),
workflowNode("end_1", "end", { x: 480, y: 0 }),
],
edges: [workflowEdge("start_1", "reply_1"), workflowEdge("reply_1", "end_1")],
}
const next = deleteWorkflowNode(definition, "reply_1")
assert.deepEqual(next.nodes.map((node) => node.id), ["start_1", "end_1"])
assert.deepEqual(next.edges, [])
const protectedDefinition = deleteWorkflowNode(definition, "start_1")
assert.deepEqual(protectedDefinition, definition)
const withoutEnd = deleteWorkflowNode(definition, "end_1")
assert.deepEqual(withoutEnd.nodes.map((node) => node.id), ["start_1", "reply_1"])
assert.deepEqual(withoutEnd.edges, [workflowEdge("start_1", "reply_1")])
})
it("upserts and deletes condition branches in node config", async () => {
const { deleteConditionBranch, upsertConditionBranch } = await loadModule()
const definition = {
schemaVersion: 2,
nodes: [
workflowNode("condition_1", "condition", { x: 240, y: 0 }, {
config: {
branches: [{ id: "default", name: "默认", targetNodeId: "end_1", default: true }],
},
}),
workflowNode("end_1", "end", { x: 480, y: 0 }),
],
edges: [
workflowEdge("condition_1", "end_1", { sourcePortID: "default" }),
],
}
const updated = upsertConditionBranch(definition, "condition_1", {
id: "vip",
name: "VIP",
targetNodeId: "end_1",
condition: {
left: { type: "ref", content: ["start_1", "priority"] },
operator: "eq",
right: "vip",
},
})
assert.deepEqual(plain(updated.nodes[0].data.config.branches.map((branch) => branch.id)), ["default", "vip"])
updated.edges.push(workflowEdge("condition_1", "end_1", { sourcePortID: "vip" }))
const deleted = deleteConditionBranch(updated, "condition_1", "vip")
assert.deepEqual(plain(deleted.nodes[0].data.config.branches.map((branch) => branch.id)), ["default"])
assert.deepEqual(plain(deleted.edges.map((edge) => edge.sourcePortID)), ["default"])
})
it("matches condition branch lines by source node and branch port", async () => {
const { isConditionBranchEdge } = await loadModule()
assert.equal(isConditionBranchEdge(
workflowEdge("condition_1", "vip_reply", { sourcePortID: "vip" }),
"condition_1",
"vip"
), true)
assert.equal(isConditionBranchEdge(
workflowEdge("condition_1", "default_reply", { sourcePortID: "default" }),
"condition_1",
"vip"
), false)
assert.equal(isConditionBranchEdge(
workflowEdge("other_condition", "vip_reply", { sourcePortID: "vip" }),
"condition_1",
"vip"
), false)
})
it("adds FlowGram source ports for condition edges without removing existing lines", async () => {
const { normalizeConditionPortsForFlowgram } = await loadModule()
const definition = {
schemaVersion: 2,
nodes: [
workflowNode("condition_1", "condition", { x: 240, y: 0 }, {
config: {
branches: [
{ id: "vip", name: "VIP", targetNodeId: "vip_reply", condition: { operator: "eq" } },
{ id: "default", name: "默认", targetNodeId: "normal_reply", default: true },
],
},
}),
workflowNode("vip_reply", "llm_reply", { x: 520, y: 0 }),
workflowNode("normal_reply", "llm_reply", { x: 520, y: 120 }),
],
edges: [
workflowEdge("condition_1", "vip_reply"),
workflowEdge("condition_1", "normal_reply"),
],
}
const next = normalizeConditionPortsForFlowgram(definition)
assert.deepEqual(plain(next.nodes[0].data.portKeys), ["vip", "default"])
assert.deepEqual(plain(next.nodes[0].data.ports), ["vip", "default"])
assert.deepEqual(plain(next.edges), [
workflowEdge("condition_1", "vip_reply", { sourcePortID: "vip" }),
workflowEdge("condition_1", "normal_reply", { sourcePortID: "default" }),
])
})
it("syncs branch targets from condition source ports while preserving unrelated edges", async () => {
const { syncConditionBranchTargetsFromEdges } = await loadModule()
const definition = {
schemaVersion: 2,
nodes: [
workflowNode("condition_1", "condition", { x: 240, y: 0 }, {
config: {
branches: [
{ id: "vip", name: "VIP", targetNodeId: "", condition: { operator: "eq" } },
{ id: "default", name: "默认", targetNodeId: "", default: true },
],
},
portKeys: ["vip", "default"],
ports: ["vip", "default"],
}),
workflowNode("vip_reply", "llm_reply", { x: 520, y: 0 }),
workflowNode("normal_reply", "llm_reply", { x: 520, y: 120 }),
],
edges: [
workflowEdge("condition_1", "vip_reply", { sourcePortID: "vip" }),
workflowEdge("condition_1", "normal_reply", { sourcePortID: "default" }),
workflowEdge("vip_reply", "normal_reply"),
],
}
const next = syncConditionBranchTargetsFromEdges(definition)
assert.equal(next.nodes[0].data.config.branches[0].targetNodeId, "vip_reply")
assert.equal(next.nodes[0].data.config.branches[1].targetNodeId, "normal_reply")
assert.equal(next.edges.length, 3)
})
})
@@ -1,557 +0,0 @@
import type { AIWorkflowDefinition, AIWorkflowNodeSpec, AIWorkflowValue } from "@/lib/api/admin"
export type WorkflowNodePosition = {
x: number
y: number
}
export type WorkflowVariableType =
| "string"
| "number"
| "integer"
| "boolean"
| "object"
| "array<string>"
| "array<int>"
| "array<object>"
| "any"
export type WorkflowVariableValueOption = {
value: unknown
label: string
description?: string
}
export type WorkflowVariableSpec = {
name: string
label?: string
type: WorkflowVariableType
required?: boolean
description?: string
operators?: string[]
valueOptions?: WorkflowVariableValueOption[]
}
export type WorkflowValue = AIWorkflowValue
export type WorkflowVariableSelector = Extract<AIWorkflowValue, { type: "ref" }>
export type WorkflowCondition = {
expression?: string
left?: WorkflowValue
operator?: string
right?: unknown
}
export type WorkflowConditionBranch = {
id: string
name?: string
targetNodeId: string
condition?: WorkflowCondition
default?: boolean
}
export type WorkflowNodeConfig = Record<string, unknown> & {
branches?: WorkflowConditionBranch[]
}
export type WorkflowVariableRef = {
nodeId: string
nodeName: string
field: string
label?: string
type: string
description: string
operators?: string[]
valueOptions?: WorkflowVariableValueOption[]
}
export type WorkflowVariableOption = {
value: string
label: string
subtitle: string
description: string
}
export type WorkflowVariableSpecDisplay = {
key: string
label: string
subtitle: string
description: string
}
export type WorkflowNodeSpec = AIWorkflowNodeSpec
export type WorkflowDraftValidation = {
valid: boolean
errors: string[]
}
export type WorkflowNode = AIWorkflowDefinition["nodes"][number]
export type WorkflowNodeData = WorkflowNode["data"]
export type WorkflowEdge = AIWorkflowDefinition["edges"][number]
export function createRefValue(nodeId: string, field: string): WorkflowVariableSelector {
return { type: "ref", content: [nodeId, field] }
}
export function isRefValue(value: WorkflowValue | undefined): value is WorkflowVariableSelector {
return value?.type === "ref" && Array.isArray(value.content) && value.content.length >= 2
}
export function refNodeId(value: WorkflowValue | undefined): string {
return isRefValue(value) ? value.content[0] : ""
}
export function refField(value: WorkflowValue | undefined): string {
return isRefValue(value) ? value.content[1] : ""
}
export function buildVariableOption(variable: WorkflowVariableRef): WorkflowVariableOption {
const ref = `${variable.nodeId}.${variable.field}`
const label = `${variable.nodeName} / ${variable.label || variable.field}`
return {
value: ref,
label,
subtitle: [ref, variable.type].filter(Boolean).join(" · "),
description: variable.description,
}
}
export function buildVariableSpecDisplay(variable: WorkflowVariableSpec): WorkflowVariableSpecDisplay {
return {
key: variable.name,
label: variable.label || variable.name,
subtitle: [variable.name, variable.type].filter(Boolean).join(" · "),
description: variable.description || "",
}
}
export function isBranchRowActionTarget(target: EventTarget | null): boolean {
const maybeElement = target as { closest?: (selector: string) => Element | null } | null
return typeof maybeElement?.closest === "function" && maybeElement.closest("button") !== null
}
export function shouldClearWorkflowSelectionOnPointerDown(target: EventTarget | null): boolean {
const maybeElement = target as { closest?: (selector: string) => Element | null } | null
if (typeof maybeElement?.closest !== "function") {
return false
}
return maybeElement.closest("[data-workflow-preserve-selection]") === null
}
export function getNodeTitle(
node: AIWorkflowDefinition["nodes"][number] | undefined,
specs: AIWorkflowNodeSpec[] = []
) {
if (!node) {
return ""
}
return node.data?.title || specs.find((item) => item.type === node.type)?.title || node.type || node.id
}
export function validateWorkflowDefinition(
definition: AIWorkflowDefinition,
nodeSpecs: AIWorkflowNodeSpec[] = []
): WorkflowDraftValidation {
const errors: string[] = []
const nodes = definition.nodes ?? []
const edges = definition.edges ?? []
const startNodes = nodes.filter((node) => node.type === "start")
const endNodes = nodes.filter((node) => node.type === "end")
if (startNodes.length !== 1) {
errors.push("workflow must contain exactly one start node")
}
if (endNodes.length === 0) {
errors.push("workflow must contain at least one end node")
}
const nodeIds = new Set<string>()
for (const node of nodes) {
if (!node.id?.trim()) {
errors.push("node id is required")
continue
}
if (nodeIds.has(node.id)) {
errors.push(`duplicate node id: ${node.id}`)
}
nodeIds.add(node.id)
if (!node.type?.trim()) {
errors.push(`node type is required: ${node.id}`)
}
if (node.type === "knowledge_retrieve") {
const config = normalizeNodeConfig(node.data?.config)
const knowledgeBaseIds = Array.isArray(config.knowledgeBaseIds) ? config.knowledgeBaseIds : []
if (knowledgeBaseIds.length === 0) {
errors.push(`${getNodeTitle(node, nodeSpecs)} 需要选择至少一个知识库`)
} else if (knowledgeBaseIds.some((id) => Number(id) <= 0)) {
errors.push(`${getNodeTitle(node, nodeSpecs)} 知识库 ID 必须大于 0`)
}
}
}
for (const edge of edges) {
if (!nodeIds.has(edge.sourceNodeID)) {
errors.push(`edge source node does not exist: ${edge.sourceNodeID}`)
}
if (!nodeIds.has(edge.targetNodeID)) {
errors.push(`edge target node does not exist: ${edge.targetNodeID}`)
}
}
const specByType = new Map(nodeSpecs.map((spec) => [spec.type, spec]))
for (const node of nodes) {
const spec = specByType.get(node.type)
if (!spec) {
continue
}
const inputsValues = node.data?.inputsValues ?? {}
for (const input of spec.inputSchema ?? []) {
if (input.required && !inputsValues[input.name]) {
errors.push(`${getNodeTitle(node, nodeSpecs)} missing required input: ${input.label || input.name}`)
}
}
}
return { valid: errors.length === 0, errors }
}
export function createWorkflowNodeFromSpec(
spec: AIWorkflowNodeSpec,
existingNodes: Pick<AIWorkflowDefinition["nodes"][number], "id">[],
position: WorkflowNodePosition
): AIWorkflowDefinition["nodes"][number] {
const id = uniqueNodeId(existingNodes, spec.type)
const defaultConfig = spec.type === "condition"
? { branches: [{ id: "default", name: "默认分支", targetNodeId: "", default: true }] }
: {}
return {
id,
type: spec.type,
meta: { position },
data: {
title: spec.title || spec.type,
config: defaultConfig,
inputsValues: spec.defaultInputs ?? {},
},
}
}
export function updateWorkflowNodeData(
definition: AIWorkflowDefinition,
nodeId: string,
data: WorkflowNodeData
): AIWorkflowDefinition {
return {
...definition,
nodes: definition.nodes.map((node) => (
node.id === nodeId ? { ...node, data } : node
)),
}
}
export function deleteWorkflowNode(
definition: AIWorkflowDefinition,
nodeId: string
): AIWorkflowDefinition {
const node = definition.nodes.find((item) => item.id === nodeId)
if (!node || node.type === "start") {
return definition
}
return {
...definition,
nodes: definition.nodes.filter((item) => item.id !== nodeId),
edges: definition.edges.filter((edge) => (
edge.sourceNodeID !== nodeId && edge.targetNodeID !== nodeId
)),
}
}
export function upsertConditionBranch(
definition: AIWorkflowDefinition,
nodeId: string,
branch: WorkflowConditionBranch
): AIWorkflowDefinition {
const node = definition.nodes.find((item) => item.id === nodeId)
if (!node) {
return definition
}
const config = normalizeNodeConfig(node.data?.config)
const branches = config.branches ?? []
const nextBranches = branches.some((item) => item.id === branch.id)
? branches.map((item) => (item.id === branch.id ? branch : item))
: [...branches, branch]
return updateWorkflowNodeData(definition, nodeId, {
...(node.data ?? {}),
config: { ...config, branches: nextBranches },
})
}
export function deleteConditionBranch(
definition: AIWorkflowDefinition,
nodeId: string,
branchId: string
): AIWorkflowDefinition {
const node = definition.nodes.find((item) => item.id === nodeId)
if (!node) {
return definition
}
const config = normalizeNodeConfig(node.data?.config)
const nextDefinition = updateWorkflowNodeData(definition, nodeId, {
...(node.data ?? {}),
config: {
...config,
branches: (config.branches ?? []).filter((branch) => branch.id !== branchId),
},
})
return {
...nextDefinition,
edges: nextDefinition.edges.filter((edge) => !isConditionBranchEdge(edge, nodeId, branchId)),
}
}
export function isConditionBranchEdge(
edge: { sourceNodeID?: unknown; sourcePortID?: unknown },
nodeId: string,
branchId: string
): boolean {
return String(edge.sourceNodeID ?? "") === nodeId && String(edge.sourcePortID ?? "") === branchId
}
export function normalizeConditionPortsForFlowgram(
definition: AIWorkflowDefinition
): AIWorkflowDefinition {
const branchIdsByNodeId = new Map<string, Set<string>>()
for (const node of definition.nodes) {
if (node.type !== "condition") {
continue
}
const config = normalizeNodeConfig(node.data?.config)
const branches = ensureConditionBranches(config.branches ?? [])
branchIdsByNodeId.set(node.id, new Set(branches.map((branch) => branch.id)))
}
return {
...definition,
nodes: definition.nodes.map((node) => {
if (node.type !== "condition") {
return node
}
const config = normalizeNodeConfig(node.data?.config)
const branches = ensureConditionBranches(config.branches ?? [])
return {
...node,
data: {
...(node.data ?? {}),
config: { ...config, branches },
portKeys: branches.map((branch) => branch.id),
ports: branches.map((branch) => branch.id),
},
}
}),
edges: definition.edges.filter((edge) => {
if (!edge.sourcePortID) {
return true
}
const branchIds = branchIdsByNodeId.get(edge.sourceNodeID)
return !branchIds || branchIds.has(edge.sourcePortID)
}).map((edge) => {
const source = definition.nodes.find((node) => node.id === edge.sourceNodeID)
if (!source || source.type !== "condition" || edge.sourcePortID) {
return edge
}
const branch = findConditionBranchForTarget(source, edge.targetNodeID)
return branch ? { ...edge, sourcePortID: branch.id } : edge
}),
}
}
export function syncConditionBranchTargetsFromEdges(
definition: AIWorkflowDefinition
): AIWorkflowDefinition {
return {
...definition,
nodes: definition.nodes.map((node) => {
if (node.type !== "condition") {
return node
}
const config = normalizeNodeConfig(node.data?.config)
const branches = ensureConditionBranches(config.branches ?? [])
const nextBranches = branches.map((branch) => {
const edge = definition.edges.find((item) => (
item.sourceNodeID === node.id && item.sourcePortID === branch.id
))
return edge ? { ...branch, targetNodeId: edge.targetNodeID } : branch
})
return {
...node,
data: {
...(node.data ?? {}),
config: { ...config, branches: nextBranches },
portKeys: nextBranches.map((branch) => branch.id),
ports: nextBranches.map((branch) => branch.id),
},
}
}),
}
}
export function normalizeNodeConfig(config: unknown): WorkflowNodeConfig {
if (!config || typeof config !== "object" || Array.isArray(config)) {
return {}
}
const record = config as Record<string, unknown>
const branches = Array.isArray(record.branches)
? record.branches
.map(normalizeConditionBranch)
.filter((branch): branch is WorkflowConditionBranch => branch !== null)
: undefined
return {
...record,
...(branches ? { branches } : {}),
} as WorkflowNodeConfig
}
function ensureConditionBranches(branches: WorkflowConditionBranch[]) {
if (branches.some((branch) => branch.default)) {
return orderConditionBranches(branches)
}
return orderConditionBranches([
...branches,
{ id: "default", name: "默认分支", targetNodeId: "", default: true },
])
}
function orderConditionBranches(branches: WorkflowConditionBranch[]) {
return [
...branches.filter((branch) => !branch.default),
...branches.filter((branch) => branch.default).slice(0, 1),
]
}
function findConditionBranchForTarget(
node: AIWorkflowDefinition["nodes"][number],
targetNodeId: string
) {
const branches = ensureConditionBranches(normalizeNodeConfig(node.data?.config).branches ?? [])
return branches.find((branch) => branch.targetNodeId === targetNodeId)
}
export function createConditionBranchID(existingBranches: WorkflowConditionBranch[]) {
const existingIDs = new Set(existingBranches.map((branch) => branch.id))
for (let index = 1; index < 10000; index++) {
const id = `branch_${index}`
if (!existingIDs.has(id)) {
return id
}
}
return `branch_${Date.now()}`
}
export function getAvailableVariables(
definition: AIWorkflowDefinition,
nodeId: string,
nodeSpecs: AIWorkflowNodeSpec[]
): WorkflowVariableRef[] {
const ancestorIds = collectAncestorNodeIds(definition, nodeId)
const specByType = new Map(nodeSpecs.map((spec) => [spec.type, spec]))
const ret: WorkflowVariableRef[] = []
for (const id of ancestorIds) {
const node = definition.nodes.find((item) => item.id === id)
if (!node) {
continue
}
const spec = specByType.get(node.type)
for (const output of spec?.outputSchema ?? []) {
ret.push({
nodeId: node.id,
nodeName: getNodeTitle(node, nodeSpecs),
field: output.name,
label: output.label,
type: output.type,
description: output.description || "",
operators: output.operators,
valueOptions: output.valueOptions,
})
}
}
return ret
}
function collectAncestorNodeIds(definition: AIWorkflowDefinition, nodeId: string): string[] {
const incoming = new Map<string, string[]>()
for (const edge of definition.edges ?? []) {
const list = incoming.get(edge.targetNodeID) ?? []
list.push(edge.sourceNodeID)
incoming.set(edge.targetNodeID, list)
}
const result: string[] = []
const seen = new Set<string>()
const visit = (id: string) => {
for (const source of incoming.get(id) ?? []) {
if (seen.has(source)) {
continue
}
seen.add(source)
visit(source)
result.push(source)
}
}
visit(nodeId)
return result
}
function uniqueNodeId(existingNodes: Pick<AIWorkflowDefinition["nodes"][number], "id">[], nodeType: string) {
const normalizedType = nodeType.replace(/[^a-zA-Z0-9_]/g, "_") || "node"
const existingIDs = new Set(existingNodes.map((node) => node.id))
for (let index = 1; index < 10000; index++) {
const id = `${normalizedType}_${index}`
if (!existingIDs.has(id)) {
return id
}
}
return `${normalizedType}_${Date.now()}`
}
function normalizeConditionBranch(value: unknown): WorkflowConditionBranch | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null
}
const record = value as Record<string, unknown>
const id = typeof record.id === "string" ? record.id : ""
const targetNodeId = typeof record.targetNodeId === "string" ? record.targetNodeId : ""
if (!id) {
return null
}
return {
id,
name: typeof record.name === "string" ? record.name : undefined,
targetNodeId,
default: record.default === true,
condition: normalizeCondition(record.condition),
}
}
function normalizeCondition(value: unknown): WorkflowCondition | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined
}
const record = value as Record<string, unknown>
return {
expression: typeof record.expression === "string" ? record.expression : undefined,
left: isWorkflowValue(record.left) ? record.left : undefined,
operator: typeof record.operator === "string" ? record.operator : undefined,
right: record.right,
}
}
function isWorkflowValue(value: unknown): value is WorkflowValue {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false
}
const type = (value as Record<string, unknown>).type
return type === "ref" || type === "constant" || type === "template"
}
@@ -1,35 +1,373 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { ArrowLeftIcon } from "lucide-react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs"
import { Textarea } from "@/components/ui/textarea"
import {
createAIWorkflow,
fetchAIWorkflow,
fetchAIWorkflowNodeSpecs,
fetchAIWorkflowUsage,
fetchAIWorkflowVersions,
publishAIWorkflow,
restoreAIWorkflowVersion,
updateAIWorkflow,
validateAIWorkflow,
type AIWorkflow,
type AIWorkflowDefinition,
type AIWorkflowNodeSpec,
type AIWorkflowUsage,
type AIWorkflowVersion,
} from "@/lib/api/admin"
import { formatDateTime } from "@/lib/utils"
import { createAIWorkflow, fetchAIWorkflow, fetchAIWorkflowNodeSpecs, fetchAIWorkflowUsage, fetchAIWorkflowVersions, publishAIWorkflow, restoreAIWorkflowVersion, updateAIWorkflow, validateAIWorkflow, type AIWorkflow, type AIWorkflowDefinition, type AIWorkflowNodeSpec, type AIWorkflowUsage, type AIWorkflowVersion } from "@/lib/api/admin"
import { WorkflowEditor } from "./workflow-editor"
import { WorkflowEditor } from "./editor/workflow-editor"
const emptyDefinition: AIWorkflowDefinition = { schemaVersion: 2, nodes: [{ id: "start_1", type: "start", meta: { position: { x: 0, y: 80 } }, data: { title: "开始", config: {}, inputsValues: {} } }, { id: "end_1", type: "end", meta: { position: { x: 260, y: 80 } }, data: { title: "结束", config: {}, inputsValues: {} } }], edges: [{ sourceNodeID: "start_1", targetNodeID: "end_1", sourcePortID: "edge_start_end" }] }
export function WorkflowWorkbench({ workflowID, onClose, onSaved }: { workflowID?: number; onClose?: () => void; onSaved?: () => void }) {
const router = useRouter()
const [active, setActive] = useState<AIWorkflow | null>(null); const [nodeSpecs, setNodeSpecs] = useState<AIWorkflowNodeSpec[]>([])
const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [definition, setDefinition] = useState<AIWorkflowDefinition>(emptyDefinition)
const [versions, setVersions] = useState<AIWorkflowVersion[]>([]); const [usage, setUsage] = useState<AIWorkflowUsage[]>([]); const [saving, setSaving] = useState(false); const [dirty, setDirty] = useState(false)
const [metadataOpen, setMetadataOpen] = useState(false); const [metadataName, setMetadataName] = useState(""); const [metadataDescription, setMetadataDescription] = useState("")
const load = useCallback(async () => { const specs = await fetchAIWorkflowNodeSpecs(); setNodeSpecs(specs ?? []); if (!workflowID) return; const [item, versionPage, uses] = await Promise.all([fetchAIWorkflow(workflowID), fetchAIWorkflowVersions({ workflowId: workflowID, limit: 50 }), fetchAIWorkflowUsage(workflowID)]); setActive(item); setName(item.name); setDescription(item.description); setDefinition(item.draftDefinition); setVersions(versionPage.results ?? []); setUsage(uses ?? []); setDirty(false) }, [workflowID])
useEffect(() => { void load().catch((error) => toast.error(error instanceof Error ? error.message : "加载工作流失败")) }, [load])
const openMetadata = () => { setMetadataName(name); setMetadataDescription(description); setMetadataOpen(true) }
const applyMetadata = () => { setName(metadataName); setDescription(metadataDescription); setDirty(true); setMetadataOpen(false) }
async function save() { if (!name.trim()) return toast.error("请填写工作流名称"); setSaving(true); try { if (active) { await updateAIWorkflow({ id: active.id, name: name.trim(), description: description.trim(), definition }); await load() } else { const created = await createAIWorkflow({ name: name.trim(), description: description.trim(), definition }); setActive(created); setName(created.name); setDescription(created.description); setDefinition(created.draftDefinition) }; onSaved?.(); toast.success("草稿已保存") } catch (error) { toast.error(error instanceof Error ? error.message : "保存失败") } finally { setSaving(false) } }
async function publish() { if (!active) return toast.error("请先保存草稿"); setSaving(true); try { const version = await publishAIWorkflow(active.id, definition); await load(); toast.success(`已发布 v${version.version}`) } catch (error) { toast.error(error instanceof Error ? error.message : "发布失败") } finally { setSaving(false) } }
async function restore(version: AIWorkflowVersion) { if (!active) return; try { await restoreAIWorkflowVersion(active.id, version.id); await load(); toast.success(`已将 v${version.version} 恢复为草稿`) } catch (error) { toast.error(error instanceof Error ? error.message : "恢复失败") } }
return <div className="flex h-full min-h-0 flex-col overflow-hidden bg-background"><header className="shrink-0 border-b px-6 py-4"><div className="flex items-center gap-4"><Button variant="ghost" size="icon" onClick={() => router.push("/dashboard/ai-workflows")}><ArrowLeftIcon className="size-4" /></Button><div className="min-w-0 flex-1"><div className="flex items-center gap-2"><h1 className="truncate text-lg font-semibold">{name || "新建工作流"}</h1><Badge variant={active?.publishedVersionId ? "secondary" : "outline"}>{active?.publishedVersionId ? "已发布" : "草稿"}</Badge>{dirty ? <span className="text-xs text-amber-600"></span> : null}</div><p className="mt-1 line-clamp-1 text-sm text-muted-foreground">{description || "暂未填写业务说明"}</p></div><div className="flex shrink-0 gap-2"><Button variant="ghost" onClick={openMetadata}></Button><Button variant="outline" disabled={saving} onClick={() => void validateAIWorkflow(definition).then((result) => toast[result.valid ? "success" : "error"](result.valid ? "校验通过" : `发现 ${result.errors.length} 个问题`))}></Button><Button variant="outline" disabled={saving} onClick={() => void save()}>稿</Button><Button disabled={saving || !active} onClick={() => void publish()}></Button></div></div></header><Tabs defaultValue="editor" className="flex min-h-0 flex-1 flex-col"><div className="shrink-0 border-b px-6"><TabsList className="h-11 bg-transparent"><TabsTrigger value="editor"></TabsTrigger><TabsTrigger value="versions"> ({versions.length})</TabsTrigger><TabsTrigger value="usage">使 ({usage.length})</TabsTrigger></TabsList></div><TabsContent value="editor" className="min-h-0 flex-1 overflow-hidden data-[state=inactive]:hidden"><WorkflowEditor definition={definition} nodeSpecs={nodeSpecs} onDefinitionChange={(next) => { setDefinition(next); setDirty(true) }} onSaveDraft={() => void save()} onPublish={() => void publish()} saveDraftDisabled={saving} publishDisabled={saving || !active} /></TabsContent><TabsContent value="versions" className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-6">{versions.length ? versions.map((version) => <div key={version.id} className="mb-3 flex items-center gap-4 rounded-lg border p-4"><Badge>v{version.version}</Badge><div className="min-w-0 flex-1"><div className="text-sm font-medium"> {formatDateTime(version.publishedAt || version.createdAt)}</div><div className="text-xs text-muted-foreground">{version.publishedByName || "-"}</div></div><Button variant="outline" size="sm" onClick={() => void restore(version)}>稿</Button></div>) : <p className="text-sm text-muted-foreground"></p>}</TabsContent><TabsContent value="usage" className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-6">{usage.length ? usage.map((item) => <div key={`${item.aiAgentId}-${item.workflowVersionId}`} className="mb-3 flex items-center justify-between rounded-lg border p-4"><div><div className="font-medium">{item.aiAgentName}</div><div className="mt-1 text-sm text-muted-foreground"> v{item.workflowVersion}</div></div><Badge variant={item.enabled ? "secondary" : "outline"}>{item.enabled ? "启用" : "已停用"}</Badge></div>) : <p className="text-sm text-muted-foreground"> Agent 使</p>}</TabsContent></Tabs><Dialog open={metadataOpen} onOpenChange={setMetadataOpen}><DialogContent><DialogHeader><DialogTitle></DialogTitle></DialogHeader><div className="space-y-4"><Input value={metadataName} onChange={(event) => setMetadataName(event.target.value)} placeholder="工作流名称" /><Textarea value={metadataDescription} onChange={(event) => setMetadataDescription(event.target.value)} placeholder="适用场景、目标与业务边界" /></div><DialogFooter><Button variant="outline" onClick={() => setMetadataOpen(false)}></Button><Button onClick={applyMetadata}></Button></DialogFooter></DialogContent></Dialog></div>
const emptyDefinition: AIWorkflowDefinition = {
schemaVersion: 2,
nodes: [
{
id: "start_1",
type: "start",
meta: { position: { x: 0, y: 80 } },
data: { title: "开始", config: {}, inputsValues: {} },
},
{
id: "end_1",
type: "end",
meta: { position: { x: 260, y: 80 } },
data: { title: "结束", config: {}, inputsValues: {} },
},
],
edges: [
{
sourceNodeID: "start_1",
targetNodeID: "end_1",
sourcePortID: "edge_start_end",
},
],
}
type WorkflowWorkbenchProps = {
workflowID?: number
onClose?: () => void
onSaved?: () => void
}
export function WorkflowWorkbench({
workflowID,
onClose,
onSaved,
}: WorkflowWorkbenchProps) {
const router = useRouter()
const [active, setActive] = useState<AIWorkflow | null>(null)
const [nodeSpecs, setNodeSpecs] = useState<AIWorkflowNodeSpec[]>([])
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [definition, setDefinition] =
useState<AIWorkflowDefinition>(emptyDefinition)
const [versions, setVersions] = useState<AIWorkflowVersion[]>([])
const [usage, setUsage] = useState<AIWorkflowUsage[]>([])
const [saving, setSaving] = useState(false)
const [dirty, setDirty] = useState(false)
const [metadataOpen, setMetadataOpen] = useState(false)
const [metadataName, setMetadataName] = useState("")
const [metadataDescription, setMetadataDescription] = useState("")
const load = useCallback(async () => {
const specs = await fetchAIWorkflowNodeSpecs()
setNodeSpecs(specs ?? [])
if (!workflowID) return
const [item, versionPage, uses] = await Promise.all([
fetchAIWorkflow(workflowID),
fetchAIWorkflowVersions({ workflowId: workflowID, limit: 50 }),
fetchAIWorkflowUsage(workflowID),
])
setActive(item)
setName(item.name)
setDescription(item.description)
setDefinition(item.draftDefinition)
setVersions(versionPage.results ?? [])
setUsage(uses ?? [])
setDirty(false)
}, [workflowID])
useEffect(() => {
void load().catch((error) =>
toast.error(error instanceof Error ? error.message : "加载工作流失败")
)
}, [load])
function openMetadata() {
setMetadataName(name)
setMetadataDescription(description)
setMetadataOpen(true)
}
function applyMetadata() {
setName(metadataName)
setDescription(metadataDescription)
setDirty(true)
setMetadataOpen(false)
}
async function save() {
if (!name.trim()) {
toast.error("请填写工作流名称")
return
}
setSaving(true)
try {
if (active) {
await updateAIWorkflow({
id: active.id,
name: name.trim(),
description: description.trim(),
definition,
})
await load()
} else {
const created = await createAIWorkflow({
name: name.trim(),
description: description.trim(),
definition,
})
setActive(created)
setName(created.name)
setDescription(created.description)
setDefinition(created.draftDefinition)
setDirty(false)
}
onSaved?.()
toast.success("草稿已保存")
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败")
} finally {
setSaving(false)
}
}
async function publish() {
if (!active) {
toast.error("请先保存草稿")
return
}
setSaving(true)
try {
const version = await publishAIWorkflow(active.id, definition)
await load()
toast.success(`已发布 v${version.version}`)
} catch (error) {
toast.error(error instanceof Error ? error.message : "发布失败")
} finally {
setSaving(false)
}
}
async function restore(version: AIWorkflowVersion) {
if (!active) return
try {
await restoreAIWorkflowVersion(active.id, version.id)
await load()
toast.success(`已将 v${version.version} 恢复为草稿`)
} catch (error) {
toast.error(error instanceof Error ? error.message : "恢复失败")
}
}
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-background">
<header className="shrink-0 border-b px-6 py-4">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() =>
onClose ? onClose() : router.push("/dashboard/ai-workflows")
}
>
<ArrowLeftIcon className="size-4" />
</Button>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h1 className="truncate text-lg font-semibold">
{name || "新建工作流"}
</h1>
<Badge
variant={active?.publishedVersionId ? "secondary" : "outline"}
>
{active?.publishedVersionId ? "已发布" : "草稿"}
</Badge>
{dirty ? (
<span className="text-xs text-amber-600"></span>
) : null}
</div>
<p className="mt-1 line-clamp-1 text-sm text-muted-foreground">
{description || "暂未填写业务说明"}
</p>
</div>
<div className="flex shrink-0 gap-2">
<Button variant="ghost" onClick={openMetadata}>
</Button>
<Button
variant="outline"
disabled={saving}
onClick={() => void save()}
>
稿
</Button>
<Button
disabled={saving || !active}
onClick={() => void publish()}
>
</Button>
</div>
</div>
</header>
<Tabs defaultValue="editor" className="flex min-h-0 flex-1 flex-col">
<div className="shrink-0 border-b px-6">
<TabsList className="h-11 bg-transparent">
<TabsTrigger value="editor"></TabsTrigger>
<TabsTrigger value="versions">
({versions.length})
</TabsTrigger>
<TabsTrigger value="usage">使 ({usage.length})</TabsTrigger>
</TabsList>
</div>
<TabsContent
value="editor"
className="min-h-0 flex-1 overflow-hidden data-[state=inactive]:hidden"
>
{nodeSpecs.length ? (
<WorkflowEditor
definition={definition}
nodeSpecs={nodeSpecs}
onDefinitionChange={(next) => {
setDefinition(next)
setDirty(true)
}}
onValidate={() => validateAIWorkflow(definition)}
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
</div>
)}
</TabsContent>
<TabsContent
value="versions"
className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-6"
>
{versions.length ? (
versions.map((version) => (
<div
key={version.id}
className="mb-3 flex items-center gap-4 rounded-lg border p-4"
>
<Badge>v{version.version}</Badge>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">
{" "}
{formatDateTime(version.publishedAt || version.createdAt)}
</div>
<div className="text-xs text-muted-foreground">
{version.publishedByName || "-"}
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => void restore(version)}
>
稿
</Button>
</div>
))
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</TabsContent>
<TabsContent
value="usage"
className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-6"
>
{usage.length ? (
usage.map((item) => (
<div
key={`${item.aiAgentId}-${item.workflowVersionId}`}
className="mb-3 flex items-center justify-between rounded-lg border p-4"
>
<div>
<div className="font-medium">{item.aiAgentName}</div>
<div className="mt-1 text-sm text-muted-foreground">
v{item.workflowVersion}
</div>
</div>
<Badge variant={item.enabled ? "secondary" : "outline"}>
{item.enabled ? "启用" : "已停用"}
</Badge>
</div>
))
) : (
<p className="text-sm text-muted-foreground">
Agent 使
</p>
)}
</TabsContent>
</Tabs>
<Dialog open={metadataOpen} onOpenChange={setMetadataOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-4">
<Input
value={metadataName}
onChange={(event) => setMetadataName(event.target.value)}
placeholder="工作流名称"
/>
<Textarea
value={metadataDescription}
onChange={(event) => setMetadataDescription(event.target.value)}
placeholder="适用场景、目标与业务边界"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setMetadataOpen(false)}>
</Button>
<Button onClick={applyMetadata}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
-12
View File
@@ -366,15 +366,3 @@
@apply font-sans;
}
}
.workflow-node-port:hover .bg:not(.hasError),
.workflow-node-port.hovered .bg:not(.hasError) {
cursor: crosshair;
transform: scale(1, 1) !important;
background: #2575fc !important;
}
.workflow-node-port:hover .bg > .symbol,
.workflow-node-port.hovered .bg > .symbol {
opacity: 1 !important;
}
+1
View File
@@ -10,6 +10,7 @@ import { Toaster } from "@/components/ui/sonner"
import { AppI18nProvider } from "@/i18n/provider"
import "@/app/globals.css"
import "@flowgram.ai/free-layout-editor/index.css"
import "md-editor-rt/lib/style.css"
import "@/styles/main.scss"
+11 -8
View File
@@ -375,7 +375,17 @@ export type AIWorkflowVariableSpec = {
export type AIWorkflowDefinition = {
schemaVersion: number
nodes: {
nodes: AIWorkflowCanvasNode[]
annotations?: AIWorkflowCanvasNode[]
edges: {
sourceNodeID: string
targetNodeID: string
sourcePortID?: string
targetPortID?: string
}[]
}
export type AIWorkflowCanvasNode = {
id: string
type: string
meta: {
@@ -389,13 +399,6 @@ export type AIWorkflowDefinition = {
inputsValues?: Record<string, AIWorkflowValue>
[key: string]: unknown
}
}[]
edges: {
sourceNodeID: string
targetNodeID: string
sourcePortID?: string
targetPortID?: string
}[]
}
export type AIWorkflow = {
+5
View File
@@ -16,10 +16,15 @@
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@flowgram.ai/export-plugin": "1.0.11",
"@flowgram.ai/free-auto-layout-plugin": "1.0.11",
"@flowgram.ai/free-layout-editor": "1.0.11",
"@flowgram.ai/free-lines-plugin": "1.0.11",
"@flowgram.ai/free-node-panel-plugin": "1.0.11",
"@flowgram.ai/free-snap-plugin": "1.0.11",
"@flowgram.ai/free-stack-plugin": "1.0.11",
"@flowgram.ai/minimap-plugin": "1.0.11",
"@flowgram.ai/panel-manager-plugin": "1.0.11",
"@hookform/resolvers": "^5.2.2",
"@tanstack/react-table": "^8.21.3",
"@tiptap/extension-image": "^3.20.2",
+106
View File
@@ -23,18 +23,33 @@ importers:
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@19.2.3)
'@flowgram.ai/export-plugin':
specifier: 1.0.11
version: 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@flowgram.ai/free-auto-layout-plugin':
specifier: 1.0.11
version: 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(styled-components@6.4.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))
'@flowgram.ai/free-layout-editor':
specifier: 1.0.11
version: 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(styled-components@6.4.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))
'@flowgram.ai/free-lines-plugin':
specifier: 1.0.11
version: 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(styled-components@6.4.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))
'@flowgram.ai/free-node-panel-plugin':
specifier: 1.0.11
version: 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@flowgram.ai/free-snap-plugin':
specifier: 1.0.11
version: 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(styled-components@6.4.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))
'@flowgram.ai/free-stack-plugin':
specifier: 1.0.11
version: 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(styled-components@6.4.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))
'@flowgram.ai/minimap-plugin':
specifier: 1.0.11
version: 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@flowgram.ai/panel-manager-plugin':
specifier: 1.0.11
version: 1.0.11(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(reflect-metadata@0.2.2)
'@hookform/resolvers':
specifier: ^5.2.2
version: 5.2.2(react-hook-form@7.71.2(react@19.2.3))
@@ -572,6 +587,12 @@ packages:
react: '>=16.8'
react-dom: '>=16.8'
'@flowgram.ai/export-plugin@1.0.11':
resolution: {integrity: sha512-drJley1YMVqANTcl+n/TDvxa75F07z7u1jme/Y1H8P1h9aOAhem+q1oJZgw7WSAK7eG7Sy1qxNb6yeqaKlzz6g==}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
'@flowgram.ai/form-core@1.0.11':
resolution: {integrity: sha512-qucie8ekXkfJbXMvPSUGR1CZufBZ1qqHbYNITZhxwR3X54MF7vieFD/6fb7H7jic2z5ad4aCOJcIDe5uD0hQjg==}
peerDependencies:
@@ -622,6 +643,12 @@ packages:
react-dom: '>=16.8'
styled-components: '>=5'
'@flowgram.ai/free-node-panel-plugin@1.0.11':
resolution: {integrity: sha512-1XqzHZhimJLK6G3zUhHUKBHgLlnWNRmWYmpCci0e9yKzOBduS6xZTfMSgc7bl5TeaG9u++nooTqirx6xP5kwXQ==}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
'@flowgram.ai/free-snap-plugin@1.0.11':
resolution: {integrity: sha512-6YPtxwIYkUgj2BBBQgJvIPeSgDZQJpHSDl9r6FyJyalAdQKHeKniCBAR8b3tgaQf/oKR1v/8zt01h+IzMFhodQ==}
peerDependencies:
@@ -681,6 +708,15 @@ packages:
react: '>=16.8'
react-dom: '>=16.8'
'@flowgram.ai/panel-manager-plugin@1.0.11':
resolution: {integrity: sha512-piax49+HwMiU+zHT5L8ttQ7O64gqJSpvlHXt68jrVDMlExMPX62R89imvTSuFDyPVjOGjoqsVaRFb8cUlNtbtw==}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
peerDependenciesMeta:
react:
optional: true
'@flowgram.ai/playground-react@1.0.11':
resolution: {integrity: sha512-4NORKKYGoH2lyRzf7VW2NFlaHZbE1dpXVAi1ehr27g4L24TUxJCWVRWi8vrCph9hrlucs86Iw1SAFOVP1dnsSQ==}
peerDependencies:
@@ -3407,6 +3443,9 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
modern-screenshot@4.6.7:
resolution: {integrity: sha512-0GhgI6i6le4AhKzCvLYjwEmsP47kTsX45iT5yuAzsLTi/7i3Rjxe8fbH2VjGJLuyOThwsa0CdQAPd4auoEtsZg==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -4460,6 +4499,21 @@ packages:
zod@4.3.6:
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
zustand@4.5.7:
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
engines: {node: '>=12.7.0'}
peerDependencies:
'@types/react': '>=16.8'
immer: '>=9.0.6'
react: '>=16.8'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
zustand@5.0.12:
resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==}
engines: {node: '>=12.20.0'}
@@ -5153,6 +5207,20 @@ snapshots:
react-dom: 19.2.3(react@19.2.3)
reflect-metadata: 0.2.2
'@flowgram.ai/export-plugin@1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@flowgram.ai/core': 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@flowgram.ai/document': 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@flowgram.ai/utils': 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
inversify: 6.2.2(reflect-metadata@0.2.2)
js-yaml: 4.1.1
lodash-es: 4.18.1
modern-screenshot: 4.6.7
nanoid: 5.1.16
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
reflect-metadata: 0.2.2
'@flowgram.ai/form-core@1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@flowgram.ai/core': 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -5263,6 +5331,18 @@ snapshots:
reflect-metadata: 0.2.2
styled-components: 6.4.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@flowgram.ai/free-node-panel-plugin@1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@flowgram.ai/core': 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@flowgram.ai/document': 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@flowgram.ai/free-history-plugin': 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@flowgram.ai/free-layout-core': 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@flowgram.ai/utils': 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
inversify: 6.2.2(reflect-metadata@0.2.2)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
reflect-metadata: 0.2.2
'@flowgram.ai/free-snap-plugin@1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(styled-components@6.4.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))':
dependencies:
'@flowgram.ai/core': 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -5388,6 +5468,23 @@ snapshots:
react-dom: 19.2.3(react@19.2.3)
reflect-metadata: 0.2.2
'@flowgram.ai/panel-manager-plugin@1.0.11(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(reflect-metadata@0.2.2)':
dependencies:
'@flowgram.ai/core': 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@flowgram.ai/utils': 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
clsx: 1.2.1
inversify: 6.2.2(reflect-metadata@0.2.2)
nanoid: 5.1.16
react-dom: 19.2.3(react@19.2.3)
use-sync-external-store: 1.6.0(react@19.2.3)
zustand: 4.5.7(@types/react@19.2.14)(react@19.2.3)
optionalDependencies:
react: 19.2.3
transitivePeerDependencies:
- '@types/react'
- immer
- reflect-metadata
'@flowgram.ai/playground-react@1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@flowgram.ai/background-plugin': 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -8145,6 +8242,8 @@ snapshots:
minimist@1.2.8: {}
modern-screenshot@4.6.7: {}
ms@2.1.3: {}
msw@2.12.10(@types/node@20.19.37)(typescript@5.9.3):
@@ -9423,6 +9522,13 @@ snapshots:
zod@4.3.6: {}
zustand@4.5.7(@types/react@19.2.14)(react@19.2.3):
dependencies:
use-sync-external-store: 1.6.0(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.14
react: 19.2.3
zustand@5.0.12(@types/react@19.2.14)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)):
optionalDependencies:
'@types/react': 19.2.14