refactor workflow
This commit is contained in:
@@ -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>)
|
||||
: {}
|
||||
}
|
||||
Reference in New Issue
Block a user