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