新版本流程编辑器

This commit is contained in:
mlogclub
2026-07-27 19:04:15 +08:00
parent d33960961a
commit e072141953
281 changed files with 25145 additions and 3405 deletions
@@ -10,7 +10,7 @@ import { ScrollArea } from "@/components/ui/scroll-area"
import type { AIWorkflowNodeRun, AIWorkflowRun } from "@/lib/api/admin"
import { cn } from "@/lib/utils"
import { WorkflowReadonlyCanvas } from "../../ai-workflows/_components/editor/workflow-editor"
import { OfficialWorkflowEditor } from "../../ai-workflows/_components/official-workflow-editor"
export function WorkflowRunAuditGraph({ run }: { run: AIWorkflowRun }) {
const nodeRuns = useMemo(() => run.nodes ?? [], [run.nodes])
@@ -22,8 +22,11 @@ export function WorkflowRunAuditGraph({ run }: { run: AIWorkflowRun }) {
return (
<div className="grid min-h-[520px] grid-cols-[minmax(0,1fr)_320px] overflow-hidden border">
<div className="relative min-w-0">
<WorkflowReadonlyCanvas
definition={run.definition ?? { schemaVersion: 2, nodes: [], edges: [] }}
<OfficialWorkflowEditor
documentKey={`run-${run.id}`}
definition={run.definition ?? { nodes: [], edges: [] }}
onDefinitionChange={() => undefined}
readonly
/>
<div className="pointer-events-none absolute left-3 top-3 flex flex-wrap gap-2">
<Badge variant="secondary" className="gap-1">
@@ -1,77 +0,0 @@
"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]",
render.form?.state.invalid && "border-destructive"
)}
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>
)
}
@@ -1,89 +0,0 @@
"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>
)
}
@@ -1,53 +0,0 @@
"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
}
@@ -1,49 +0,0 @@
"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)
}
@@ -1,151 +0,0 @@
"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])
}
@@ -1,505 +0,0 @@
"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,
getAntiOverlapPosition,
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: async (result) => {
if (!result) return
const rect = playground.node.getBoundingClientRect()
const center = playground.config.getPosFromMouseEvent({
clientX: rect.left + rect.width / 2,
clientY: rect.top + rect.height / 2,
})
const existingBounds = document
.getAllNodes()
.map((item) => item.transform.bounds)
const position =
existingBounds.length > 0
? {
x:
Math.max(...existingBounds.map((bounds) => bounds.right)) +
200,
y: Math.min(...existingBounds.map((bounds) => bounds.top)),
}
: center
const node: WorkflowNodeEntity = document.createWorkflowNodeByType(
result.nodeType,
getAntiOverlapPosition(document, position),
result.nodeJSON ?? ({} as WorkflowNodeJSON)
)
selection.selectNode(node)
await new Promise<void>((resolve) =>
window.requestAnimationFrame(() => resolve())
)
tools.fitView(false)
},
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)
}
@@ -1,96 +0,0 @@
"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>
)
}
@@ -1,715 +0,0 @@
"use client"
import {
startTransition,
useEffect,
useRef,
useState,
} from "react"
import {
Field,
PlaygroundEntityContext,
WorkflowDocument,
type WorkflowNodeEntity,
type WorkflowNodeJSON,
WorkflowSelectService,
useClientContext,
useNodeRender,
useRefresh,
useService,
} from "@flowgram.ai/free-layout-editor"
import { usePanelManager } from "@flowgram.ai/panel-manager-plugin"
import {
AlertCircleIcon,
CopyIcon,
MoreHorizontalIcon,
PencilIcon,
PlusIcon,
Trash2Icon,
XIcon,
} from "lucide-react"
import { OptionCombobox } from "@/components/option-combobox"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import {
fetchKnowledgeBasesAll,
type AIWorkflowDefinition,
type AIWorkflowNodeSpec,
type AIWorkflowValue,
type KnowledgeBase,
} from "@/lib/api/admin"
import { Status } from "@/lib/generated/enums"
import { cn } from "@/lib/utils"
import { NODE_FORM_PANEL } from "./base-node"
import {
WorkflowEditorSurfaceProvider,
useWorkflowEditorContext,
useWorkflowEditorSurface,
} 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, playground, selection } = useClientContext()
const panelManager = usePanelManager()
const refresh = useRefresh()
const node = document.getNode(nodeId)
useEffect(() => {
const disposable = playground.config.onReadonlyOrDisabledChange(() => {
panelManager.close(NODE_FORM_PANEL)
refresh()
})
return () => disposable.dispose()
}, [panelManager, playground, refresh])
useEffect(() => {
const disposable = selection.onSelectionChanged(() => {
if (
selection.selection.length !== 1 ||
selection.selection[0] !== node
) {
startTransition(() => panelManager.close(NODE_FORM_PANEL))
}
})
return () => disposable.dispose()
}, [node, panelManager, selection])
useEffect(() => {
if (!node) return
const disposable = node.onDispose(() =>
panelManager.close(NODE_FORM_PANEL)
)
return () => disposable.dispose()
}, [node, panelManager])
if (
!node ||
playground.config.readonly ||
node.getNodeMeta<{ sidebarDisabled?: boolean }>().sidebarDisabled
) {
return null
}
return (
<PlaygroundEntityContext.Provider key={node.id} value={node}>
<WorkflowEditorSurfaceProvider surface="sidebar">
<SidebarNodeRenderer node={node} />
</WorkflowEditorSurfaceProvider>
</PlaygroundEntityContext.Provider>
)
}
function SidebarNodeRenderer({ node }: { node: WorkflowNodeEntity }) {
const render = useNodeRender(node)
return (
<div className="h-full w-full overflow-hidden rounded-lg border border-[rgba(82,100,154,0.13)] bg-[#fbfbfb]">
{render.form?.render()}
</div>
)
}
export function WorkflowNodeForm({ spec }: { spec: AIWorkflowNodeSpec }) {
const render = useNodeRender()
const surface = useWorkflowEditorSurface()
const isSidebar = surface === "sidebar"
const { document } = useClientContext()
const { nodeSpecs } = useWorkflowEditorContext()
const definition = document.toJSON() as AIWorkflowDefinition
const variables = buildAvailableVariables(definition, render.node.id, nodeSpecs)
return (
<div className={cn("w-full select-none", isSidebar && "h-full")}>
<NodeFormHeader spec={spec} />
<div
className={cn(
"w-full rounded-b-lg bg-[#fbfbfb] px-3 pb-3",
isSidebar
? "h-[calc(100%-40px)] overflow-y-auto overscroll-contain pt-1"
: "space-y-1.5"
)}
>
{isSidebar && spec.description ? (
<p className="px-1 pb-2 text-xs leading-5 text-[rgba(6,7,9,0.5)]">
{spec.description}
</p>
) : null}
<InputFields spec={spec} variables={variables} />
{spec.type === "knowledge_retrieve" ? <KnowledgeFields /> : null}
{spec.type === "condition" ? (
<ConditionFields variables={variables} />
) : null}
<OutputFields spec={spec} />
</div>
</div>
)
}
function NodeFormHeader({ spec }: { spec: AIWorkflowNodeSpec }) {
const render = useNodeRender()
const panelManager = usePanelManager()
const { document } = useClientContext()
const selection = useService(WorkflowSelectService)
const surface = useWorkflowEditorSurface()
const isSidebar = surface === "sidebar"
const canDelete = !["start", "end"].includes(String(render.node.flowNodeType))
const canCopy = canDelete
const [editing, setEditing] = useState(false)
const [menuOpen, setMenuOpen] = useState(false)
const titleRef = useRef<HTMLInputElement>(null)
const closeMenuTimer = useRef<number | null>(null)
useEffect(() => {
if (editing) titleRef.current?.focus()
}, [editing])
useEffect(
() => () => {
if (closeMenuTimer.current) window.clearTimeout(closeMenuTimer.current)
},
[]
)
function openMenu() {
if (closeMenuTimer.current) window.clearTimeout(closeMenuTimer.current)
setMenuOpen(true)
}
function scheduleCloseMenu() {
if (closeMenuTimer.current) window.clearTimeout(closeMenuTimer.current)
closeMenuTimer.current = window.setTimeout(() => setMenuOpen(false), 120)
}
return (
<div className="flex h-10 w-full items-center gap-2 overflow-hidden rounded-t-lg bg-gradient-to-b from-[#f2f2ff] to-[#fbfbfb] px-2">
<span className="flex size-6 shrink-0 items-center justify-center rounded bg-white/70 text-[#4e40e5]">
<WorkflowNodeIcon name={spec.icon} className="size-3.5" />
</span>
<Field<string> name="title">
{({ field, fieldState }) => (
<div className="relative min-w-0 flex-1">
{editing && !render.readonly ? (
<Input
ref={titleRef}
value={field.value ?? ""}
className="h-7 border-[#4e40e5] bg-white px-2 text-sm"
onClick={(event) => event.stopPropagation()}
onBlur={() => setEditing(false)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === "Escape") {
setEditing(false)
}
}}
onChange={(event) => field.onChange(event.target.value)}
/>
) : (
<button
type="button"
className="block h-7 w-full truncate text-left text-sm font-medium text-[#060709]"
title={field.value || spec.title}
onDoubleClick={(event) => {
event.stopPropagation()
if (!render.readonly) setEditing(true)
}}
>
{field.value || spec.title}
</button>
)}
{fieldState?.invalid ? (
<AlertCircleIcon className="absolute -left-1 -top-1 size-4 rounded-full bg-white text-destructive" />
) : null}
</div>
)}
</Field>
{!render.readonly ? (
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon-xs"
aria-label="节点操作"
className="shrink-0 text-muted-foreground"
onMouseEnter={openMenu}
onMouseLeave={scheduleCloseMenu}
onClick={(event) => event.stopPropagation()}
/>
}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-36"
onMouseEnter={openMenu}
onMouseLeave={scheduleCloseMenu}
>
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation()
setEditing(true)
}}
>
<PencilIcon />
</DropdownMenuItem>
<DropdownMenuItem
disabled={!canCopy}
onClick={(event) => {
event.stopPropagation()
duplicateNode(render.node, document, selection)
}}
>
<CopyIcon />
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
disabled={!canDelete}
onClick={(event) => {
event.stopPropagation()
render.deleteNode()
panelManager.close(NODE_FORM_PANEL)
}}
>
<Trash2Icon />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
{isSidebar ? (
<Button
variant="ghost"
size="icon-xs"
aria-label="关闭配置"
className="shrink-0"
onClick={() => panelManager.close(NODE_FORM_PANEL)}
>
<XIcon />
</Button>
) : null}
</div>
)
}
function InputFields({
spec,
variables,
}: {
spec: AIWorkflowNodeSpec
variables: ReturnType<typeof buildAvailableVariables>
}) {
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 (
<>
{spec.inputSchema.map((input) => (
<Field<AIWorkflowValue | undefined>
key={input.name}
name={`inputsValues.${input.name}`}
>
{({ field, fieldState }) => (
<NodeFormRow
label={input.label || input.name}
type={input.type}
required={input.required}
description={input.description}
>
<OptionCombobox
value={refKey(field.value)}
options={options}
placeholder="选择上游变量"
searchPlaceholder="搜索变量"
preserveExternalSelection
triggerClassName={cn(
"h-8 bg-white text-xs",
fieldState?.invalid && "border-destructive"
)}
onChange={(value) => {
const parsed = parseRefKey(value)
if (parsed) field.onChange(parsed)
}}
/>
</NodeFormRow>
)}
</Field>
))}
</>
)
}
function KnowledgeFields() {
const [items, setItems] = useState<KnowledgeBase[]>([])
useEffect(() => {
let active = true
fetchKnowledgeBasesAll({ status: Status.Ok })
.then((result) => active && setItems(result ?? []))
.catch(() => active && setItems([]))
return () => {
active = false
}
}, [])
return (
<Field<Record<string, unknown>> name="config">
{({ field }) => {
const config = asRecord(field.value)
const values = normalizeIDs(config.knowledgeBaseIds).map(String)
return (
<NodeFormRow
label="检索范围"
type="array<int>"
required
description="可选择多个已启用知识库。"
>
<OptionCombobox
multiple
values={values}
options={items.map((item) => ({
value: String(item.id),
label: item.name,
}))}
placeholder="选择知识库"
searchPlaceholder="搜索知识库"
triggerClassName="min-h-8 bg-white text-xs"
onValuesChange={(next) =>
field.onChange({
...config,
knowledgeBaseIds: next
.map(Number)
.filter((id) => id > 0),
})
}
/>
</NodeFormRow>
)
}}
</Field>
)
}
function ConditionFields({
variables,
}: {
variables: ReturnType<typeof buildAvailableVariables>
}) {
const render = useNodeRender()
const variableOptions = variables.map((variable) => ({
value: `${variable.nodeId}.${variable.name}`,
label: variable.label || variable.name,
group: variable.nodeTitle,
subtitle: `${variable.nodeId}.${variable.name}`,
}))
return (
<Field<Record<string, unknown>> name="config">
{({ field }) => {
const config = asRecord(field.value)
const branches = normalizeConditionBranches({
data: { config },
})
const regular = branches.filter((branch) => !branch.default)
const fallback = branches.find((branch) => branch.default)
function commit(next: WorkflowConditionBranch[]) {
const nextConfig = { ...config, branches: next }
field.onChange(nextConfig)
render.updateData({
...render.data,
config: nextConfig,
portKeys: next.map((branch) => branch.id),
ports: next.map((branch) => branch.id),
})
window.requestAnimationFrame(() =>
render.node.ports.updateDynamicPorts()
)
}
function update(branch: WorkflowConditionBranch) {
commit(
branches.map((item) => (item.id === branch.id ? branch : item))
)
}
return (
<div className="space-y-1.5">
{branches.map((branch, index) => (
<div
key={branch.id}
className="relative flex items-start gap-2 py-0.5"
>
<div className="flex h-8 w-[50px] shrink-0 items-center gap-1 text-xs">
<Badge
variant="outline"
className="h-5 rounded px-1.5 font-mono text-[10px] uppercase text-[#4e40e5]"
>
{branch.default
? "else"
: index === 0
? "if"
: "elif"}
</Badge>
</div>
{branch.default ? (
<div className="flex h-8 min-w-0 flex-1 items-center text-xs text-muted-foreground">
</div>
) : (
<div className="grid min-w-0 flex-1 gap-1.5">
<OptionCombobox
value={refKey(branch.condition?.left)}
options={variableOptions}
placeholder="选择变量"
preserveExternalSelection
triggerClassName="h-8 bg-white text-xs"
onChange={(value) =>
update({
...branch,
condition: {
...branch.condition,
left: parseRefKey(value),
},
})
}
/>
<div className="flex gap-1.5">
<OptionCombobox
value={branch.condition?.operator ?? "eq"}
options={operatorOptions}
placeholder="运算符"
triggerClassName="h-8 min-w-0 flex-1 bg-white text-xs"
onChange={(operator) =>
update({
...branch,
condition: {
...branch.condition,
operator,
},
})
}
/>
{!["exists", "empty"].includes(
branch.condition?.operator ?? ""
) ? (
<Input
value={String(branch.condition?.right ?? "")}
placeholder="比较值"
className="h-8 min-w-0 flex-1 bg-white text-xs"
onChange={(event) =>
update({
...branch,
condition: {
...branch.condition,
right: event.target.value,
},
})
}
/>
) : null}
</div>
</div>
)}
{!branch.default && !render.readonly ? (
<Button
variant="ghost"
size="icon-xs"
aria-label="删除条件"
className="mt-1 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() =>
commit(
branches.filter((item) => item.id !== branch.id)
)
}
>
<Trash2Icon />
</Button>
) : null}
<span
data-port-id={branch.id}
data-port-type="output"
className="absolute -right-3 top-4 size-0"
/>
</div>
))}
{!render.readonly ? (
<Button
variant="ghost"
size="sm"
className="h-7 px-1.5 text-xs text-[#4e40e5]"
onClick={() => {
const branch: WorkflowConditionBranch = {
id: nextBranchID(branches),
name: `条件 ${regular.length + 1}`,
targetNodeId: "",
condition: { operator: "eq" },
}
commit(
[...regular, branch, fallback].filter(
Boolean
) as WorkflowConditionBranch[]
)
}}
>
<PlusIcon />
</Button>
) : null}
</div>
)
}}
</Field>
)
}
function OutputFields({ spec }: { spec: AIWorkflowNodeSpec }) {
if (!spec.outputSchema?.length) return null
return (
<div className="mt-1 border-t border-[rgba(82,100,154,0.13)] pt-2">
{spec.outputSchema.map((output) => (
<NodeFormRow
key={output.name}
label={output.label || output.name}
type={output.type}
description={output.description}
>
<div
className="flex h-8 items-center truncate rounded-md bg-[#f3f3f6] px-2 font-mono text-xs text-muted-foreground"
title={`${output.name}: ${output.description}`}
>
{output.name}
</div>
</NodeFormRow>
))}
</div>
)
}
function NodeFormRow({
label,
type,
required,
description,
children,
}: {
label: string
type?: string
required?: boolean
description?: string
children: React.ReactNode
}) {
return (
<div
className="flex w-full items-start gap-2 py-0.5 text-xs"
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<div
className="flex min-h-8 w-[118px] min-w-[118px] items-center gap-1"
title={description}
>
{type ? (
<span className="flex size-[18px] shrink-0 items-center justify-center rounded bg-[#ececf1] font-mono text-[9px] uppercase text-muted-foreground">
{typeIcon(type)}
</span>
) : null}
<span className="min-w-0 truncate text-[#060709]">{label}</span>
{required ? <span className="text-destructive">*</span> : null}
</div>
<div className="min-w-0 flex-1">{children}</div>
</div>
)
}
function duplicateNode(
node: WorkflowNodeEntity,
document: WorkflowDocument,
selection: WorkflowSelectService
) {
const source = document.toNodeJSON(node) as WorkflowNodeJSON
const position = {
x: Number(source.meta?.position?.x ?? node.transform.position.x) + 48,
y: Number(source.meta?.position?.y ?? node.transform.position.y) + 48,
}
const used = new Set(document.getAllNodes().map((item) => item.id))
const baseID = `${source.id}_copy`
let id = baseID
let index = 2
while (used.has(id)) {
id = `${baseID}_${index}`
index += 1
}
const copied = document.createWorkflowNodeByType(
String(node.flowNodeType),
position,
{
...source,
id,
meta: { ...source.meta, position },
}
)
selection.selectNode(copied)
}
function typeIcon(type: string) {
if (type === "string") return "S"
if (type === "boolean") return "B"
if (type === "number" || type === "integer") return "N"
if (type.startsWith("array")) return "A"
if (type === "object") return "O"
return "•"
}
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)
)
)
}
@@ -1,46 +0,0 @@
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} />
}
@@ -1,66 +0,0 @@
"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>
</>
)
}
@@ -1,54 +0,0 @@
"use client"
import { type WorkflowNodeRegistry } from "@flowgram.ai/free-layout-editor"
import type { AIWorkflowNodeSpec } from "@/lib/api/admin"
import { WorkflowNodeForm } from "./node-form-panel"
export function buildNodeRegistries(
nodeSpecs: AIWorkflowNodeSpec[]
): WorkflowNodeRegistry[] {
return [
...nodeSpecs.map((spec) => ({
type: spec.type,
info: {
description: spec.description,
},
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: () => <WorkflowNodeForm spec={spec} />,
},
})),
{
type: "comment",
meta: {
sidebarDisabled: true,
nodePanelVisible: false,
defaultPorts: [],
renderKey: "comment",
size: { width: 240, height: 150 },
},
formMeta: {
render: () => <></>,
},
getInputPoints: () => [],
getOutputPoints: () => [],
},
]
}
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 }]
}
@@ -1,57 +0,0 @@
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 })
}
@@ -1,34 +0,0 @@
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
}
@@ -1,69 +0,0 @@
"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]
)
}
@@ -1,155 +0,0 @@
"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>
)
}
@@ -1,273 +0,0 @@
import type {
AIWorkflowDefinition,
AIWorkflowNodeSpec,
AIWorkflowValue,
} from "@/lib/api/admin"
import type { WorkflowNodeJSON } from "@flowgram.ai/free-layout-editor"
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[] = []
): WorkflowNodeJSON {
const id = uniqueNodeID(spec.type, existingNodeIDs)
const config =
spec.type === "condition" ? { branches: [defaultBranch] } : {}
return {
id,
type: spec.type,
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>)
: {}
}
@@ -0,0 +1,80 @@
"use client"
import { useEffect, useRef } from "react"
import type { AIWorkflowDefinition } from "@/lib/api/admin"
const MESSAGE_SOURCE = "agent-desk"
type EditorMessage =
| {
source: typeof MESSAGE_SOURCE
type: "workflow:ready"
}
| {
source: typeof MESSAGE_SOURCE
type: "workflow:change"
document: AIWorkflowDefinition
}
export function OfficialWorkflowEditor({
documentKey,
definition,
onDefinitionChange,
readonly = false,
}: {
documentKey: string
definition: AIWorkflowDefinition
onDefinitionChange: (definition: AIWorkflowDefinition) => void
readonly?: boolean
}) {
const frameRef = useRef<HTMLIFrameElement>(null)
const definitionRef = useRef(definition)
definitionRef.current = definition
function loadDocument() {
frameRef.current?.contentWindow?.postMessage(
{
source: MESSAGE_SOURCE,
type: "workflow:load",
documentKey,
document: definitionRef.current,
readonly,
},
window.location.origin
)
}
useEffect(() => {
const handleMessage = (event: MessageEvent<EditorMessage>) => {
if (
event.origin !== window.location.origin ||
event.source !== frameRef.current?.contentWindow ||
event.data?.source !== MESSAGE_SOURCE
) {
return
}
if (event.data.type === "workflow:ready") {
loadDocument()
} else if (event.data.type === "workflow:change") {
onDefinitionChange(event.data.document)
}
}
window.addEventListener("message", handleMessage)
return () => window.removeEventListener("message", handleMessage)
}, [documentKey, onDefinitionChange, readonly])
useEffect(() => {
loadDocument()
}, [documentKey, readonly])
return (
<iframe
ref={frameRef}
src="/flowgram-editor/index.html"
title="FlowGram 工作流编辑器"
className="h-full min-h-[560px] w-full border-0 bg-white"
/>
)
}
@@ -26,22 +26,20 @@ import { Textarea } from "@/components/ui/textarea"
import {
createAIWorkflow,
fetchAIWorkflow,
fetchAIWorkflowNodeSpecs,
fetchAIWorkflowDefaultDefinition,
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 { WorkflowEditor } from "./editor/workflow-editor"
import { OfficialWorkflowEditor } from "./official-workflow-editor"
const emptyDefinition: AIWorkflowDefinition = {
schemaVersion: 2,
@@ -81,7 +79,6 @@ export function WorkflowWorkbench({
}: 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] =
@@ -90,14 +87,18 @@ export function WorkflowWorkbench({
const [usage, setUsage] = useState<AIWorkflowUsage[]>([])
const [saving, setSaving] = useState(false)
const [dirty, setDirty] = useState(false)
const [loaded, setLoaded] = 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
setLoaded(false)
if (!workflowID) {
setDefinition(await fetchAIWorkflowDefaultDefinition())
setLoaded(true)
return
}
const [item, versionPage, uses] = await Promise.all([
fetchAIWorkflow(workflowID),
@@ -111,8 +112,17 @@ export function WorkflowWorkbench({
setVersions(versionPage.results ?? [])
setUsage(uses ?? [])
setDirty(false)
setLoaded(true)
}, [workflowID])
const handleDefinitionChange = useCallback(
(next: AIWorkflowDefinition) => {
setDefinition(next)
setDirty(true)
},
[]
)
useEffect(() => {
void load().catch((error) =>
toast.error(error instanceof Error ? error.message : "加载工作流失败")
@@ -263,16 +273,11 @@ export function WorkflowWorkbench({
value="editor"
className="min-h-0 flex-1 overflow-hidden data-[state=inactive]:hidden"
>
{nodeSpecs.length ? (
<WorkflowEditor
key={active?.id ?? (workflowID ? `loading-${workflowID}` : "new")}
{loaded ? (
<OfficialWorkflowEditor
documentKey={String(active?.id ?? (workflowID ? `loading-${workflowID}` : "new"))}
definition={definition}
nodeSpecs={nodeSpecs}
onDefinitionChange={(next) => {
setDefinition(next)
setDirty(true)
}}
onValidate={() => validateAIWorkflow(definition)}
onDefinitionChange={handleDefinitionChange}
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
-1
View File
@@ -10,7 +10,6 @@ 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"
+4 -1
View File
@@ -374,9 +374,10 @@ export type AIWorkflowVariableSpec = {
}
export type AIWorkflowDefinition = {
schemaVersion: number
schemaVersion?: number
nodes: AIWorkflowCanvasNode[]
annotations?: AIWorkflowCanvasNode[]
globalVariable?: Record<string, unknown>
edges: {
sourceNodeID: string
targetNodeID: string
@@ -399,6 +400,8 @@ export type AIWorkflowCanvasNode = {
inputsValues?: Record<string, AIWorkflowValue>
[key: string]: unknown
}
blocks?: AIWorkflowCanvasNode[]
edges?: AIWorkflowDefinition["edges"]
}
export type AIWorkflow = {
-10
View File
@@ -16,15 +16,6 @@
"@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",
@@ -53,7 +44,6 @@
"recharts": "2.15.4",
"shadcn": "^4.0.7",
"sonner": "^2.0.7",
"styled-components": "^6.4.3",
"tailwind-merge": "^3.5.0",
"turndown": "^7.2.2",
"tw-animate-css": "^1.4.0",
-877
View File
File diff suppressed because it is too large Load Diff