diff --git a/internal/ai/workflow/dsl/types.go b/internal/ai/workflow/dsl/types.go
index d193648..5f0cc57 100644
--- a/internal/ai/workflow/dsl/types.go
+++ b/internal/ai/workflow/dsl/types.go
@@ -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)
+}
diff --git a/internal/ai/workflow/dsl/types_test.go b/internal/ai/workflow/dsl/types_test.go
index 2b6f30e..e1e4b7c 100644
--- a/internal/ai/workflow/dsl/types_test.go
+++ b/internal/ai/workflow/dsl/types_test.go
@@ -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)
+ }
+}
diff --git a/web/app/dashboard/ai-workflow-runs/_components/workflow-run-audit-graph.tsx b/web/app/dashboard/ai-workflow-runs/_components/workflow-run-audit-graph.tsx
index 1d457f3..183e4af 100644
--- a/web/app/dashboard/ai-workflow-runs/_components/workflow-run-audit-graph.tsx
+++ b/web/app/dashboard/ai-workflow-runs/_components/workflow-run-audit-graph.tsx
@@ -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 (
-
-
-
+
diff --git a/web/app/dashboard/ai-workflows/_components/editor/base-node.tsx b/web/app/dashboard/ai-workflows/_components/editor/base-node.tsx
new file mode 100644
index 0000000..f681a3e
--- /dev/null
+++ b/web/app/dashboard/ai-workflows/_components/editor/base-node.tsx
@@ -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 (
+
+ {
+ 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()}
+
+ {render.ports.map((port) => (
+
+ ))}
+
+ )
+}
diff --git a/web/app/dashboard/ai-workflows/_components/editor/comment-node.tsx b/web/app/dashboard/ai-workflows/_components/editor/comment-node.tsx
new file mode 100644
index 0000000..9e31e03
--- /dev/null
+++ b/web/app/dashboard/ai-workflows/_components/editor/comment-node.tsx
@@ -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()
+ 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 (
+
+
+ {!render.readonly ? (
+
+ ) : null}
+
+
+ )
+}
diff --git a/web/app/dashboard/ai-workflows/_components/editor/editor-canvas-events.tsx b/web/app/dashboard/ai-workflows/_components/editor/editor-canvas-events.tsx
new file mode 100644
index 0000000..c707d9a
--- /dev/null
+++ b/web/app/dashboard/ai-workflows/_components/editor/editor-canvas-events.tsx
@@ -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
+}
diff --git a/web/app/dashboard/ai-workflows/_components/editor/editor-context.tsx b/web/app/dashboard/ai-workflows/_components/editor/editor-context.tsx
new file mode 100644
index 0000000..a2f3042
--- /dev/null
+++ b/web/app/dashboard/ai-workflows/_components/editor/editor-context.tsx
@@ -0,0 +1,49 @@
+"use client"
+
+import { createContext, useContext, type ReactNode } from "react"
+
+import type { WorkflowEditorContextValue } from "./types"
+
+const WorkflowEditorContext = createContext(null)
+const WorkflowEditorSurfaceContext = createContext<"canvas" | "sidebar">("canvas")
+
+export function WorkflowEditorContextProvider({
+ value,
+ children,
+}: {
+ value: WorkflowEditorContextValue
+ children: ReactNode
+}) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function WorkflowEditorSurfaceProvider({
+ surface,
+ children,
+}: {
+ surface: "canvas" | "sidebar"
+ children: ReactNode
+}) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function useWorkflowEditorContext() {
+ const value = useContext(WorkflowEditorContext)
+ if (!value) {
+ throw new Error("WorkflowEditorContext is unavailable")
+ }
+ return value
+}
+
+export function useWorkflowEditorSurface() {
+ return useContext(WorkflowEditorSurfaceContext)
+}
+
diff --git a/web/app/dashboard/ai-workflows/_components/editor/editor-provider.tsx b/web/app/dashboard/ai-workflows/_components/editor/editor-provider.tsx
new file mode 100644
index 0000000..b7768c2
--- /dev/null
+++ b/web/app/dashboard/ai-workflows/_components/editor/editor-provider.tsx
@@ -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) => ,
+ },
+ ]
+
+ 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])
+}
diff --git a/web/app/dashboard/ai-workflows/_components/editor/editor-tools.tsx b/web/app/dashboard/ai-workflows/_components/editor/editor-tools.tsx
new file mode 100644
index 0000000..fc75a09
--- /dev/null
+++ b/web/app/dashboard/ai-workflows/_components/editor/editor-tools.tsx
@@ -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
+ 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(null)
+ const [minimapVisible, setMinimapVisible] = useState(true)
+ const [validating, setValidating] = useState(false)
+ const [interactiveType, setInteractiveType] =
+ useState("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) {
+ 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((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 (
+
+
+
+
+
+ }
+ />
+ }
+ >
+ {interactiveType === ("MOUSE" as InteractiveType) ? (
+
+ ) : (
+
+ )}
+
+
+ {interactiveType === ("MOUSE" as InteractiveType)
+ ? "鼠标友好模式"
+ : "触控板友好模式"}
+
+
+
+ 交互模式
+
+ }
+ title="鼠标友好"
+ description="按住鼠标左键拖动画布,滚轮缩放。"
+ onClick={() =>
+ changeInteractiveType(
+ "MOUSE" as InteractiveType,
+ tools.setInteractiveType,
+ setInteractiveType
+ )
+ }
+ />
+ }
+ title="触控板友好"
+ description="双指移动画布,双指捏合缩放。"
+ onClick={() =>
+ changeInteractiveType(
+ "PAD" as InteractiveType,
+ tools.setInteractiveType,
+ setInteractiveType
+ )
+ }
+ />
+
+
+
+
+
+ void tools.autoLayout({
+ enableAnimation: true,
+ animationDuration: 1000,
+ layoutConfig: { rankdir: "LR", nodesep: 100, ranksep: 100 },
+ })
+ }
+ >
+
+
+
linesManager.switchLineType()}>
+
+
+
+
+
+ }
+ >
+ {Math.floor(tools.zoom * 100)}%
+
+
+ tools.zoomin()}>
+ 放大
+
+ tools.zoomout()}>
+ 缩小
+
+
+ {[0.5, 1, 1.5, 2].map((zoom) => (
+ playground.config.updateZoom(zoom)}
+ >
+ 缩放至 {zoom * 100}%
+
+ ))}
+
+
+
+
tools.fitView()}>
+
+
+
setMinimapVisible((value) => !value)}
+ >
+
+
+ {minimapVisible ? (
+
+
+
+ ) : null}
+
{
+ playground.config.readonly = !playground.config.readonly
+ }}
+ >
+ {readonly ? : }
+
+
void createComment(event)}
+ >
+
+
+
void history.undo()}
+ >
+
+
+
void history.redo()}
+ >
+
+
+
void validate("problem")}
+ >
+
+
+
+
+
+
+ }
+ />
+ }
+ >
+
+
+ 下载
+
+
+ {Object.values(FlowDownloadFormat).map((format) => (
+ void download(format)}
+ >
+ {format.toUpperCase()}
+
+ ))}
+
+
+
+
+
+
+
+
+
+ )
+}
+
+function InteractionOption({
+ selected,
+ icon,
+ title,
+ description,
+ onClick,
+}: {
+ selected: boolean
+ icon: React.ReactNode
+ title: string
+ description: string
+ onClick: () => void
+}) {
+ return (
+
+ )
+}
+
+function ToolButton({
+ label,
+ disabled,
+ active,
+ onClick,
+ children,
+}: {
+ label: string
+ disabled?: boolean
+ active?: boolean
+ onClick: (event: React.MouseEvent) => void
+ children: React.ReactNode
+}) {
+ return (
+
+
+ }
+ >
+ {children}
+
+ {label}
+
+ )
+}
+
+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)
+}
diff --git a/web/app/dashboard/ai-workflows/_components/editor/line-add-button.tsx b/web/app/dashboard/ai-workflows/_components/editor/line-add-button.tsx
new file mode 100644
index 0000000..a44c82c
--- /dev/null
+++ b/web/app/dashboard/ai-workflows/_components/editor/line-add-button.tsx
@@ -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 (
+
+ )
+}
diff --git a/web/app/dashboard/ai-workflows/_components/editor/node-form-panel.tsx b/web/app/dashboard/ai-workflows/_components/editor/node-form-panel.tsx
new file mode 100644
index 0000000..30a31a1
--- /dev/null
+++ b/web/app/dashboard/ai-workflows/_components/editor/node-form-panel.tsx
@@ -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 (
+
+
+
+
+
+ )
+}
+
+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) {
+ render.updateData({ ...data, ...next })
+ }
+
+ return (
+
+
+
+
+
+
+
+ {String(data.title || spec?.title || node.flowNodeType)}
+
+
+
+
+
+
+
+ updateData({ title: event.target.value })}
+ />
+
+
+ updateData({ inputsValues })}
+ />
+ {String(node.flowNodeType) === "knowledge_retrieve" ? (
+ updateData({ config })}
+ />
+ ) : null}
+ {String(node.flowNodeType) === "condition" ? (
+
+ updateData({
+ config: { ...asRecord(data.config), branches },
+ portKeys: branches.map((branch) => branch.id),
+ ports: branches.map((branch) => branch.id),
+ })
+ }
+ />
+ ) : null}
+
+
+ {canDelete ? (
+
+
+
+ ) : null}
+
+ )
+}
+
+function InputSection({
+ spec,
+ inputsValues,
+ variables,
+ onChange,
+}: {
+ spec?: AIWorkflowNodeSpec
+ inputsValues: Record
+ variables: ReturnType
+ onChange: (value: Record) => 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 (
+
+ {spec.inputSchema.map((input) => (
+
+ {
+ const parsed = parseRefKey(value)
+ if (!parsed) return
+ onChange({ ...inputsValues, [input.name]: parsed })
+ }}
+ />
+
+ ))}
+
+ )
+}
+
+function KnowledgeSection({
+ config,
+ onChange,
+}: {
+ config: Record
+ onChange: (value: Record) => void
+}) {
+ const [items, setItems] = useState([])
+ 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 (
+
+
+ ({
+ value: String(item.id),
+ label: item.name,
+ }))}
+ placeholder="选择知识库"
+ searchPlaceholder="搜索知识库"
+ onValuesChange={(next) =>
+ onChange({
+ ...config,
+ knowledgeBaseIds: next.map(Number).filter((id) => id > 0),
+ })
+ }
+ />
+
+
+ )
+}
+
+function ConditionSection({
+ branches,
+ variables,
+ onChange,
+}: {
+ branches: WorkflowConditionBranch[]
+ variables: ReturnType
+ 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 (
+ {
+ const next: WorkflowConditionBranch = {
+ id: nextBranchID(branches),
+ name: `条件 ${regular.length + 1}`,
+ targetNodeId: "",
+ condition: { operator: "eq" },
+ }
+ onChange([...regular, next, fallback].filter(Boolean) as WorkflowConditionBranch[])
+ }}
+ >
+
+ 添加
+
+ }
+ >
+ {[...regular, ...(fallback ? [fallback] : [])].map((branch) => (
+
+
+ update({ ...branch, name: event.target.value })}
+ />
+ {!branch.default ? (
+
+ ) : null}
+
+ {branch.default ? (
+
其他条件均不匹配时进入此分支。
+ ) : (
+
+
+ update({
+ ...branch,
+ condition: {
+ ...branch.condition,
+ left: parseRefKey(value),
+ },
+ })
+ }
+ />
+
+ update({
+ ...branch,
+ condition: { ...branch.condition, operator },
+ })
+ }
+ />
+ {!["exists", "empty"].includes(branch.condition?.operator ?? "") ? (
+
+ update({
+ ...branch,
+ condition: {
+ ...branch.condition,
+ right: event.target.value,
+ },
+ })
+ }
+ />
+ ) : null}
+
+ )}
+
+ ))}
+
+ )
+}
+
+function OutputSection({ spec }: { spec?: AIWorkflowNodeSpec }) {
+ if (!spec?.outputSchema?.length) return null
+ return (
+
+
+ {spec.outputSchema.map((output) => (
+
+
+ {output.label || output.name}
+ {output.type}
+
+ {output.description ? (
+
{output.description}
+ ) : null}
+
+ ))}
+
+
+ )
+}
+
+function FormSection({
+ title,
+ action,
+ children,
+}: {
+ title: string
+ action?: React.ReactNode
+ children: React.ReactNode
+}) {
+ return (
+
+
+
{title}
+ {action}
+
+ {children}
+
+ )
+}
+
+function FormField({
+ label,
+ required,
+ hint,
+ children,
+}: {
+ label: string
+ required?: boolean
+ hint?: string
+ children: React.ReactNode
+}) {
+ return (
+
+
+ {children}
+ {hint ?
{hint}
: null}
+
+ )
+}
+
+function asRecord(value: unknown): Record {
+ return value && typeof value === "object" && !Array.isArray(value)
+ ? (value as Record)
+ : {}
+}
+
+function normalizeIDs(value: unknown) {
+ if (!Array.isArray(value)) return []
+ return Array.from(
+ new Set(value.map(Number).filter((item) => Number.isInteger(item) && item > 0))
+ )
+}
diff --git a/web/app/dashboard/ai-workflows/_components/editor/node-icon.tsx b/web/app/dashboard/ai-workflows/_components/editor/node-icon.tsx
new file mode 100644
index 0000000..3c0c4dd
--- /dev/null
+++ b/web/app/dashboard/ai-workflows/_components/editor/node-icon.tsx
@@ -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 = {
+ 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
+}
+
diff --git a/web/app/dashboard/ai-workflows/_components/editor/node-panel.tsx b/web/app/dashboard/ai-workflows/_components/editor/node-panel.tsx
new file mode 100644
index 0000000..59e9b87
--- /dev/null
+++ b/web/app/dashboard/ai-workflows/_components/editor/node-panel.tsx
@@ -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)
+ }
+
+ return (
+ <>
+
+
+
+ {visibleSpecs.map((spec) => (
+
+ ))}
+
+
+ >
+ )
+}
diff --git a/web/app/dashboard/ai-workflows/_components/editor/node-registry.tsx b/web/app/dashboard/ai-workflows/_components/editor/node-registry.tsx
new file mode 100644
index 0000000..d827400
--- /dev/null
+++ b/web/app/dashboard/ai-workflows/_components/editor/node-registry.tsx
@@ -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: () => ,
+ },
+ })),
+ {
+ 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 (
+ name="title">
+ {({ field }) => (
+
+
+
+
+
+
+ {field.value || spec.title}
+
+
+
+
+
+
+ {spec.description}
+
+
+
+ {spec.type === "condition" ? (
+
> name="config">
+ {({ field: configField }) => {
+ const branches = normalizeConditionBranches({
+ data: { config: configField.value },
+ })
+ return (
+
+ {branches.map((branch, index) => (
+
+
+ {branch.default ? "else" : index === 0 ? "if" : "elif"}
+
+
+ {branch.name || branch.id}
+
+
+
+ ))}
+
+ )
+ }}
+
+ ) : null}
+
+
+ )}
+
+ )
+}
+
+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 }]
+}
diff --git a/web/app/dashboard/ai-workflows/_components/editor/on-drag-line-end.ts b/web/app/dashboard/ai-workflows/_components/editor/on-drag-line-end.ts
new file mode 100644
index 0000000..b9a866b
--- /dev/null
+++ b/web/app/dashboard/ai-workflows/_components/editor/on-drag-line-end.ts
@@ -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 })
+}
diff --git a/web/app/dashboard/ai-workflows/_components/editor/types.ts b/web/app/dashboard/ai-workflows/_components/editor/types.ts
new file mode 100644
index 0000000..726c63b
--- /dev/null
+++ b/web/app/dashboard/ai-workflows/_components/editor/types.ts
@@ -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
+}
+
diff --git a/web/app/dashboard/ai-workflows/_components/editor/use-port-click.ts b/web/app/dashboard/ai-workflows/_components/editor/use-port-click.ts
new file mode 100644
index 0000000..ab7e13f
--- /dev/null
+++ b/web/app/dashboard/ai-workflows/_components/editor/use-port-click.ts
@@ -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]
+ )
+}
diff --git a/web/app/dashboard/ai-workflows/_components/editor/workflow-editor.tsx b/web/app/dashboard/ai-workflows/_components/editor/workflow-editor.tsx
new file mode 100644
index 0000000..3878095
--- /dev/null
+++ b/web/app/dashboard/ai-workflows/_components/editor/workflow-editor.tsx
@@ -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
+}) {
+ const [validation, setValidation] =
+ useState(null)
+ const props = useWorkflowEditorProps({
+ definition,
+ nodeSpecs,
+ onDefinitionChange,
+ })
+
+ return (
+
+
+
+
+
+
+
+
+ {validation ? (
+ setValidation(null)}
+ />
+ ) : null}
+
+
+
+ )
+}
+
+function ProblemPanel({
+ validation,
+ onClose,
+}: {
+ validation: AIWorkflowValidationResult
+ onClose: () => void
+}) {
+ return (
+
+
+
+ 问题
+ {validation.valid ? (
+
+ ) : (
+
+ {validation.errors.length}
+
+ )}
+
+
+
+
+ {validation.valid ? (
+
+ 未发现问题
+
+ ) : (
+
+ {validation.errors.map((error, index) => (
+
+
+
+
+ {error.field}
+
+
{error.message}
+
+
+ ))}
+
+ )}
+
+
+ )
+}
+
+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 (
+
+
+
+
+
+ )
+}
diff --git a/web/app/dashboard/ai-workflows/_components/editor/workflow-model.ts b/web/app/dashboard/ai-workflows/_components/editor/workflow-model.ts
new file mode 100644
index 0000000..11e95ce
--- /dev/null
+++ b/web/app/dashboard/ai-workflows/_components/editor/workflow-model.ts
@@ -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>()
+ 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
+): 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
+ 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()
+ 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()
+ 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 {
+ return value && typeof value === "object" && !Array.isArray(value)
+ ? (value as Record)
+ : {}
+}
diff --git a/web/app/dashboard/ai-workflows/_components/flowgram-editor-provider.tsx b/web/app/dashboard/ai-workflows/_components/flowgram-editor-provider.tsx
deleted file mode 100644
index dff3f43..0000000
--- a/web/app/dashboard/ai-workflows/_components/flowgram-editor-provider.tsx
+++ /dev/null
@@ -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(
- () => {
- 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]
-}
diff --git a/web/app/dashboard/ai-workflows/_components/flowgram-node-registries.tsx b/web/app/dashboard/ai-workflows/_components/flowgram-node-registries.tsx
deleted file mode 100644
index 1a136e7..0000000
--- a/web/app/dashboard/ai-workflows/_components/flowgram-node-registries.tsx
+++ /dev/null
@@ -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()
- 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: () => (
-
- ),
- },
- }))
-}
-
-function FlowgramNodeForm({
- nodeType,
- fallbackTitle,
- icon,
-}: {
- nodeType: string
- fallbackTitle: string
- icon: string
-}) {
- const { node, selected } = useNodeRender()
- const nodeId = String(node.id ?? "")
-
- return (
- name="title">
- {({ field }) => (
-
- {nodeType === "condition" ? (
- > name="config">
- {({ field: configField }) => (
-
- )}
-
- ) : null}
-
- )}
-
- )
-}
-
-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 }]
-}
diff --git a/web/app/dashboard/ai-workflows/_components/flowgram-node-renderer.tsx b/web/app/dashboard/ai-workflows/_components/flowgram-node-renderer.tsx
deleted file mode 100644
index df1d4b2..0000000
--- a/web/app/dashboard/ai-workflows/_components/flowgram-node-renderer.tsx
+++ /dev/null
@@ -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 (
- {
- if (port.portType !== "output" || typeof event === "function") {
- return
- }
- event.stopPropagation()
- requestPortAdd?.({ sourcePort: port, event })
- }}
- >
- {form?.render()}
-
- )
-}
diff --git a/web/app/dashboard/ai-workflows/_components/node-config-panel.tsx b/web/app/dashboard/ai-workflows/_components/node-config-panel.tsx
deleted file mode 100644
index 00dcf51..0000000
--- a/web/app/dashboard/ai-workflows/_components/node-config-panel.tsx
+++ /dev/null
@@ -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 (
-
- 未选择节点
-
- )
- }
-
- 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) => {
- onChange(node.id, {
- ...(node.data ?? {}),
- ...data,
- })
- }
- const updateConfig = (nextConfig: Record) => updateData({ config: nextConfig })
- const inputFields = inputSchema.map((input) => {
- const value = inputsValues[input.name]
- return (
-
- {
- updateData({
- inputsValues: {
- ...inputsValues,
- [input.name]: next,
- },
- })
- }}
- />
- {input.description ? {input.description} : null}
-
- )
- })
- const outputFields = outputSchema.map((output) => {
- const item = buildVariableSpecDisplay(output)
- return (
-
- {item.description ? {item.description} : null}
-
- )
- })
- 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 (
-
- {showHeader ? (
-
-
-
-
- {node.data?.title || nodeSpec?.title || node.type}
-
-
{node.id}
-
- {canDelete ? (
-
- ) : null}
-
-
- ) : null}
-
-
-
- {node.type === "knowledge_retrieve" ? (
- updateConfig(nextConfig)}
- />
- ) : null}
-
- {showConditionBranches && (node.type === "condition" || branches.length > 0) ? (
-
- ) : null}
-
-
- )
-}
-
-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 (
-
- 条件分支不存在
-
- )
- }
-
- const updateBranch = (nextBranch: WorkflowConditionBranch) => {
- onChange(node.id, {
- ...(node.data ?? {}),
- config: {
- ...config,
- branches: branches.map((item) => (item.id === nextBranch.id ? nextBranch : item)),
- },
- })
- }
-
- return (
-
-
-
-
- updateBranch({ ...branch, targetNodeId })}
- />
-
-
-
-
-
-
-
- {branch.default ? (
-
-
- 默认分支不需要条件表达式,会在其他条件不匹配时执行。
-
-
- ) : (
-
-
-
- )}
-
-
- )
-}
-
-function KnowledgeRetrieveConfigPanel({
- config,
- onChange,
-}: {
- config: Record
- onChange: (config: Record) => void
-}) {
- const [knowledgeBases, setKnowledgeBases] = useState([])
- 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 (
-
-
-
-
-
- }
- >
-
- {selectedKnowledgeOptions.length === 0
- ? "选择知识库"
- : selectedKnowledgeOptions.length === 1
- ? selectedKnowledgeOptions[0].label
- : `已选择 ${selectedKnowledgeOptions.length} 个知识库`}
-
-
-
-
-
-
-
- 没有可用知识库
-
- {knowledgeOptions.map((option) => {
- const selected = selectedKnowledgeIds.includes(Number(option.value))
- return (
- toggleKnowledgeBase(option.value)}
- >
-
- {option.label}
-
- )
- })}
-
-
-
-
-
- {selectedKnowledgeOptions.length === 0 ? (
-
- 未选择知识库,流程发布校验不会通过。
-
- ) : null}
-
-
-
- )
-}
-
-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 (
-
- 添加
-
- }
- >
- {branches.length === 0 ? (
-
- 暂无分支。条件节点需要至少一个默认分支或条件分支。
-
- ) : null}
-
- {branches.map((branch) => {
- return (
-
-
-
- {branch.default ? "ELSE" : "IF"}
-
- onChange({ ...branch, name: event.target.value })}
- />
- {branch.default ? null : (
-
- )}
-
-
-
-
目标节点
-
onChange({ ...branch, targetNodeId })}
- />
-
-
-
-
- {branch.default ? null : (
-
-
-
- )}
-
- )
- })}
-
-
- )
-}
-
-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 (
-
-
-
左值
-
onChange({
- ...branch,
- condition: { ...condition, left },
- })}
- />
-
-
-
判断
-
- onChange({
- ...branch,
- condition: { ...condition, operator },
- })}
- />
-
-
- {valueOptions.length > 0 && !rightDisabled ? (
- ({
- 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 },
- })
- }}
- />
- ) : (
- onChange({
- ...branch,
- condition: { ...condition, right: event.target.value },
- })}
- />
- )}
-
-
-
- )
-}
-
-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 (
-
- {tabs[0].content}
-
- )
- }
-
- return (
-
-
-
-
- {tabs.map((tab) => (
-
- {tab.label}
- {tab.count}
-
- ))}
-
-
- {tabs.map((tab) => (
-
- {tab.content}
-
- ))}
-
-
- )
-}
-
-function InspectorSection({
- title,
- meta,
- action,
- children,
-}: {
- title: string
- meta?: string
- action?: ReactNode
- children: ReactNode
-}) {
- return (
-
-
-
-
- {meta ? {meta} : null}
- {action}
-
-
- {children}
-
- )
-}
-
-function InspectorRow({
- label,
- detail,
- required,
- children,
-}: {
- label: string
- detail?: string
- required?: boolean
- children: ReactNode
-}) {
- return (
-
-
-
- {label}
- {required ? * : null}
-
- {detail ?
{detail}
: null}
-
-
{children}
-
- )
-}
-
-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 (
-
-
-
-
- {label}
- {required ? * : null}
-
- {detail ?
{detail}
: null}
-
-
- {metaItems.length > 0 ? (
-
- {metaItems.map((item) => (
-
- {item.label}
- {item.value}
-
- ))}
-
- ) : null}
-
{children}
-
- )
-}
-
-function InspectorHint({ children }: { children: ReactNode }) {
- return {children}
-}
-
-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)))
-}
diff --git a/web/app/dashboard/ai-workflows/_components/variable-selector.tsx b/web/app/dashboard/ai-workflows/_components/variable-selector.tsx
deleted file mode 100644
index 3aa9c77..0000000
--- a/web/app/dashboard/ai-workflows/_components/variable-selector.tsx
+++ /dev/null
@@ -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 (
- {
- const variable = variables.find((item) => `${item.nodeId}.${item.field}` === next)
- if (variable) {
- onChange(createRefValue(variable.nodeId, variable.field))
- }
- }}
- />
- )
-}
diff --git a/web/app/dashboard/ai-workflows/_components/workflow-branch-selection.tsx b/web/app/dashboard/ai-workflows/_components/workflow-branch-selection.tsx
deleted file mode 100644
index c128db0..0000000
--- a/web/app/dashboard/ai-workflows/_components/workflow-branch-selection.tsx
+++ /dev/null
@@ -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({
- selectedBranch: null,
- onSelectBranch: () => {},
-})
-
-export function WorkflowBranchSelectionProvider({
- selectedBranch,
- onSelectBranch,
- children,
-}: WorkflowBranchSelectionContextValue & {
- children: ReactNode
-}) {
- const value = useMemo(
- () => ({ selectedBranch, onSelectBranch }),
- [onSelectBranch, selectedBranch]
- )
-
- return (
-
- {children}
-
- )
-}
-
-export function useWorkflowBranchSelection() {
- return useContext(WorkflowBranchSelectionContext)
-}
diff --git a/web/app/dashboard/ai-workflows/_components/workflow-canvas-controls.tsx b/web/app/dashboard/ai-workflows/_components/workflow-canvas-controls.tsx
deleted file mode 100644
index 073eddb..0000000
--- a/web/app/dashboard/ai-workflows/_components/workflow-canvas-controls.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
-
-
- }
- >
- {zoomPercent ?? "100%"}
-
- 重置为 100%
-
-
-
-
-
-
-
-
-
-
-
-
- )
-}
-
-function CanvasControlButton({
- label,
- onClick,
- disabled = false,
- children,
-}: {
- label: string
- onClick?: () => void
- disabled?: boolean
- children: ReactNode
-}) {
- return (
-
-
- }
- >
- {children}
-
- {label}
-
- )
-}
diff --git a/web/app/dashboard/ai-workflows/_components/workflow-condition-node-content.tsx b/web/app/dashboard/ai-workflows/_components/workflow-condition-node-content.tsx
deleted file mode 100644
index 9b4534e..0000000
--- a/web/app/dashboard/ai-workflows/_components/workflow-condition-node-content.tsx
+++ /dev/null
@@ -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 | undefined
- nodeId: string
- onChange: (value: Record) => 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 (
-
-
- {branches.map((branch, index) => (
- onSelectBranch?.({ nodeId, branchId: branch.id })}
- onDelete={() => deleteBranch(branch.id)}
- />
- ))}
-
-
-
- )
-}
-
-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 (
- {
- event.stopPropagation()
- if (isBranchRowActionTarget(event.target)) {
- return
- }
- onSelect()
- }}
- onMouseDownCapture={(event) => {
- event.stopPropagation()
- }}
- onClick={(event) => {
- event.stopPropagation()
- }}
- >
-
- {branchType}
-
-
- {branch.name || (branch.default ? "默认分支" : branch.id)}
-
- {branch.default ? null : (
-
- )}
-
-
- )
-}
-
-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),
- ]
-}
diff --git a/web/app/dashboard/ai-workflows/_components/workflow-config-sidebar.tsx b/web/app/dashboard/ai-workflows/_components/workflow-config-sidebar.tsx
deleted file mode 100644
index 2201134..0000000
--- a/web/app/dashboard/ai-workflows/_components/workflow-config-sidebar.tsx
+++ /dev/null
@@ -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) => {
- 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 (
-
-
-
-
-
-
-
-
-
-
- updatePanelTitle(event.target.value)}
- />
-
-
-
-
- {panelDescription ? (
-
- {panelDescription}
-
- ) : null}
-
-
-
- {selectedBranchItem && selectedBranch ? (
-
- ) : (
-
- )}
-
-
-
-
- )
-}
diff --git a/web/app/dashboard/ai-workflows/_components/workflow-editor-status.tsx b/web/app/dashboard/ai-workflows/_components/workflow-editor-status.tsx
deleted file mode 100644
index 45493f6..0000000
--- a/web/app/dashboard/ai-workflows/_components/workflow-editor-status.tsx
+++ /dev/null
@@ -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 (
-
-
- {validation.valid ? (
-
- ) : (
-
- )}
- {validation.valid ? "检查通过" : `${validation.errors.length} 个问题`}
-
-
/
-
{nodeCount} 节点
-
·
-
{edgeCount} 连线
-
- )
-}
diff --git a/web/app/dashboard/ai-workflows/_components/workflow-editor-toolbar.tsx b/web/app/dashboard/ai-workflows/_components/workflow-editor-toolbar.tsx
deleted file mode 100644
index c396446..0000000
--- a/web/app/dashboard/ai-workflows/_components/workflow-editor-toolbar.tsx
+++ /dev/null
@@ -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 (
-
-
- {toolbarExtra}
-
-
-
-
-
-
-
-
-
-
-
- )
-}
diff --git a/web/app/dashboard/ai-workflows/_components/workflow-editor.tsx b/web/app/dashboard/ai-workflows/_components/workflow-editor.tsx
deleted file mode 100644
index 1c8d9de..0000000
--- a/web/app/dashboard/ai-workflows/_components/workflow-editor.tsx
+++ /dev/null
@@ -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(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 (
-
-
- {
- 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}
- />
-
-
- )
-}
-
-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
- 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(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 (
- {
- if (shouldClearWorkflowSelectionOnPointerDown(event.target)) {
- clearSelectionFromCanvas()
- }
- }}
- >
-
- 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}
- />
-
-
-
- playgroundTools.zoomin(true)}
- onZoomOut={() => playgroundTools.zoomout(true)}
- onResetZoom={resetZoom}
- onFitView={() => playgroundTools.fitView(true)}
- onAutoLayout={() => void autoLayout()}
- autoLayoutDisabled={autoLayouting || definition.nodes.length < 2}
- />
-
-
-
-
-
-
-
-
-
-
-
void addNodeFromPort(spec)}
- onClose={() => setNodeMenu(null)}
- />
-
-
-
- )
-}
-
-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,
- }
-}
diff --git a/web/app/dashboard/ai-workflows/_components/workflow-line-add-button.tsx b/web/app/dashboard/ai-workflows/_components/workflow-line-add-button.tsx
deleted file mode 100644
index 3c48686..0000000
--- a/web/app/dashboard/ai-workflows/_components/workflow-line-add-button.tsx
+++ /dev/null
@@ -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 (
-
- )
-}
diff --git a/web/app/dashboard/ai-workflows/_components/workflow-node-card.tsx b/web/app/dashboard/ai-workflows/_components/workflow-node-card.tsx
deleted file mode 100644
index 0f0206e..0000000
--- a/web/app/dashboard/ai-workflows/_components/workflow-node-card.tsx
+++ /dev/null
@@ -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 (
-
-
-
- {children ?
{children}
: null}
-
-
- )
-}
diff --git a/web/app/dashboard/ai-workflows/_components/workflow-node-icon.tsx b/web/app/dashboard/ai-workflows/_components/workflow-node-icon.tsx
deleted file mode 100644
index dfacd5b..0000000
--- a/web/app/dashboard/ai-workflows/_components/workflow-node-icon.tsx
+++ /dev/null
@@ -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
-
-export function WorkflowNodeIcon({
- icon,
- size = "md",
- className,
-}: {
- icon?: string
- size?: "sm" | "md"
- className?: string
-}) {
- const Icon = icon ? lucideIconComponents[icon] ?? FileTextIcon : FileTextIcon
-
- return (
-
-
-
- )
-}
diff --git a/web/app/dashboard/ai-workflows/_components/workflow-port-add-context.tsx b/web/app/dashboard/ai-workflows/_components/workflow-port-add-context.tsx
deleted file mode 100644
index 6024d8c..0000000
--- a/web/app/dashboard/ai-workflows/_components/workflow-port-add-context.tsx
+++ /dev/null
@@ -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 (
-
- {children}
-
- )
-}
-
-export function useWorkflowPortAdd() {
- return useContext(WorkflowPortAddContext)
-}
diff --git a/web/app/dashboard/ai-workflows/_components/workflow-port-node-menu.tsx b/web/app/dashboard/ai-workflows/_components/workflow-port-node-menu.tsx
deleted file mode 100644
index c6e7e7d..0000000
--- a/web/app/dashboard/ai-workflows/_components/workflow-port-node-menu.tsx
+++ /dev/null
@@ -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(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 (
- event.stopPropagation()}
- >
-
- 添加节点
-
-
-
- {insertableNodeSpecs.map((spec) => (
-
- ))}
-
-
-
- )
-}
diff --git a/web/app/dashboard/ai-workflows/_components/workflow-utils.test.mjs b/web/app/dashboard/ai-workflows/_components/workflow-utils.test.mjs
deleted file mode 100644
index 09dbdd2..0000000
--- a/web/app/dashboard/ai-workflows/_components/workflow-utils.test.mjs
+++ /dev/null
@@ -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