新版本流程编辑器
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
-877
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user