refactor workflow
This commit is contained in:
@@ -7,6 +7,7 @@ const SchemaVersion = 2
|
|||||||
type Definition struct {
|
type Definition struct {
|
||||||
SchemaVersion int `json:"schemaVersion"`
|
SchemaVersion int `json:"schemaVersion"`
|
||||||
Nodes []Node `json:"nodes"`
|
Nodes []Node `json:"nodes"`
|
||||||
|
Annotations []Node `json:"annotations,omitempty"`
|
||||||
Edges []Edge `json:"edges"`
|
Edges []Edge `json:"edges"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,3 +188,22 @@ func (d *NodeData) UnmarshalJSON(data []byte) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d NodeData) MarshalJSON() ([]byte, error) {
|
||||||
|
type alias NodeData
|
||||||
|
base, err := json.Marshal(alias(d))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
values := make(map[string]json.RawMessage)
|
||||||
|
if err := json.Unmarshal(base, &values); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for key, value := range d.Extra {
|
||||||
|
if _, exists := values[key]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
values[key] = value
|
||||||
|
}
|
||||||
|
return json.Marshal(values)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package dsl_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"agent-desk/internal/ai/workflow/dsl"
|
"agent-desk/internal/ai/workflow/dsl"
|
||||||
@@ -81,3 +82,32 @@ func TestDefinitionUnmarshalsFlowGramStyleSchema(t *testing.T) {
|
|||||||
t.Fatalf("unexpected edge: %#v", edge)
|
t.Fatalf("unexpected edge: %#v", edge)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDefinitionPreservesCanvasAnnotations(t *testing.T) {
|
||||||
|
var def dsl.Definition
|
||||||
|
err := json.Unmarshal([]byte(`{
|
||||||
|
"schemaVersion": 2,
|
||||||
|
"nodes": [],
|
||||||
|
"annotations": [{
|
||||||
|
"id": "comment_1",
|
||||||
|
"type": "comment",
|
||||||
|
"meta": {"position": {"x": 12, "y": 34}},
|
||||||
|
"data": {"note": "check this branch", "size": {"width": 240, "height": 150}}
|
||||||
|
}],
|
||||||
|
"edges": []
|
||||||
|
}`), &def)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unmarshal definition: %v", err)
|
||||||
|
}
|
||||||
|
if len(def.Annotations) != 1 || def.Annotations[0].ID != "comment_1" {
|
||||||
|
t.Fatalf("expected annotation to be preserved, got %#v", def.Annotations)
|
||||||
|
}
|
||||||
|
encoded, err := json.Marshal(def)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal definition: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(encoded), `"annotations"`) ||
|
||||||
|
!strings.Contains(string(encoded), `"check this branch"`) {
|
||||||
|
t.Fatalf("expected annotation JSON to round trip, got %s", encoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
|
|
||||||
import { EditorRenderer, FreeLayoutEditorProvider } from "@flowgram.ai/free-layout-editor"
|
|
||||||
import { AlertTriangleIcon, CheckCircle2Icon, TimerIcon } from "lucide-react"
|
import { AlertTriangleIcon, CheckCircle2Icon, TimerIcon } from "lucide-react"
|
||||||
|
|
||||||
import { JsonTreeViewer } from "@/components/json-tree-viewer"
|
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 type { AIWorkflowNodeRun, AIWorkflowRun } from "@/lib/api/admin"
|
||||||
import { cn } from "@/lib/utils"
|
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 }) {
|
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 firstNodeId = nodeRuns[0]?.nodeId ?? run.definition?.nodes?.[0]?.id ?? ""
|
||||||
const [selectedNodeId, setSelectedNodeId] = useState(firstNodeId)
|
const [selectedNodeId, setSelectedNodeId] = useState(firstNodeId)
|
||||||
const selectedNodeRun = nodeRuns.find((item) => item.nodeId === selectedNodeId) ?? nodeRuns[0]
|
const selectedNodeRun = nodeRuns.find((item) => item.nodeId === selectedNodeId) ?? nodeRuns[0]
|
||||||
const executedNodeIds = useMemo(() => new Set(nodeRuns.map((item) => item.nodeId)), [nodeRuns])
|
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 (
|
return (
|
||||||
<div className="grid min-h-[520px] grid-cols-[minmax(0,1fr)_320px] overflow-hidden border">
|
<div className="grid min-h-[520px] grid-cols-[minmax(0,1fr)_320px] overflow-hidden border">
|
||||||
<div className="relative min-w-0">
|
<div className="relative min-w-0">
|
||||||
<FreeLayoutEditorProvider {...editorProps}>
|
<WorkflowReadonlyCanvas
|
||||||
<EditorRenderer className="h-full w-full" />
|
definition={run.definition ?? { schemaVersion: 2, nodes: [], edges: [] }}
|
||||||
</FreeLayoutEditorProvider>
|
/>
|
||||||
<div className="pointer-events-none absolute left-3 top-3 flex flex-wrap gap-2">
|
<div className="pointer-events-none absolute left-3 top-3 flex flex-wrap gap-2">
|
||||||
<Badge variant="secondary" className="gap-1">
|
<Badge variant="secondary" className="gap-1">
|
||||||
<CheckCircle2Icon className="size-3" />
|
<CheckCircle2Icon className="size-3" />
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
/* eslint-disable react-hooks/refs -- FlowGram useNodeRender exposes reactive render state through a ref-backed adapter. */
|
||||||
|
|
||||||
|
import { useLayoutEffect, useState } from "react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
WorkflowPortRender,
|
||||||
|
type WorkflowNodeProps,
|
||||||
|
useNodeRender,
|
||||||
|
} from "@flowgram.ai/free-layout-editor"
|
||||||
|
import { usePanelManager } from "@flowgram.ai/panel-manager-plugin"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
import { WorkflowEditorSurfaceProvider } from "./editor-context"
|
||||||
|
import { usePortClick } from "./use-port-click"
|
||||||
|
|
||||||
|
export const NODE_FORM_PANEL = "workflow-node-form"
|
||||||
|
|
||||||
|
export function BaseNode(props: WorkflowNodeProps) {
|
||||||
|
const render = useNodeRender(props.node)
|
||||||
|
const panelManager = usePanelManager()
|
||||||
|
const onPortClick = usePortClick()
|
||||||
|
const [dragging, setDragging] = useState(false)
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (String(render.node.flowNodeType) !== "condition") return
|
||||||
|
const frame = window.requestAnimationFrame(() => {
|
||||||
|
render.node.ports.updateDynamicPorts()
|
||||||
|
})
|
||||||
|
return () => window.cancelAnimationFrame(frame)
|
||||||
|
}, [render.data, render.node])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WorkflowEditorSurfaceProvider surface="canvas">
|
||||||
|
<div
|
||||||
|
ref={render.nodeRef}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-[360px] flex-col rounded-lg border bg-white",
|
||||||
|
"border-[rgba(6,7,9,0.15)] shadow-[0_2px_6px_rgba(0,0,0,0.04),0_4px_12px_rgba(0,0,0,0.02)]",
|
||||||
|
render.selected && "border-[#4e40e5]"
|
||||||
|
)}
|
||||||
|
draggable={!render.readonly}
|
||||||
|
onDragStart={(event) => {
|
||||||
|
render.startDrag(event)
|
||||||
|
setDragging(true)
|
||||||
|
}}
|
||||||
|
onTouchStart={(event) => {
|
||||||
|
render.startDrag(event as unknown as React.MouseEvent)
|
||||||
|
setDragging(true)
|
||||||
|
}}
|
||||||
|
onMouseUp={() => setDragging(false)}
|
||||||
|
onClick={(event) => {
|
||||||
|
render.selectNode(event)
|
||||||
|
if (!render.readonly && !dragging) {
|
||||||
|
panelManager.open(NODE_FORM_PANEL, "docked-right", {
|
||||||
|
props: { nodeId: render.node.id },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onFocus={render.onFocus}
|
||||||
|
onBlur={render.onBlur}
|
||||||
|
>
|
||||||
|
{render.form?.render()}
|
||||||
|
</div>
|
||||||
|
{render.ports.map((port) => (
|
||||||
|
<WorkflowPortRender
|
||||||
|
key={port.id}
|
||||||
|
entity={port}
|
||||||
|
onClick={render.readonly ? undefined : onPortClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</WorkflowEditorSurfaceProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
/* eslint-disable react-hooks/refs -- FlowGram useNodeRender exposes reactive render state through a ref-backed adapter. */
|
||||||
|
|
||||||
|
import { useLayoutEffect } from "react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FlowNodeFormData,
|
||||||
|
Form,
|
||||||
|
type FormModelV2,
|
||||||
|
type WorkflowNodeProps,
|
||||||
|
useNodeRender,
|
||||||
|
} from "@flowgram.ai/free-layout-editor"
|
||||||
|
import { Trash2Icon } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
type CommentSize = {
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommentNode(props: WorkflowNodeProps) {
|
||||||
|
const render = useNodeRender(props.node)
|
||||||
|
const formModel = render.node
|
||||||
|
.getData(FlowNodeFormData)
|
||||||
|
.getFormModel<FormModelV2>()
|
||||||
|
const size = (formModel?.getValueIn("size") ?? {}) as CommentSize
|
||||||
|
const width = Math.max(120, Number(size.width) || 240)
|
||||||
|
const height = Math.max(80, Number(size.height) || 150)
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
render.node.transform.update({
|
||||||
|
size: { width, height },
|
||||||
|
})
|
||||||
|
}, [height, render.node, width])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={render.nodeRef}
|
||||||
|
className={cn(
|
||||||
|
"group relative rounded-lg border border-[#f5c451] bg-[#fff9dc] p-2 text-[#594a16] shadow-sm",
|
||||||
|
render.selected && "border-[#f59e0b] ring-1 ring-[#f59e0b]/25"
|
||||||
|
)}
|
||||||
|
style={{ width, height }}
|
||||||
|
onMouseDown={render.selectNode}
|
||||||
|
onFocus={render.onFocus}
|
||||||
|
onBlur={render.onBlur}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-x-0 top-0 h-7 cursor-move"
|
||||||
|
draggable={!render.readonly}
|
||||||
|
onDragStart={render.startDrag}
|
||||||
|
/>
|
||||||
|
{!render.readonly ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
aria-label="删除注释"
|
||||||
|
className="absolute right-1 top-1 z-10 text-[#a46b00] opacity-0 hover:bg-[#f5c451]/20 group-hover:opacity-100"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
render.deleteNode()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2Icon />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Form control={formModel?.formControl}>
|
||||||
|
<Field<string> name="note">
|
||||||
|
{({ field }) => (
|
||||||
|
<textarea
|
||||||
|
value={field.value ?? ""}
|
||||||
|
readOnly={render.readonly}
|
||||||
|
aria-label="注释内容"
|
||||||
|
placeholder="输入注释..."
|
||||||
|
className="relative mt-5 h-[calc(100%-1.25rem)] w-full resize-none border-0 bg-transparent p-0 text-sm leading-6 outline-none placeholder:text-[#9a8650]"
|
||||||
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
|
onChange={(event) => field.onChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect } from "react"
|
||||||
|
|
||||||
|
import { WorkflowNodePanelService } from "@flowgram.ai/free-node-panel-plugin"
|
||||||
|
import {
|
||||||
|
useClientContext,
|
||||||
|
useService,
|
||||||
|
WorkflowDragService,
|
||||||
|
WorkflowSelectService,
|
||||||
|
type WorkflowNodeEntity,
|
||||||
|
type WorkflowNodeJSON,
|
||||||
|
} from "@flowgram.ai/free-layout-editor"
|
||||||
|
|
||||||
|
export function EditorCanvasEvents() {
|
||||||
|
const context = useClientContext()
|
||||||
|
const nodePanel = useService(WorkflowNodePanelService)
|
||||||
|
const selection = useService(WorkflowSelectService)
|
||||||
|
const dragService = useService(WorkflowDragService)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = context.playground.node
|
||||||
|
const handleContextMenu = (event: MouseEvent) => {
|
||||||
|
if (context.playground.config.readonlyOrDisabled) return
|
||||||
|
const position = context.playground.config.getPosFromMouseEvent(event)
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
void nodePanel.callNodePanel({
|
||||||
|
position,
|
||||||
|
onSelect: (result) => {
|
||||||
|
if (!result) return
|
||||||
|
const nodePosition = dragService.adjustSubNodePosition(
|
||||||
|
result.nodeType,
|
||||||
|
undefined,
|
||||||
|
position
|
||||||
|
)
|
||||||
|
const node: WorkflowNodeEntity =
|
||||||
|
context.document.createWorkflowNodeByType(
|
||||||
|
result.nodeType,
|
||||||
|
nodePosition,
|
||||||
|
result.nodeJSON ?? ({} as WorkflowNodeJSON)
|
||||||
|
)
|
||||||
|
selection.selectNode(node)
|
||||||
|
},
|
||||||
|
onClose: () => undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
element.addEventListener("contextmenu", handleContextMenu)
|
||||||
|
return () => element.removeEventListener("contextmenu", handleContextMenu)
|
||||||
|
}, [context, dragService, nodePanel, selection])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { createContext, useContext, type ReactNode } from "react"
|
||||||
|
|
||||||
|
import type { WorkflowEditorContextValue } from "./types"
|
||||||
|
|
||||||
|
const WorkflowEditorContext = createContext<WorkflowEditorContextValue | null>(null)
|
||||||
|
const WorkflowEditorSurfaceContext = createContext<"canvas" | "sidebar">("canvas")
|
||||||
|
|
||||||
|
export function WorkflowEditorContextProvider({
|
||||||
|
value,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
value: WorkflowEditorContextValue
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<WorkflowEditorContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</WorkflowEditorContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkflowEditorSurfaceProvider({
|
||||||
|
surface,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
surface: "canvas" | "sidebar"
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<WorkflowEditorSurfaceContext.Provider value={surface}>
|
||||||
|
{children}
|
||||||
|
</WorkflowEditorSurfaceContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkflowEditorContext() {
|
||||||
|
const value = useContext(WorkflowEditorContext)
|
||||||
|
if (!value) {
|
||||||
|
throw new Error("WorkflowEditorContext is unavailable")
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkflowEditorSurface() {
|
||||||
|
return useContext(WorkflowEditorSurfaceContext)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
|
||||||
|
import { createFreeAutoLayoutPlugin } from "@flowgram.ai/free-auto-layout-plugin"
|
||||||
|
import { createDownloadPlugin } from "@flowgram.ai/export-plugin"
|
||||||
|
import {
|
||||||
|
type FreeLayoutPluginContext,
|
||||||
|
type FreeLayoutProps,
|
||||||
|
type WorkflowJSON,
|
||||||
|
} from "@flowgram.ai/free-layout-editor"
|
||||||
|
import { createFreeLinesPlugin } from "@flowgram.ai/free-lines-plugin"
|
||||||
|
import { createFreeNodePanelPlugin } from "@flowgram.ai/free-node-panel-plugin"
|
||||||
|
import { createFreeSnapPlugin } from "@flowgram.ai/free-snap-plugin"
|
||||||
|
import { createFreeStackPlugin } from "@flowgram.ai/free-stack-plugin"
|
||||||
|
import { createMinimapPlugin } from "@flowgram.ai/minimap-plugin"
|
||||||
|
import {
|
||||||
|
createPanelManagerPlugin,
|
||||||
|
type PanelFactory,
|
||||||
|
} from "@flowgram.ai/panel-manager-plugin"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AIWorkflowDefinition,
|
||||||
|
AIWorkflowNodeSpec,
|
||||||
|
} from "@/lib/api/admin"
|
||||||
|
|
||||||
|
import { BaseNode, NODE_FORM_PANEL } from "./base-node"
|
||||||
|
import { CommentNode } from "./comment-node"
|
||||||
|
import { LineAddButton } from "./line-add-button"
|
||||||
|
import { NodeFormPanel } from "./node-form-panel"
|
||||||
|
import { NodePanel } from "./node-panel"
|
||||||
|
import { buildNodeRegistries } from "./node-registry"
|
||||||
|
import { onDragLineEnd } from "./on-drag-line-end"
|
||||||
|
import {
|
||||||
|
prepareDefinitionForEditor,
|
||||||
|
serializeDefinition,
|
||||||
|
} from "./workflow-model"
|
||||||
|
|
||||||
|
export function useWorkflowEditorProps({
|
||||||
|
definition,
|
||||||
|
nodeSpecs,
|
||||||
|
readonly = false,
|
||||||
|
onDefinitionChange,
|
||||||
|
}: {
|
||||||
|
definition: AIWorkflowDefinition
|
||||||
|
nodeSpecs: AIWorkflowNodeSpec[]
|
||||||
|
readonly?: boolean
|
||||||
|
onDefinitionChange?: (definition: AIWorkflowDefinition) => void
|
||||||
|
}): FreeLayoutProps {
|
||||||
|
return useMemo(() => {
|
||||||
|
const panelFactories: PanelFactory<{ nodeId: string }>[] = readonly
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
key: NODE_FORM_PANEL,
|
||||||
|
defaultSize: 500,
|
||||||
|
minSize: 300,
|
||||||
|
maxSize: 800,
|
||||||
|
render: (props) => <NodeFormPanel {...props} />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
background: true,
|
||||||
|
readonly,
|
||||||
|
twoWayConnection: true,
|
||||||
|
enableReadonlyNodeDragging: false,
|
||||||
|
playground: { preventGlobalGesture: true },
|
||||||
|
scroll: { disableScrollBar: true, enableScrollLimit: false },
|
||||||
|
initialData: prepareDefinitionForEditor(definition) as WorkflowJSON,
|
||||||
|
nodeRegistries: buildNodeRegistries(nodeSpecs),
|
||||||
|
getNodeDefaultRegistry: (type) => ({
|
||||||
|
type,
|
||||||
|
meta: { defaultExpanded: true },
|
||||||
|
}),
|
||||||
|
fromNodeJSON: (_node, json) => json,
|
||||||
|
toNodeJSON: (_node, json) => json,
|
||||||
|
materials: {
|
||||||
|
renderDefaultNode: BaseNode,
|
||||||
|
renderNodes: { comment: CommentNode },
|
||||||
|
},
|
||||||
|
nodeEngine: { enable: true },
|
||||||
|
variableEngine: { enable: true },
|
||||||
|
history: {
|
||||||
|
enable: !readonly,
|
||||||
|
enableChangeNode: !readonly,
|
||||||
|
},
|
||||||
|
lineColor: {
|
||||||
|
hidden: "transparent",
|
||||||
|
default: "#94a3b8",
|
||||||
|
drawing: "#2563eb",
|
||||||
|
hovered: "#2563eb",
|
||||||
|
selected: "#2563eb",
|
||||||
|
error: "#dc2626",
|
||||||
|
flowing: "#2563eb",
|
||||||
|
},
|
||||||
|
canAddLine: (_ctx, fromPort, toPort) => {
|
||||||
|
if (readonly || fromPort.node === toPort.node) return false
|
||||||
|
return !fromPort.node.lines.allInputNodes.includes(toPort.node)
|
||||||
|
},
|
||||||
|
canDeleteLine: () => !readonly,
|
||||||
|
canDeleteNode: (_ctx, node) =>
|
||||||
|
!readonly && !["start", "end"].includes(String(node.flowNodeType)),
|
||||||
|
onContentChange: (ctx) => {
|
||||||
|
if (readonly || ctx.document.disposed) return
|
||||||
|
onDefinitionChange?.(
|
||||||
|
serializeDefinition(ctx.document.toJSON() as AIWorkflowDefinition)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onDragLineEnd: readonly ? undefined : onDragLineEnd,
|
||||||
|
onAllLayersRendered: (ctx: FreeLayoutPluginContext) => {
|
||||||
|
window.requestAnimationFrame(() => ctx.tools.fitView(false))
|
||||||
|
},
|
||||||
|
plugins: () => [
|
||||||
|
createFreeStackPlugin({}),
|
||||||
|
createFreeLinesPlugin({
|
||||||
|
renderInsideLine: readonly ? undefined : LineAddButton,
|
||||||
|
}),
|
||||||
|
createMinimapPlugin({
|
||||||
|
disableLayer: true,
|
||||||
|
canvasStyle: {
|
||||||
|
canvasWidth: 176,
|
||||||
|
canvasHeight: 104,
|
||||||
|
canvasPadding: 32,
|
||||||
|
canvasBackground: "#f8fafc",
|
||||||
|
viewportBackground: "rgba(255,255,255,.7)",
|
||||||
|
viewportBorderColor: "#cbd5e1",
|
||||||
|
nodeColor: "#cbd5e1",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
createFreeSnapPlugin({
|
||||||
|
edgeColor: "#2563eb",
|
||||||
|
alignColor: "#2563eb",
|
||||||
|
}),
|
||||||
|
createFreeAutoLayoutPlugin({}),
|
||||||
|
createDownloadPlugin({
|
||||||
|
getFilename: (format) => `workflow.${format}`,
|
||||||
|
}),
|
||||||
|
...(readonly
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
createFreeNodePanelPlugin({ renderer: NodePanel }),
|
||||||
|
createPanelManagerPlugin({
|
||||||
|
factories: panelFactories,
|
||||||
|
autoResize: true,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}, [definition, nodeSpecs, onDefinitionChange, readonly])
|
||||||
|
}
|
||||||
@@ -0,0 +1,483 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
FlowDownloadFormat,
|
||||||
|
FlowDownloadService,
|
||||||
|
} from "@flowgram.ai/export-plugin"
|
||||||
|
import { WorkflowNodePanelService } from "@flowgram.ai/free-node-panel-plugin"
|
||||||
|
import {
|
||||||
|
type InteractiveType,
|
||||||
|
useClientContext,
|
||||||
|
usePlayground,
|
||||||
|
usePlaygroundTools,
|
||||||
|
useRefresh,
|
||||||
|
useService,
|
||||||
|
WorkflowDocument,
|
||||||
|
WorkflowDragService,
|
||||||
|
WorkflowLinesManager,
|
||||||
|
WorkflowSelectService,
|
||||||
|
type WorkflowNodeEntity,
|
||||||
|
type WorkflowNodeJSON,
|
||||||
|
} from "@flowgram.ai/free-layout-editor"
|
||||||
|
import { MinimapRender } from "@flowgram.ai/minimap-plugin"
|
||||||
|
import {
|
||||||
|
AlertTriangleIcon,
|
||||||
|
CheckIcon,
|
||||||
|
DownloadIcon,
|
||||||
|
FocusIcon,
|
||||||
|
GitBranchIcon,
|
||||||
|
HandIcon,
|
||||||
|
LayoutDashboardIcon,
|
||||||
|
LockIcon,
|
||||||
|
MousePointer2Icon,
|
||||||
|
MessageSquareTextIcon,
|
||||||
|
PlusIcon,
|
||||||
|
Redo2Icon,
|
||||||
|
Undo2Icon,
|
||||||
|
UnlockIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip"
|
||||||
|
import type { AIWorkflowValidationResult } from "@/lib/api/admin"
|
||||||
|
|
||||||
|
const INTERACTIVE_TYPE_KEY = "workflow_prefer_interactive_type"
|
||||||
|
|
||||||
|
export function EditorTools({
|
||||||
|
onValidate,
|
||||||
|
onValidation,
|
||||||
|
}: {
|
||||||
|
onValidate: () => Promise<AIWorkflowValidationResult>
|
||||||
|
onValidation: (result: AIWorkflowValidationResult) => void
|
||||||
|
}) {
|
||||||
|
const tools = usePlaygroundTools({ maxZoom: 2, minZoom: 0.25 })
|
||||||
|
const playground = usePlayground()
|
||||||
|
const refresh = useRefresh()
|
||||||
|
const { history } = useClientContext()
|
||||||
|
const document = useService(WorkflowDocument)
|
||||||
|
const linesManager = useService(WorkflowLinesManager)
|
||||||
|
const selection = useService(WorkflowSelectService)
|
||||||
|
const dragService = useService(WorkflowDragService)
|
||||||
|
const nodePanel = useService(WorkflowNodePanelService)
|
||||||
|
const downloadService = useService(FlowDownloadService)
|
||||||
|
const addButtonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const [minimapVisible, setMinimapVisible] = useState(true)
|
||||||
|
const [validating, setValidating] = useState(false)
|
||||||
|
const [interactiveType, setInteractiveType] =
|
||||||
|
useState<InteractiveType>("PAD" as InteractiveType)
|
||||||
|
const [historyState, setHistoryState] = useState({
|
||||||
|
undo: history.canUndo(),
|
||||||
|
redo: history.canRedo(),
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const preferred = readPreferredInteractiveType()
|
||||||
|
setInteractiveType(preferred)
|
||||||
|
tools.setInteractiveType(preferred)
|
||||||
|
}, [tools])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const disposable = history.undoRedoService.onChange(() =>
|
||||||
|
setHistoryState({ undo: history.canUndo(), redo: history.canRedo() })
|
||||||
|
)
|
||||||
|
return () => disposable.dispose()
|
||||||
|
}, [history])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const disposable = playground.config.onReadonlyOrDisabledChange(refresh)
|
||||||
|
return () => disposable.dispose()
|
||||||
|
}, [playground, refresh])
|
||||||
|
|
||||||
|
async function addNode() {
|
||||||
|
const rect = addButtonRef.current?.getBoundingClientRect()
|
||||||
|
if (!rect) return
|
||||||
|
const position = playground.config.getPosFromMouseEvent({
|
||||||
|
clientX: rect.left + 64,
|
||||||
|
clientY: rect.top - 7,
|
||||||
|
})
|
||||||
|
await nodePanel.callNodePanel({
|
||||||
|
position,
|
||||||
|
enableMultiAdd: true,
|
||||||
|
onSelect: (result) => {
|
||||||
|
if (!result) return
|
||||||
|
const node: WorkflowNodeEntity = document.createWorkflowNodeByType(
|
||||||
|
result.nodeType,
|
||||||
|
undefined,
|
||||||
|
result.nodeJSON ?? ({} as WorkflowNodeJSON)
|
||||||
|
)
|
||||||
|
selection.selectNode(node)
|
||||||
|
},
|
||||||
|
onClose: () => undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validate(purpose: "problem" | "test" = "problem") {
|
||||||
|
setValidating(true)
|
||||||
|
try {
|
||||||
|
const result = await onValidate()
|
||||||
|
onValidation(result)
|
||||||
|
if (purpose === "test" && result.valid) {
|
||||||
|
toast.success(
|
||||||
|
"预运行检查已通过;实际工作流会由已关联 Agent 的会话触发"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} finally {
|
||||||
|
setValidating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createComment(event: React.MouseEvent<HTMLButtonElement>) {
|
||||||
|
const position = playground.config.getPosFromMouseEvent(event)
|
||||||
|
const node = document.createWorkflowNodeByType(
|
||||||
|
"comment",
|
||||||
|
{ x: position.x, y: position.y - 75 },
|
||||||
|
{
|
||||||
|
id: `comment_${Date.now()}`,
|
||||||
|
type: "comment",
|
||||||
|
data: {
|
||||||
|
size: { width: 240, height: 150 },
|
||||||
|
note: "",
|
||||||
|
},
|
||||||
|
} as WorkflowNodeJSON
|
||||||
|
)
|
||||||
|
await new Promise<void>((resolve) =>
|
||||||
|
window.requestAnimationFrame(() => resolve())
|
||||||
|
)
|
||||||
|
selection.selectNode(node)
|
||||||
|
if (event.detail !== 0) {
|
||||||
|
dragService.startDragSelectedNodes(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function download(format: FlowDownloadFormat) {
|
||||||
|
await downloadService.download({ format })
|
||||||
|
toast.success(`已导出 ${format.toUpperCase()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const readonly = playground.config.readonly
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none absolute bottom-4 left-4 z-30 flex min-w-[360px] gap-2">
|
||||||
|
<div className="pointer-events-auto flex h-10 items-center gap-0.5 rounded-[10px] border border-[rgba(68,83,130,0.25)] bg-white px-1 shadow-[0_2px_6px_rgba(0,0,0,0.04),0_4px_12px_rgba(0,0,0,0.02)]">
|
||||||
|
<DropdownMenu>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button variant="ghost" size="icon-sm" aria-label="交互模式" />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{interactiveType === ("MOUSE" as InteractiveType) ? (
|
||||||
|
<MousePointer2Icon />
|
||||||
|
) : (
|
||||||
|
<HandIcon />
|
||||||
|
)}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{interactiveType === ("MOUSE" as InteractiveType)
|
||||||
|
? "鼠标友好模式"
|
||||||
|
: "触控板友好模式"}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<DropdownMenuContent side="top" align="start" className="w-[420px] p-3">
|
||||||
|
<div className="mb-3 text-base font-semibold">交互模式</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<InteractionOption
|
||||||
|
selected={interactiveType === ("MOUSE" as InteractiveType)}
|
||||||
|
icon={<MousePointer2Icon />}
|
||||||
|
title="鼠标友好"
|
||||||
|
description="按住鼠标左键拖动画布,滚轮缩放。"
|
||||||
|
onClick={() =>
|
||||||
|
changeInteractiveType(
|
||||||
|
"MOUSE" as InteractiveType,
|
||||||
|
tools.setInteractiveType,
|
||||||
|
setInteractiveType
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InteractionOption
|
||||||
|
selected={interactiveType === ("PAD" as InteractiveType)}
|
||||||
|
icon={<HandIcon />}
|
||||||
|
title="触控板友好"
|
||||||
|
description="双指移动画布,双指捏合缩放。"
|
||||||
|
onClick={() =>
|
||||||
|
changeInteractiveType(
|
||||||
|
"PAD" as InteractiveType,
|
||||||
|
tools.setInteractiveType,
|
||||||
|
setInteractiveType
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<ToolButton
|
||||||
|
label="自动布局"
|
||||||
|
disabled={readonly}
|
||||||
|
onClick={() =>
|
||||||
|
void tools.autoLayout({
|
||||||
|
enableAnimation: true,
|
||||||
|
animationDuration: 1000,
|
||||||
|
layoutConfig: { rankdir: "LR", nodesep: 100, ranksep: 100 },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LayoutDashboardIcon />
|
||||||
|
</ToolButton>
|
||||||
|
<ToolButton label="切换线型" onClick={() => linesManager.switchLineType()}>
|
||||||
|
<GitBranchIcon />
|
||||||
|
</ToolButton>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-[50px] rounded-lg border border-[rgba(68,83,130,0.25)] px-1 py-1 text-xs hover:bg-muted"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{Math.floor(tools.zoom * 100)}%
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent side="top" align="start">
|
||||||
|
<DropdownMenuItem onClick={() => tools.zoomin()}>
|
||||||
|
放大
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => tools.zoomout()}>
|
||||||
|
缩小
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{[0.5, 1, 1.5, 2].map((zoom) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={zoom}
|
||||||
|
onClick={() => playground.config.updateZoom(zoom)}
|
||||||
|
>
|
||||||
|
缩放至 {zoom * 100}%
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<ToolButton label="适应画布" onClick={() => tools.fitView()}>
|
||||||
|
<FocusIcon />
|
||||||
|
</ToolButton>
|
||||||
|
<ToolButton
|
||||||
|
label="缩略图"
|
||||||
|
active={minimapVisible}
|
||||||
|
onClick={() => setMinimapVisible((value) => !value)}
|
||||||
|
>
|
||||||
|
<LayoutDashboardIcon />
|
||||||
|
</ToolButton>
|
||||||
|
{minimapVisible ? (
|
||||||
|
<div className="absolute bottom-[60px] left-0 w-[198px] overflow-hidden rounded-lg border bg-white shadow-sm">
|
||||||
|
<MinimapRender
|
||||||
|
panelStyles={{}}
|
||||||
|
containerStyles={{
|
||||||
|
pointerEvents: "auto",
|
||||||
|
position: "relative",
|
||||||
|
inset: "unset",
|
||||||
|
}}
|
||||||
|
inactiveStyle={{
|
||||||
|
opacity: 1,
|
||||||
|
scale: 1,
|
||||||
|
translateX: 0,
|
||||||
|
translateY: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<ToolButton
|
||||||
|
label={readonly ? "切换为可编辑" : "切换为只读"}
|
||||||
|
onClick={() => {
|
||||||
|
playground.config.readonly = !playground.config.readonly
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{readonly ? <LockIcon /> : <UnlockIcon />}
|
||||||
|
</ToolButton>
|
||||||
|
<ToolButton
|
||||||
|
label="添加注释"
|
||||||
|
disabled={readonly}
|
||||||
|
onClick={(event) => void createComment(event)}
|
||||||
|
>
|
||||||
|
<MessageSquareTextIcon />
|
||||||
|
</ToolButton>
|
||||||
|
<ToolButton
|
||||||
|
label="撤销"
|
||||||
|
disabled={!historyState.undo || readonly}
|
||||||
|
onClick={() => void history.undo()}
|
||||||
|
>
|
||||||
|
<Undo2Icon />
|
||||||
|
</ToolButton>
|
||||||
|
<ToolButton
|
||||||
|
label="重做"
|
||||||
|
disabled={!historyState.redo || readonly}
|
||||||
|
onClick={() => void history.redo()}
|
||||||
|
>
|
||||||
|
<Redo2Icon />
|
||||||
|
</ToolButton>
|
||||||
|
<ToolButton
|
||||||
|
label="问题"
|
||||||
|
disabled={validating}
|
||||||
|
onClick={() => void validate("problem")}
|
||||||
|
>
|
||||||
|
<AlertTriangleIcon />
|
||||||
|
</ToolButton>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button variant="ghost" size="icon-sm" aria-label="下载" />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DownloadIcon />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>下载</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<DropdownMenuContent side="top" align="start">
|
||||||
|
{Object.values(FlowDownloadFormat).map((format) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={format}
|
||||||
|
disabled={readonly}
|
||||||
|
onClick={() => void download(format)}
|
||||||
|
>
|
||||||
|
{format.toUpperCase()}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<div className="mx-1 h-4 w-px bg-border" />
|
||||||
|
<Button
|
||||||
|
ref={addButtonRef}
|
||||||
|
size="sm"
|
||||||
|
className="h-8 rounded-lg border-0 bg-[rgba(171,181,255,0.3)] text-[#4e40e5] shadow-none hover:bg-[rgba(171,181,255,0.45)]"
|
||||||
|
disabled={readonly}
|
||||||
|
onClick={() => void addNode()}
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
添加节点
|
||||||
|
</Button>
|
||||||
|
<div className="mx-1 h-4 w-px bg-border" />
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-8 rounded-lg bg-[rgba(171,181,255,0.3)] text-[#4e40e5] shadow-none hover:bg-[rgba(171,181,255,0.45)]"
|
||||||
|
disabled={readonly || validating}
|
||||||
|
onClick={() => void validate("test")}
|
||||||
|
>
|
||||||
|
<CheckIcon />
|
||||||
|
测试运行
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InteractionOption({
|
||||||
|
selected,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
selected: boolean
|
||||||
|
icon: React.ReactNode
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
onClick: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`rounded-lg border p-3 text-left ${
|
||||||
|
selected ? "border-[#4e40e5] bg-[#f5f3ff]" : "hover:bg-muted/50"
|
||||||
|
}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<span className={selected ? "text-[#4e40e5]" : "text-muted-foreground"}>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
<span className="mt-2 block text-sm font-semibold">{title}</span>
|
||||||
|
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
|
||||||
|
{description}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolButton({
|
||||||
|
label,
|
||||||
|
disabled,
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
disabled?: boolean
|
||||||
|
active?: boolean
|
||||||
|
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className={active ? "bg-muted" : undefined}
|
||||||
|
aria-label={label}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{label}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPreferredInteractiveType() {
|
||||||
|
const stored = window.localStorage.getItem(INTERACTIVE_TYPE_KEY)
|
||||||
|
if (stored === "MOUSE" || stored === "PAD") {
|
||||||
|
return stored as InteractiveType
|
||||||
|
}
|
||||||
|
return /Macintosh|MacIntel|MacPPC|Mac68K|iPad/.test(navigator.userAgent)
|
||||||
|
? ("PAD" as InteractiveType)
|
||||||
|
: ("MOUSE" as InteractiveType)
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeInteractiveType(
|
||||||
|
value: InteractiveType,
|
||||||
|
update: (value: InteractiveType) => void,
|
||||||
|
setValue: (value: InteractiveType) => void
|
||||||
|
) {
|
||||||
|
window.localStorage.setItem(INTERACTIVE_TYPE_KEY, value)
|
||||||
|
update(value)
|
||||||
|
setValue(value)
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback } from "react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
WorkflowNodePanelService,
|
||||||
|
WorkflowNodePanelUtils,
|
||||||
|
} from "@flowgram.ai/free-node-panel-plugin"
|
||||||
|
import type { LineRenderProps } from "@flowgram.ai/free-lines-plugin"
|
||||||
|
import {
|
||||||
|
delay,
|
||||||
|
HistoryService,
|
||||||
|
useService,
|
||||||
|
WorkflowDocument,
|
||||||
|
WorkflowDragService,
|
||||||
|
WorkflowLinesManager,
|
||||||
|
type WorkflowNodeEntity,
|
||||||
|
type WorkflowNodeJSON,
|
||||||
|
} from "@flowgram.ai/free-layout-editor"
|
||||||
|
import { PlusIcon } from "lucide-react"
|
||||||
|
|
||||||
|
export function LineAddButton({
|
||||||
|
line,
|
||||||
|
selected,
|
||||||
|
hovered,
|
||||||
|
color,
|
||||||
|
}: LineRenderProps) {
|
||||||
|
const nodePanel = useService(WorkflowNodePanelService)
|
||||||
|
const document = useService(WorkflowDocument)
|
||||||
|
const dragService = useService(WorkflowDragService)
|
||||||
|
const linesManager = useService(WorkflowLinesManager)
|
||||||
|
const history = useService(HistoryService)
|
||||||
|
const { fromPort, toPort } = line
|
||||||
|
|
||||||
|
const addNode = useCallback(async () => {
|
||||||
|
if (!fromPort || !toPort) return
|
||||||
|
const position = {
|
||||||
|
x: (line.position.from.x + line.position.to.x) / 2,
|
||||||
|
y: (line.position.from.y + line.position.to.y) / 2,
|
||||||
|
}
|
||||||
|
const containerNode = fromPort.node.parent
|
||||||
|
const result = await nodePanel.singleSelectNodePanel({
|
||||||
|
position,
|
||||||
|
containerNode,
|
||||||
|
panelProps: { fromPort, enableScrollClose: true },
|
||||||
|
})
|
||||||
|
if (!result) return
|
||||||
|
const nodePosition = WorkflowNodePanelUtils.adjustNodePosition({
|
||||||
|
nodeType: result.nodeType,
|
||||||
|
position,
|
||||||
|
fromPort,
|
||||||
|
toPort,
|
||||||
|
containerNode,
|
||||||
|
document,
|
||||||
|
dragService,
|
||||||
|
})
|
||||||
|
const node: WorkflowNodeEntity = document.createWorkflowNodeByType(
|
||||||
|
result.nodeType,
|
||||||
|
nodePosition,
|
||||||
|
result.nodeJSON ?? ({} as WorkflowNodeJSON),
|
||||||
|
containerNode?.id
|
||||||
|
)
|
||||||
|
WorkflowNodePanelUtils.subNodesAutoOffset({
|
||||||
|
node,
|
||||||
|
fromPort,
|
||||||
|
toPort,
|
||||||
|
containerNode,
|
||||||
|
historyService: history,
|
||||||
|
dragService,
|
||||||
|
linesManager,
|
||||||
|
})
|
||||||
|
await delay(20)
|
||||||
|
WorkflowNodePanelUtils.buildLine({ fromPort, node, toPort, linesManager })
|
||||||
|
line.dispose()
|
||||||
|
}, [document, dragService, fromPort, history, line, linesManager, nodePanel, toPort])
|
||||||
|
|
||||||
|
if (!selected && !hovered) return null
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute z-10 flex size-6 items-center justify-center rounded-full border-2 bg-white shadow-sm hover:scale-110"
|
||||||
|
style={{
|
||||||
|
color,
|
||||||
|
borderColor: color,
|
||||||
|
transform: `translate(-50%, -50%) translate(${line.center.labelX}px, ${line.center.labelY}px)`,
|
||||||
|
}}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
void addNode()
|
||||||
|
}}
|
||||||
|
aria-label="在线路中插入节点"
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
PlaygroundEntityContext,
|
||||||
|
type WorkflowNodeEntity,
|
||||||
|
useClientContext,
|
||||||
|
useNodeRender,
|
||||||
|
} from "@flowgram.ai/free-layout-editor"
|
||||||
|
import { usePanelManager } from "@flowgram.ai/panel-manager-plugin"
|
||||||
|
import { PlusIcon, Trash2Icon, XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { OptionCombobox } from "@/components/option-combobox"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import {
|
||||||
|
fetchKnowledgeBasesAll,
|
||||||
|
type AIWorkflowDefinition,
|
||||||
|
type AIWorkflowNodeSpec,
|
||||||
|
type AIWorkflowValue,
|
||||||
|
type KnowledgeBase,
|
||||||
|
} from "@/lib/api/admin"
|
||||||
|
import { Status } from "@/lib/generated/enums"
|
||||||
|
|
||||||
|
import { NODE_FORM_PANEL } from "./base-node"
|
||||||
|
import {
|
||||||
|
WorkflowEditorSurfaceProvider,
|
||||||
|
useWorkflowEditorContext,
|
||||||
|
} from "./editor-context"
|
||||||
|
import { WorkflowNodeIcon } from "./node-icon"
|
||||||
|
import {
|
||||||
|
buildAvailableVariables,
|
||||||
|
nextBranchID,
|
||||||
|
normalizeConditionBranches,
|
||||||
|
parseRefKey,
|
||||||
|
refKey,
|
||||||
|
} from "./workflow-model"
|
||||||
|
import type { WorkflowConditionBranch } from "./types"
|
||||||
|
|
||||||
|
const operatorOptions = [
|
||||||
|
{ value: "eq", label: "等于" },
|
||||||
|
{ value: "neq", label: "不等于" },
|
||||||
|
{ value: "contains", label: "包含" },
|
||||||
|
{ value: "not_contains", label: "不包含" },
|
||||||
|
{ value: "gt", label: "大于" },
|
||||||
|
{ value: "gte", label: "大于等于" },
|
||||||
|
{ value: "lt", label: "小于" },
|
||||||
|
{ value: "lte", label: "小于等于" },
|
||||||
|
{ value: "exists", label: "存在" },
|
||||||
|
{ value: "empty", label: "为空" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function NodeFormPanel({ nodeId }: { nodeId: string }) {
|
||||||
|
const { document } = useClientContext()
|
||||||
|
const node = document.getNode(nodeId)
|
||||||
|
if (!node) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlaygroundEntityContext.Provider value={node}>
|
||||||
|
<WorkflowEditorSurfaceProvider surface="sidebar">
|
||||||
|
<NodeForm node={node} />
|
||||||
|
</WorkflowEditorSurfaceProvider>
|
||||||
|
</PlaygroundEntityContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NodeForm({ node }: { node: WorkflowNodeEntity }) {
|
||||||
|
const panelManager = usePanelManager()
|
||||||
|
const render = useNodeRender(node)
|
||||||
|
const { document } = useClientContext()
|
||||||
|
const { nodeSpecs } = useWorkflowEditorContext()
|
||||||
|
const spec = nodeSpecs.find((item) => item.type === String(node.flowNodeType))
|
||||||
|
const data = render.data ?? {}
|
||||||
|
const definition = document.toJSON() as AIWorkflowDefinition
|
||||||
|
const variables = buildAvailableVariables(definition, node.id, nodeSpecs)
|
||||||
|
const canDelete = !["start", "end"].includes(String(node.flowNodeType))
|
||||||
|
|
||||||
|
function updateData(next: Record<string, unknown>) {
|
||||||
|
render.updateData({ ...data, ...next })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col bg-[#fbfbfb]">
|
||||||
|
<div className="flex h-[58px] shrink-0 items-center gap-3 border-b border-[rgba(82,100,154,0.13)] px-4">
|
||||||
|
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-[#f2f3ff] text-[#4e40e5]">
|
||||||
|
<WorkflowNodeIcon name={spec?.icon} className="size-4" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-semibold text-[#060709]">
|
||||||
|
{String(data.title || spec?.title || node.flowNodeType)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => panelManager.close(NODE_FORM_PANEL)}
|
||||||
|
aria-label="关闭配置"
|
||||||
|
>
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
<FormSection title="基本信息">
|
||||||
|
<FormField label="节点名称">
|
||||||
|
<Input
|
||||||
|
value={String(data.title ?? "")}
|
||||||
|
placeholder={spec?.title}
|
||||||
|
onChange={(event) => updateData({ title: event.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</FormSection>
|
||||||
|
<InputSection
|
||||||
|
spec={spec}
|
||||||
|
inputsValues={data.inputsValues ?? {}}
|
||||||
|
variables={variables}
|
||||||
|
onChange={(inputsValues) => updateData({ inputsValues })}
|
||||||
|
/>
|
||||||
|
{String(node.flowNodeType) === "knowledge_retrieve" ? (
|
||||||
|
<KnowledgeSection
|
||||||
|
config={asRecord(data.config)}
|
||||||
|
onChange={(config) => updateData({ config })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{String(node.flowNodeType) === "condition" ? (
|
||||||
|
<ConditionSection
|
||||||
|
branches={normalizeConditionBranches({ data })}
|
||||||
|
variables={variables}
|
||||||
|
onChange={(branches) =>
|
||||||
|
updateData({
|
||||||
|
config: { ...asRecord(data.config), branches },
|
||||||
|
portKeys: branches.map((branch) => branch.id),
|
||||||
|
ports: branches.map((branch) => branch.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<OutputSection spec={spec} />
|
||||||
|
</div>
|
||||||
|
{canDelete ? (
|
||||||
|
<div className="shrink-0 border-t p-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full text-destructive hover:text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
render.deleteNode()
|
||||||
|
panelManager.close(NODE_FORM_PANEL)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2Icon className="size-4" />
|
||||||
|
删除节点
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputSection({
|
||||||
|
spec,
|
||||||
|
inputsValues,
|
||||||
|
variables,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
spec?: AIWorkflowNodeSpec
|
||||||
|
inputsValues: Record<string, AIWorkflowValue>
|
||||||
|
variables: ReturnType<typeof buildAvailableVariables>
|
||||||
|
onChange: (value: Record<string, AIWorkflowValue>) => void
|
||||||
|
}) {
|
||||||
|
if (!spec?.inputSchema?.length) return null
|
||||||
|
const options = variables.map((variable) => ({
|
||||||
|
value: `${variable.nodeId}.${variable.name}`,
|
||||||
|
label: variable.label || variable.name,
|
||||||
|
group: variable.nodeTitle,
|
||||||
|
subtitle: `${variable.nodeId}.${variable.name}`,
|
||||||
|
description: variable.description,
|
||||||
|
}))
|
||||||
|
return (
|
||||||
|
<FormSection title="输入">
|
||||||
|
{spec.inputSchema.map((input) => (
|
||||||
|
<FormField
|
||||||
|
key={input.name}
|
||||||
|
label={input.label || input.name}
|
||||||
|
required={input.required}
|
||||||
|
hint={input.description}
|
||||||
|
>
|
||||||
|
<OptionCombobox
|
||||||
|
value={refKey(inputsValues[input.name])}
|
||||||
|
options={options}
|
||||||
|
placeholder="选择上游变量"
|
||||||
|
searchPlaceholder="搜索变量"
|
||||||
|
preserveExternalSelection
|
||||||
|
onChange={(value) => {
|
||||||
|
const parsed = parseRefKey(value)
|
||||||
|
if (!parsed) return
|
||||||
|
onChange({ ...inputsValues, [input.name]: parsed })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
))}
|
||||||
|
</FormSection>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function KnowledgeSection({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
config: Record<string, unknown>
|
||||||
|
onChange: (value: Record<string, unknown>) => void
|
||||||
|
}) {
|
||||||
|
const [items, setItems] = useState<KnowledgeBase[]>([])
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
fetchKnowledgeBasesAll({ status: Status.Ok })
|
||||||
|
.then((result) => active && setItems(result ?? []))
|
||||||
|
.catch(() => active && setItems([]))
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
const values = normalizeIDs(config.knowledgeBaseIds).map(String)
|
||||||
|
return (
|
||||||
|
<FormSection title="知识库">
|
||||||
|
<FormField label="检索范围" required hint="可选择多个已启用知识库。">
|
||||||
|
<OptionCombobox
|
||||||
|
multiple
|
||||||
|
values={values}
|
||||||
|
options={items.map((item) => ({
|
||||||
|
value: String(item.id),
|
||||||
|
label: item.name,
|
||||||
|
}))}
|
||||||
|
placeholder="选择知识库"
|
||||||
|
searchPlaceholder="搜索知识库"
|
||||||
|
onValuesChange={(next) =>
|
||||||
|
onChange({
|
||||||
|
...config,
|
||||||
|
knowledgeBaseIds: next.map(Number).filter((id) => id > 0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</FormSection>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConditionSection({
|
||||||
|
branches,
|
||||||
|
variables,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
branches: WorkflowConditionBranch[]
|
||||||
|
variables: ReturnType<typeof buildAvailableVariables>
|
||||||
|
onChange: (branches: WorkflowConditionBranch[]) => void
|
||||||
|
}) {
|
||||||
|
const variableOptions = variables.map((variable) => ({
|
||||||
|
value: `${variable.nodeId}.${variable.name}`,
|
||||||
|
label: variable.label || variable.name,
|
||||||
|
group: variable.nodeTitle,
|
||||||
|
subtitle: `${variable.nodeId}.${variable.name}`,
|
||||||
|
}))
|
||||||
|
const fallback = branches.find((branch) => branch.default)
|
||||||
|
const regular = branches.filter((branch) => !branch.default)
|
||||||
|
|
||||||
|
function update(branch: WorkflowConditionBranch) {
|
||||||
|
onChange(branches.map((item) => (item.id === branch.id ? branch : item)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormSection
|
||||||
|
title="条件分支"
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const next: WorkflowConditionBranch = {
|
||||||
|
id: nextBranchID(branches),
|
||||||
|
name: `条件 ${regular.length + 1}`,
|
||||||
|
targetNodeId: "",
|
||||||
|
condition: { operator: "eq" },
|
||||||
|
}
|
||||||
|
onChange([...regular, next, fallback].filter(Boolean) as WorkflowConditionBranch[])
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-4" />
|
||||||
|
添加
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{[...regular, ...(fallback ? [fallback] : [])].map((branch) => (
|
||||||
|
<div key={branch.id} className="rounded-md bg-slate-50 p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={branch.name ?? ""}
|
||||||
|
onChange={(event) => update({ ...branch, name: event.target.value })}
|
||||||
|
/>
|
||||||
|
{!branch.default ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="shrink-0 text-slate-500 hover:text-destructive"
|
||||||
|
onClick={() => onChange(branches.filter((item) => item.id !== branch.id))}
|
||||||
|
>
|
||||||
|
<Trash2Icon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{branch.default ? (
|
||||||
|
<p className="mt-2 text-xs text-slate-500">其他条件均不匹配时进入此分支。</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-3 grid gap-2">
|
||||||
|
<OptionCombobox
|
||||||
|
value={refKey(branch.condition?.left)}
|
||||||
|
options={variableOptions}
|
||||||
|
placeholder="选择变量"
|
||||||
|
preserveExternalSelection
|
||||||
|
onChange={(value) =>
|
||||||
|
update({
|
||||||
|
...branch,
|
||||||
|
condition: {
|
||||||
|
...branch.condition,
|
||||||
|
left: parseRefKey(value),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<OptionCombobox
|
||||||
|
value={branch.condition?.operator ?? "eq"}
|
||||||
|
options={operatorOptions}
|
||||||
|
placeholder="选择运算符"
|
||||||
|
onChange={(operator) =>
|
||||||
|
update({
|
||||||
|
...branch,
|
||||||
|
condition: { ...branch.condition, operator },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{!["exists", "empty"].includes(branch.condition?.operator ?? "") ? (
|
||||||
|
<Input
|
||||||
|
value={String(branch.condition?.right ?? "")}
|
||||||
|
placeholder="比较值"
|
||||||
|
onChange={(event) =>
|
||||||
|
update({
|
||||||
|
...branch,
|
||||||
|
condition: {
|
||||||
|
...branch.condition,
|
||||||
|
right: event.target.value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</FormSection>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function OutputSection({ spec }: { spec?: AIWorkflowNodeSpec }) {
|
||||||
|
if (!spec?.outputSchema?.length) return null
|
||||||
|
return (
|
||||||
|
<FormSection title="输出">
|
||||||
|
<div className="divide-y rounded-md border">
|
||||||
|
{spec.outputSchema.map((output) => (
|
||||||
|
<div key={output.name} className="px-3 py-2.5">
|
||||||
|
<div className="flex items-center justify-between gap-3 text-sm">
|
||||||
|
<span className="font-medium">{output.label || output.name}</span>
|
||||||
|
<span className="font-mono text-xs text-slate-500">{output.type}</span>
|
||||||
|
</div>
|
||||||
|
{output.description ? (
|
||||||
|
<p className="mt-1 text-xs leading-5 text-slate-500">{output.description}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</FormSection>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormSection({
|
||||||
|
title,
|
||||||
|
action,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
action?: React.ReactNode
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="border-b px-5 py-5 last:border-b-0">
|
||||||
|
<div className="mb-4 flex items-center justify-between gap-3">
|
||||||
|
<h3 className="text-sm font-semibold text-slate-900">{title}</h3>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">{children}</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormField({
|
||||||
|
label,
|
||||||
|
required,
|
||||||
|
hint,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
required?: boolean
|
||||||
|
hint?: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>
|
||||||
|
{label}
|
||||||
|
{required ? <span className="ml-1 text-destructive">*</span> : null}
|
||||||
|
</Label>
|
||||||
|
{children}
|
||||||
|
{hint ? <p className="text-xs leading-5 text-slate-500">{hint}</p> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIDs(value: unknown) {
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
return Array.from(
|
||||||
|
new Set(value.map(Number).filter((item) => Number.isInteger(item) && item > 0))
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import {
|
||||||
|
BookOpenIcon,
|
||||||
|
BotIcon,
|
||||||
|
CircleHelpIcon,
|
||||||
|
ClipboardListIcon,
|
||||||
|
FlagIcon,
|
||||||
|
GitBranchIcon,
|
||||||
|
HeadphonesIcon,
|
||||||
|
MessageCircleIcon,
|
||||||
|
PlayCircleIcon,
|
||||||
|
SearchIcon,
|
||||||
|
SendIcon,
|
||||||
|
ShieldCheckIcon,
|
||||||
|
TicketIcon,
|
||||||
|
UserCheckIcon,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
const icons: Record<string, LucideIcon> = {
|
||||||
|
PlayCircleIcon,
|
||||||
|
MessageCircleIcon,
|
||||||
|
ShieldCheckIcon,
|
||||||
|
BookOpenIcon,
|
||||||
|
HelpCircleIcon: CircleHelpIcon,
|
||||||
|
BotIcon,
|
||||||
|
GitBranchIcon,
|
||||||
|
SearchIcon,
|
||||||
|
ClipboardListIcon,
|
||||||
|
UserCheckIcon,
|
||||||
|
TicketIcon,
|
||||||
|
HeadphonesIcon,
|
||||||
|
SendIcon,
|
||||||
|
FlagIcon,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkflowNodeIcon({
|
||||||
|
name,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
name?: string
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const Icon = icons[name ?? ""] ?? GitBranchIcon
|
||||||
|
return <Icon className={className} />
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
NodePanelRenderProps,
|
||||||
|
NodePanelResult,
|
||||||
|
} from "@flowgram.ai/free-node-panel-plugin"
|
||||||
|
import { useClientContext } from "@flowgram.ai/free-layout-editor"
|
||||||
|
|
||||||
|
import { useWorkflowEditorContext } from "./editor-context"
|
||||||
|
import { WorkflowNodeIcon } from "./node-icon"
|
||||||
|
import { createNodeJSON } from "./workflow-model"
|
||||||
|
|
||||||
|
export function NodePanel({
|
||||||
|
position,
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
}: NodePanelRenderProps) {
|
||||||
|
const { nodeSpecs } = useWorkflowEditorContext()
|
||||||
|
const { document } = useClientContext()
|
||||||
|
const visibleSpecs = nodeSpecs.filter((spec) => spec.type !== "start")
|
||||||
|
|
||||||
|
function select(spec: (typeof nodeSpecs)[number], event: React.MouseEvent) {
|
||||||
|
onSelect({
|
||||||
|
nodeType: spec.type,
|
||||||
|
nodeJSON: createNodeJSON(
|
||||||
|
spec,
|
||||||
|
document.getAllNodes().map((node) => node.id)
|
||||||
|
),
|
||||||
|
selectEvent: event,
|
||||||
|
} satisfies Exclude<NodePanelResult, undefined>)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="fixed inset-0 z-40 cursor-default"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="关闭节点面板"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute z-50 w-[180px] overflow-hidden rounded-lg border border-[rgba(68,83,130,0.25)] bg-white p-2 shadow-[0_4px_12px_rgba(0,0,0,0.02),0_2px_6px_rgba(0,0,0,0.04)]"
|
||||||
|
style={{
|
||||||
|
left: position.x + 30,
|
||||||
|
top: position.y,
|
||||||
|
transform: "translateY(-100%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="max-h-[500px] overflow-y-auto [scrollbar-width:none]">
|
||||||
|
{visibleSpecs.map((spec) => (
|
||||||
|
<button
|
||||||
|
key={spec.type}
|
||||||
|
type="button"
|
||||||
|
data-testid={`demo-free-node-list-${spec.type}`}
|
||||||
|
className="flex h-8 w-full items-center rounded-[5px] px-[15px] text-left hover:bg-[hsla(252,62%,55%,0.09)] hover:text-[hsl(252,62%,55%)]"
|
||||||
|
onClick={(event) => select(spec, event)}
|
||||||
|
>
|
||||||
|
<WorkflowNodeIcon name={spec.icon} className="size-3.5 shrink-0" />
|
||||||
|
<span className="ml-2.5 truncate text-xs">{spec.title}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
type WorkflowNodeRegistry,
|
||||||
|
} from "@flowgram.ai/free-layout-editor"
|
||||||
|
|
||||||
|
import type { AIWorkflowNodeSpec } from "@/lib/api/admin"
|
||||||
|
|
||||||
|
import { WorkflowNodeIcon } from "./node-icon"
|
||||||
|
import { normalizeConditionBranches } from "./workflow-model"
|
||||||
|
|
||||||
|
export function buildNodeRegistries(
|
||||||
|
nodeSpecs: AIWorkflowNodeSpec[]
|
||||||
|
): WorkflowNodeRegistry[] {
|
||||||
|
return [
|
||||||
|
...nodeSpecs.map((spec) => ({
|
||||||
|
type: spec.type,
|
||||||
|
meta: {
|
||||||
|
defaultExpanded: true,
|
||||||
|
isStart: spec.type === "start",
|
||||||
|
deleteDisable: spec.type === "start" || spec.type === "end",
|
||||||
|
copyDisable: spec.type === "start" || spec.type === "end",
|
||||||
|
nodePanelVisible: spec.type !== "start",
|
||||||
|
useDynamicPort: spec.type === "condition",
|
||||||
|
defaultPorts: getDefaultPorts(spec.type),
|
||||||
|
},
|
||||||
|
formMeta: {
|
||||||
|
render: () => <CanvasNodeContent spec={spec} />,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
type: "comment",
|
||||||
|
meta: {
|
||||||
|
sidebarDisabled: true,
|
||||||
|
nodePanelVisible: false,
|
||||||
|
defaultPorts: [],
|
||||||
|
renderKey: "comment",
|
||||||
|
size: { width: 240, height: 150 },
|
||||||
|
},
|
||||||
|
formMeta: {
|
||||||
|
render: () => <></>,
|
||||||
|
},
|
||||||
|
getInputPoints: () => [],
|
||||||
|
getOutputPoints: () => [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function CanvasNodeContent({ spec }: { spec: AIWorkflowNodeSpec }) {
|
||||||
|
return (
|
||||||
|
<Field<string> name="title">
|
||||||
|
{({ field }) => (
|
||||||
|
<div className="w-[360px] select-none">
|
||||||
|
<div className="flex items-center gap-2 border-b border-[rgba(82,100,154,0.13)] px-4 py-3">
|
||||||
|
<span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-[#f2f3ff] text-[#4e40e5]">
|
||||||
|
<WorkflowNodeIcon name={spec.icon} className="size-3.5" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1 truncate text-sm font-semibold text-[#060709]">
|
||||||
|
{field.value || spec.title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="line-clamp-2 text-xs leading-5 text-[rgba(6,7,9,0.5)]">
|
||||||
|
{spec.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{spec.type === "condition" ? (
|
||||||
|
<Field<Record<string, unknown>> name="config">
|
||||||
|
{({ field: configField }) => {
|
||||||
|
const branches = normalizeConditionBranches({
|
||||||
|
data: { config: configField.value },
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<div className="mt-3 space-y-1.5 border-t border-[rgba(82,100,154,0.13)] pt-2.5">
|
||||||
|
{branches.map((branch, index) => (
|
||||||
|
<div
|
||||||
|
key={branch.id}
|
||||||
|
className="relative flex items-center gap-2 rounded-md bg-[#f7f7fa] px-2 py-1.5 text-xs"
|
||||||
|
>
|
||||||
|
<span className="w-8 shrink-0 font-medium uppercase text-[#4e40e5]">
|
||||||
|
{branch.default ? "else" : index === 0 ? "if" : "elif"}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[rgba(6,7,9,0.65)]">
|
||||||
|
{branch.name || branch.id}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
data-port-id={branch.id}
|
||||||
|
data-port-type="output"
|
||||||
|
className="absolute -right-4 top-1/2 size-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Field>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultPorts(type: string) {
|
||||||
|
if (type === "start") return [{ type: "output" as const }]
|
||||||
|
if (type === "end") return [{ type: "input" as const }]
|
||||||
|
if (type === "condition") return [{ type: "input" as const }]
|
||||||
|
return [{ type: "input" as const }, { type: "output" as const }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import {
|
||||||
|
WorkflowNodePanelService,
|
||||||
|
WorkflowNodePanelUtils,
|
||||||
|
} from "@flowgram.ai/free-node-panel-plugin"
|
||||||
|
import {
|
||||||
|
delay,
|
||||||
|
type FreeLayoutPluginContext,
|
||||||
|
type onDragLineEndParams,
|
||||||
|
WorkflowDragService,
|
||||||
|
WorkflowLinesManager,
|
||||||
|
type WorkflowNodeEntity,
|
||||||
|
type WorkflowNodeJSON,
|
||||||
|
} from "@flowgram.ai/free-layout-editor"
|
||||||
|
|
||||||
|
export async function onDragLineEnd(
|
||||||
|
context: FreeLayoutPluginContext,
|
||||||
|
params: onDragLineEndParams
|
||||||
|
) {
|
||||||
|
const { fromPort, toPort, mousePos, line, originLine } = params
|
||||||
|
if (originLine || !line || toPort || !fromPort) return
|
||||||
|
|
||||||
|
const nodePanel = context.get(WorkflowNodePanelService)
|
||||||
|
const dragService = context.get(WorkflowDragService)
|
||||||
|
const linesManager = context.get(WorkflowLinesManager)
|
||||||
|
const containerNode = fromPort.node.parent
|
||||||
|
const result = await nodePanel.singleSelectNodePanel({
|
||||||
|
position:
|
||||||
|
fromPort.location === "bottom"
|
||||||
|
? { x: mousePos.x - 165, y: mousePos.y + 60 }
|
||||||
|
: mousePos,
|
||||||
|
containerNode,
|
||||||
|
panelProps: {
|
||||||
|
enableNodePlaceholder: true,
|
||||||
|
enableScrollClose: true,
|
||||||
|
fromPort,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!result) return
|
||||||
|
|
||||||
|
const position = WorkflowNodePanelUtils.adjustNodePosition({
|
||||||
|
nodeType: result.nodeType,
|
||||||
|
position: mousePos,
|
||||||
|
fromPort,
|
||||||
|
toPort,
|
||||||
|
containerNode,
|
||||||
|
document: context.document,
|
||||||
|
dragService,
|
||||||
|
})
|
||||||
|
const node: WorkflowNodeEntity = context.document.createWorkflowNodeByType(
|
||||||
|
result.nodeType,
|
||||||
|
position,
|
||||||
|
result.nodeJSON ?? ({} as WorkflowNodeJSON),
|
||||||
|
containerNode?.id
|
||||||
|
)
|
||||||
|
await delay(20)
|
||||||
|
WorkflowNodePanelUtils.buildLine({ fromPort, node, linesManager })
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type {
|
||||||
|
AIWorkflowDefinition,
|
||||||
|
AIWorkflowNodeSpec,
|
||||||
|
AIWorkflowValue,
|
||||||
|
AIWorkflowVariableSpec,
|
||||||
|
} from "@/lib/api/admin"
|
||||||
|
|
||||||
|
export type WorkflowNode = AIWorkflowDefinition["nodes"][number]
|
||||||
|
export type WorkflowEdge = AIWorkflowDefinition["edges"][number]
|
||||||
|
|
||||||
|
export type WorkflowCondition = {
|
||||||
|
left?: AIWorkflowValue
|
||||||
|
operator?: string
|
||||||
|
right?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkflowConditionBranch = {
|
||||||
|
id: string
|
||||||
|
name?: string
|
||||||
|
targetNodeId: string
|
||||||
|
condition?: WorkflowCondition
|
||||||
|
default?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkflowVariable = AIWorkflowVariableSpec & {
|
||||||
|
nodeId: string
|
||||||
|
nodeTitle: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkflowEditorContextValue = {
|
||||||
|
nodeSpecs: AIWorkflowNodeSpec[]
|
||||||
|
readonly: boolean
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback } from "react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
WorkflowNodePanelService,
|
||||||
|
WorkflowNodePanelUtils,
|
||||||
|
} from "@flowgram.ai/free-node-panel-plugin"
|
||||||
|
import {
|
||||||
|
delay,
|
||||||
|
usePlayground,
|
||||||
|
useService,
|
||||||
|
WorkflowDocument,
|
||||||
|
WorkflowDragService,
|
||||||
|
WorkflowLinesManager,
|
||||||
|
type WorkflowNodeEntity,
|
||||||
|
type WorkflowNodeJSON,
|
||||||
|
type WorkflowPortEntity,
|
||||||
|
} from "@flowgram.ai/free-layout-editor"
|
||||||
|
|
||||||
|
export function usePortClick() {
|
||||||
|
const playground = usePlayground()
|
||||||
|
const nodePanel = useService(WorkflowNodePanelService)
|
||||||
|
const document = useService(WorkflowDocument)
|
||||||
|
const dragService = useService(WorkflowDragService)
|
||||||
|
const linesManager = useService(WorkflowLinesManager)
|
||||||
|
|
||||||
|
return useCallback(
|
||||||
|
async (event: React.MouseEvent, port: WorkflowPortEntity) => {
|
||||||
|
if (port.portType === "input") return
|
||||||
|
const mousePosition = playground.config.getPosFromMouseEvent(event)
|
||||||
|
const containerNode = port.node.parent
|
||||||
|
const result = await nodePanel.singleSelectNodePanel({
|
||||||
|
position: mousePosition,
|
||||||
|
containerNode,
|
||||||
|
panelProps: {
|
||||||
|
enableScrollClose: true,
|
||||||
|
fromPort: port,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!result) return
|
||||||
|
|
||||||
|
const nodePosition = WorkflowNodePanelUtils.adjustNodePosition({
|
||||||
|
nodeType: result.nodeType,
|
||||||
|
position:
|
||||||
|
port.location === "bottom"
|
||||||
|
? { x: mousePosition.x, y: mousePosition.y + 100 }
|
||||||
|
: { x: mousePosition.x + 100, y: mousePosition.y },
|
||||||
|
fromPort: port,
|
||||||
|
containerNode,
|
||||||
|
document,
|
||||||
|
dragService,
|
||||||
|
})
|
||||||
|
const node: WorkflowNodeEntity = document.createWorkflowNodeByType(
|
||||||
|
result.nodeType,
|
||||||
|
nodePosition,
|
||||||
|
result.nodeJSON ?? ({} as WorkflowNodeJSON),
|
||||||
|
containerNode?.id
|
||||||
|
)
|
||||||
|
await delay(20)
|
||||||
|
WorkflowNodePanelUtils.buildLine({
|
||||||
|
fromPort: port,
|
||||||
|
node,
|
||||||
|
linesManager,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[document, dragService, linesManager, nodePanel, playground]
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
EditorRenderer,
|
||||||
|
FreeLayoutEditorProvider,
|
||||||
|
} from "@flowgram.ai/free-layout-editor"
|
||||||
|
import { DockedPanelLayer } from "@flowgram.ai/panel-manager-plugin"
|
||||||
|
import { AlertCircleIcon, CheckCircle2Icon, XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import type {
|
||||||
|
AIWorkflowDefinition,
|
||||||
|
AIWorkflowNodeSpec,
|
||||||
|
AIWorkflowValidationResult,
|
||||||
|
} from "@/lib/api/admin"
|
||||||
|
|
||||||
|
import { EditorTools } from "./editor-tools"
|
||||||
|
import { EditorCanvasEvents } from "./editor-canvas-events"
|
||||||
|
import { WorkflowEditorContextProvider } from "./editor-context"
|
||||||
|
import { useWorkflowEditorProps } from "./editor-provider"
|
||||||
|
|
||||||
|
export function WorkflowEditor({
|
||||||
|
definition,
|
||||||
|
nodeSpecs,
|
||||||
|
onDefinitionChange,
|
||||||
|
onValidate,
|
||||||
|
}: {
|
||||||
|
definition: AIWorkflowDefinition
|
||||||
|
nodeSpecs: AIWorkflowNodeSpec[]
|
||||||
|
onDefinitionChange: (definition: AIWorkflowDefinition) => void
|
||||||
|
onValidate: () => Promise<AIWorkflowValidationResult>
|
||||||
|
}) {
|
||||||
|
const [validation, setValidation] =
|
||||||
|
useState<AIWorkflowValidationResult | null>(null)
|
||||||
|
const props = useWorkflowEditorProps({
|
||||||
|
definition,
|
||||||
|
nodeSpecs,
|
||||||
|
onDefinitionChange,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WorkflowEditorContextProvider value={{ nodeSpecs, readonly: false }}>
|
||||||
|
<div className="relative h-full min-h-[560px] overflow-hidden bg-slate-50">
|
||||||
|
<FreeLayoutEditorProvider {...props}>
|
||||||
|
<DockedPanelLayer>
|
||||||
|
<EditorRenderer className="h-full w-full" />
|
||||||
|
<EditorCanvasEvents />
|
||||||
|
<EditorTools
|
||||||
|
onValidate={onValidate}
|
||||||
|
onValidation={setValidation}
|
||||||
|
/>
|
||||||
|
</DockedPanelLayer>
|
||||||
|
{validation ? (
|
||||||
|
<ProblemPanel
|
||||||
|
validation={validation}
|
||||||
|
onClose={() => setValidation(null)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</FreeLayoutEditorProvider>
|
||||||
|
</div>
|
||||||
|
</WorkflowEditorContextProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProblemPanel({
|
||||||
|
validation,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
validation: AIWorkflowValidationResult
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-x-0 bottom-0 z-40 h-[210px] border-t border-[rgba(82,100,154,0.13)] bg-[#fbfbfb] shadow-[0_-4px_12px_rgba(0,0,0,0.04)]">
|
||||||
|
<div className="flex h-[50px] items-center justify-between px-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||||
|
问题
|
||||||
|
{validation.valid ? (
|
||||||
|
<CheckCircle2Icon className="size-4 text-emerald-600" />
|
||||||
|
) : (
|
||||||
|
<span className="rounded-full bg-destructive px-1.5 py-0.5 text-[10px] leading-none text-white">
|
||||||
|
{validation.errors.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="icon-sm" onClick={onClose}>
|
||||||
|
<XIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="h-[160px] overflow-y-auto px-3 pb-3">
|
||||||
|
{validation.valid ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
未发现问题
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{validation.errors.map((error, index) => (
|
||||||
|
<div
|
||||||
|
key={`${error.field}-${index}`}
|
||||||
|
className="flex items-start gap-2 rounded border border-border px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<AlertCircleIcon className="mt-0.5 size-4 shrink-0 text-destructive" />
|
||||||
|
<div>
|
||||||
|
<div className="font-mono text-xs text-muted-foreground">
|
||||||
|
{error.field}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5">{error.message}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkflowReadonlyCanvas({
|
||||||
|
definition,
|
||||||
|
nodeSpecs = [],
|
||||||
|
}: {
|
||||||
|
definition: AIWorkflowDefinition
|
||||||
|
nodeSpecs?: AIWorkflowNodeSpec[]
|
||||||
|
}) {
|
||||||
|
const resolvedSpecs =
|
||||||
|
nodeSpecs.length > 0
|
||||||
|
? nodeSpecs
|
||||||
|
: Array.from(new Set(definition.nodes.map((node) => node.type))).map(
|
||||||
|
(type) => ({
|
||||||
|
type,
|
||||||
|
title:
|
||||||
|
String(
|
||||||
|
definition.nodes.find((node) => node.type === type)?.data?.title
|
||||||
|
) || type,
|
||||||
|
description: "工作流节点",
|
||||||
|
icon: type === "start" ? "PlayCircleIcon" : type === "end" ? "FlagIcon" : "GitBranchIcon",
|
||||||
|
riskLevel: "low" as const,
|
||||||
|
interruptible: false,
|
||||||
|
requiresConfirmationPredecessor: false,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const props = useWorkflowEditorProps({
|
||||||
|
definition,
|
||||||
|
nodeSpecs: resolvedSpecs,
|
||||||
|
readonly: true,
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<WorkflowEditorContextProvider value={{ nodeSpecs: resolvedSpecs, readonly: true }}>
|
||||||
|
<FreeLayoutEditorProvider {...props}>
|
||||||
|
<EditorRenderer className="h-full w-full" />
|
||||||
|
</FreeLayoutEditorProvider>
|
||||||
|
</WorkflowEditorContextProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import type {
|
||||||
|
AIWorkflowDefinition,
|
||||||
|
AIWorkflowNodeSpec,
|
||||||
|
AIWorkflowValue,
|
||||||
|
} from "@/lib/api/admin"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
WorkflowConditionBranch,
|
||||||
|
WorkflowNode,
|
||||||
|
WorkflowVariable,
|
||||||
|
} from "./types"
|
||||||
|
|
||||||
|
const defaultBranch: WorkflowConditionBranch = {
|
||||||
|
id: "default",
|
||||||
|
name: "默认分支",
|
||||||
|
targetNodeId: "",
|
||||||
|
default: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prepareDefinitionForEditor(
|
||||||
|
definition: AIWorkflowDefinition
|
||||||
|
): AIWorkflowDefinition {
|
||||||
|
const branchIDsByNode = new Map<string, Set<string>>()
|
||||||
|
const executableNodes = (definition.nodes ?? []).map((node) => {
|
||||||
|
if (node.type !== "condition") {
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
const branches = normalizeConditionBranches(node)
|
||||||
|
branchIDsByNode.set(node.id, new Set(branches.map((branch) => branch.id)))
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...node.data,
|
||||||
|
config: {
|
||||||
|
...asRecord(node.data?.config),
|
||||||
|
branches,
|
||||||
|
},
|
||||||
|
portKeys: branches.map((branch) => branch.id),
|
||||||
|
ports: branches.map((branch) => branch.id),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const nodes = normalizeEditorPositions([
|
||||||
|
...executableNodes,
|
||||||
|
...(definition.annotations ?? []),
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
schemaVersion: definition.schemaVersion || 2,
|
||||||
|
nodes,
|
||||||
|
annotations: undefined,
|
||||||
|
edges: (definition.edges ?? [])
|
||||||
|
.filter((edge) => {
|
||||||
|
if (!edge.sourcePortID) return true
|
||||||
|
const branchIDs = branchIDsByNode.get(edge.sourceNodeID)
|
||||||
|
return !branchIDs || branchIDs.has(edge.sourcePortID)
|
||||||
|
})
|
||||||
|
.map((edge) => {
|
||||||
|
if (edge.sourcePortID) return edge
|
||||||
|
const source = nodes.find((node) => node.id === edge.sourceNodeID)
|
||||||
|
if (source?.type !== "condition") return edge
|
||||||
|
const branch = normalizeConditionBranches(source).find(
|
||||||
|
(item) => item.targetNodeId === edge.targetNodeID
|
||||||
|
)
|
||||||
|
return branch ? { ...edge, sourcePortID: branch.id } : edge
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeDefinition(
|
||||||
|
definition: AIWorkflowDefinition
|
||||||
|
): AIWorkflowDefinition {
|
||||||
|
const edges = definition.edges ?? []
|
||||||
|
const annotations = (definition.nodes ?? []).filter(
|
||||||
|
(node) => node.type === "comment"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
schemaVersion: definition.schemaVersion || 2,
|
||||||
|
nodes: (definition.nodes ?? []).filter((node) => node.type !== "comment").map((node) => {
|
||||||
|
const data = { ...node.data }
|
||||||
|
delete data.portKeys
|
||||||
|
delete data.ports
|
||||||
|
if (node.type === "condition") {
|
||||||
|
const branches = normalizeConditionBranches(node).map((branch) => {
|
||||||
|
const edge = edges.find(
|
||||||
|
(item) =>
|
||||||
|
item.sourceNodeID === node.id &&
|
||||||
|
item.sourcePortID === branch.id
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...branch,
|
||||||
|
targetNodeId: edge?.targetNodeID ?? branch.targetNodeId ?? "",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
data.config = { ...asRecord(data.config), branches }
|
||||||
|
}
|
||||||
|
return { ...node, data }
|
||||||
|
}),
|
||||||
|
annotations,
|
||||||
|
edges,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNodeJSON(
|
||||||
|
spec: AIWorkflowNodeSpec,
|
||||||
|
existingNodeIDs: string[] = []
|
||||||
|
): WorkflowNode {
|
||||||
|
const id = uniqueNodeID(spec.type, existingNodeIDs)
|
||||||
|
const config =
|
||||||
|
spec.type === "condition" ? { branches: [defaultBranch] } : {}
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: spec.type,
|
||||||
|
meta: { position: { x: 0, y: 0 } },
|
||||||
|
data: {
|
||||||
|
title: spec.title || spec.type,
|
||||||
|
config,
|
||||||
|
inputsValues: spec.defaultInputs ?? {},
|
||||||
|
...(spec.type === "condition"
|
||||||
|
? { portKeys: [defaultBranch.id], ports: [defaultBranch.id] }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeConditionBranches(
|
||||||
|
node: Pick<WorkflowNode, "data">
|
||||||
|
): WorkflowConditionBranch[] {
|
||||||
|
const config = asRecord(node.data?.config)
|
||||||
|
const values = Array.isArray(config.branches) ? config.branches : []
|
||||||
|
const branches = values
|
||||||
|
.map(normalizeBranch)
|
||||||
|
.filter((item): item is WorkflowConditionBranch => Boolean(item))
|
||||||
|
const nonDefault = branches.filter((branch) => !branch.default)
|
||||||
|
const fallback =
|
||||||
|
branches.find((branch) => branch.default) ?? defaultBranch
|
||||||
|
return [...nonDefault, fallback]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextBranchID(branches: WorkflowConditionBranch[]) {
|
||||||
|
const used = new Set(branches.map((branch) => branch.id))
|
||||||
|
for (let index = 1; index < 10000; index += 1) {
|
||||||
|
const id = `branch_${index}`
|
||||||
|
if (!used.has(id)) return id
|
||||||
|
}
|
||||||
|
return `branch_${Date.now()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAvailableVariables(
|
||||||
|
definition: AIWorkflowDefinition,
|
||||||
|
nodeID: string,
|
||||||
|
specs: AIWorkflowNodeSpec[]
|
||||||
|
): WorkflowVariable[] {
|
||||||
|
const ancestors = collectAncestorIDs(definition, nodeID)
|
||||||
|
const specByType = new Map(specs.map((spec) => [spec.type, spec]))
|
||||||
|
return ancestors.flatMap((ancestorID) => {
|
||||||
|
const node = definition.nodes.find((item) => item.id === ancestorID)
|
||||||
|
if (!node) return []
|
||||||
|
const spec = specByType.get(node.type)
|
||||||
|
return (spec?.outputSchema ?? []).map((output) => ({
|
||||||
|
...output,
|
||||||
|
nodeId: node.id,
|
||||||
|
nodeTitle: String(node.data?.title || spec?.title || node.type),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refValue(nodeID: string, field: string): AIWorkflowValue {
|
||||||
|
return { type: "ref", content: [nodeID, field] }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refKey(value: AIWorkflowValue | undefined) {
|
||||||
|
if (value?.type !== "ref" || !Array.isArray(value.content)) return ""
|
||||||
|
return `${value.content[0]}.${value.content[1]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRefKey(value: string): AIWorkflowValue | undefined {
|
||||||
|
const separator = value.indexOf(".")
|
||||||
|
if (separator <= 0 || separator === value.length - 1) return undefined
|
||||||
|
return refValue(value.slice(0, separator), value.slice(separator + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueNodeID(type: string, existingNodeIDs: string[]) {
|
||||||
|
const normalized = type.replace(/[^a-zA-Z0-9_]/g, "_") || "node"
|
||||||
|
const used = new Set(existingNodeIDs)
|
||||||
|
for (let index = 1; index < 10000; index += 1) {
|
||||||
|
const id = `${normalized}_${index}`
|
||||||
|
if (!used.has(id)) return id
|
||||||
|
}
|
||||||
|
return `${normalized}_${Date.now()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEditorPositions(
|
||||||
|
nodes: AIWorkflowDefinition["nodes"]
|
||||||
|
): AIWorkflowDefinition["nodes"] {
|
||||||
|
const executableXs = Array.from(
|
||||||
|
new Set(
|
||||||
|
nodes
|
||||||
|
.filter((node) => node.type !== "comment")
|
||||||
|
.map((node) => node.meta.position.x)
|
||||||
|
)
|
||||||
|
).sort((left, right) => left - right)
|
||||||
|
const positiveGaps = executableXs
|
||||||
|
.slice(1)
|
||||||
|
.map((x, index) => x - executableXs[index])
|
||||||
|
.filter((gap) => gap > 0)
|
||||||
|
const minimumGap = positiveGaps.length ? Math.min(...positiveGaps) : 0
|
||||||
|
if (!minimumGap || minimumGap >= 460) return nodes
|
||||||
|
|
||||||
|
const origin = executableXs[0]
|
||||||
|
const scale = 460 / minimumGap
|
||||||
|
return nodes.map((node) =>
|
||||||
|
node.type === "comment"
|
||||||
|
? node
|
||||||
|
: {
|
||||||
|
...node,
|
||||||
|
meta: {
|
||||||
|
...node.meta,
|
||||||
|
position: {
|
||||||
|
...node.meta.position,
|
||||||
|
x: origin + (node.meta.position.x - origin) * scale,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBranch(value: unknown): WorkflowConditionBranch | null {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null
|
||||||
|
const item = value as Record<string, unknown>
|
||||||
|
const id = String(item.id ?? "").trim()
|
||||||
|
if (!id) return null
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: String(item.name ?? "").trim(),
|
||||||
|
targetNodeId: String(item.targetNodeId ?? "").trim(),
|
||||||
|
default: Boolean(item.default),
|
||||||
|
condition:
|
||||||
|
item.condition && typeof item.condition === "object"
|
||||||
|
? (item.condition as WorkflowConditionBranch["condition"])
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectAncestorIDs(
|
||||||
|
definition: AIWorkflowDefinition,
|
||||||
|
nodeID: string
|
||||||
|
) {
|
||||||
|
const incoming = new Map<string, string[]>()
|
||||||
|
for (const edge of definition.edges ?? []) {
|
||||||
|
incoming.set(edge.targetNodeID, [
|
||||||
|
...(incoming.get(edge.targetNodeID) ?? []),
|
||||||
|
edge.sourceNodeID,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
const queue = [...(incoming.get(nodeID) ?? [])]
|
||||||
|
const visited = new Set<string>()
|
||||||
|
while (queue.length) {
|
||||||
|
const current = queue.shift()
|
||||||
|
if (!current || visited.has(current)) continue
|
||||||
|
visited.add(current)
|
||||||
|
queue.push(...(incoming.get(current) ?? []))
|
||||||
|
}
|
||||||
|
return definition.nodes
|
||||||
|
.map((node) => node.id)
|
||||||
|
.filter((id) => visited.has(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: {}
|
||||||
|
}
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useMemo } from "react"
|
|
||||||
|
|
||||||
import {
|
|
||||||
type FreeLayoutPluginContext,
|
|
||||||
type FreeLayoutProps,
|
|
||||||
type WorkflowNodeEntity,
|
|
||||||
type WorkflowJSON,
|
|
||||||
} from "@flowgram.ai/free-layout-editor"
|
|
||||||
import { createFreeLinesPlugin } from "@flowgram.ai/free-lines-plugin"
|
|
||||||
import { createFreeSnapPlugin } from "@flowgram.ai/free-snap-plugin"
|
|
||||||
import { createMinimapPlugin } from "@flowgram.ai/minimap-plugin"
|
|
||||||
|
|
||||||
import type { AIWorkflowDefinition, AIWorkflowNodeSpec } from "@/lib/api/admin"
|
|
||||||
|
|
||||||
import { FlowgramNodeRenderer } from "./flowgram-node-renderer"
|
|
||||||
import { buildFlowgramNodeRegistries } from "./flowgram-node-registries"
|
|
||||||
import { WorkflowLineAddButton } from "./workflow-line-add-button"
|
|
||||||
import {
|
|
||||||
normalizeConditionPortsForFlowgram,
|
|
||||||
syncConditionBranchTargetsFromEdges,
|
|
||||||
} from "./workflow-utils"
|
|
||||||
|
|
||||||
export function useFlowgramEditorProps({
|
|
||||||
definition,
|
|
||||||
nodeSpecs,
|
|
||||||
readonly = false,
|
|
||||||
onDefinitionChange,
|
|
||||||
}: {
|
|
||||||
definition: AIWorkflowDefinition
|
|
||||||
nodeSpecs: AIWorkflowNodeSpec[]
|
|
||||||
readonly?: boolean
|
|
||||||
onDefinitionChange?: (definition: AIWorkflowDefinition) => void
|
|
||||||
}) {
|
|
||||||
return useMemo<FreeLayoutProps>(
|
|
||||||
() => {
|
|
||||||
const initialData = normalizeConditionPortsForFlowgram(definition)
|
|
||||||
return {
|
|
||||||
background: true,
|
|
||||||
readonly,
|
|
||||||
scroll: {
|
|
||||||
disableScrollBar: true,
|
|
||||||
},
|
|
||||||
initialData: initialData as WorkflowJSON,
|
|
||||||
fromNodeJSON(_node, json) {
|
|
||||||
return json
|
|
||||||
},
|
|
||||||
toNodeJSON(_node, json) {
|
|
||||||
return json
|
|
||||||
},
|
|
||||||
materials: {
|
|
||||||
renderDefaultNode: FlowgramNodeRenderer,
|
|
||||||
},
|
|
||||||
nodeRegistries: buildFlowgramNodeRegistries(nodeSpecs),
|
|
||||||
nodeEngine: {
|
|
||||||
enable: true,
|
|
||||||
},
|
|
||||||
history: {
|
|
||||||
enable: !readonly,
|
|
||||||
enableChangeNode: !readonly,
|
|
||||||
},
|
|
||||||
canDeleteNode: (_ctx, node) => {
|
|
||||||
const type = String(node.flowNodeType ?? "")
|
|
||||||
return type !== "start"
|
|
||||||
},
|
|
||||||
canDeleteLine: () => !readonly,
|
|
||||||
onContentChange: (ctx) => {
|
|
||||||
if (readonly) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const next = normalizeConditionPortsForFlowgram(
|
|
||||||
syncConditionBranchTargetsFromEdges(ctx.document.toJSON() as AIWorkflowDefinition)
|
|
||||||
)
|
|
||||||
onDefinitionChange?.(next)
|
|
||||||
},
|
|
||||||
onAllLayersRendered: (ctx) => {
|
|
||||||
scrollToInitialNode(ctx)
|
|
||||||
},
|
|
||||||
getNodeDefaultRegistry(type) {
|
|
||||||
return {
|
|
||||||
type,
|
|
||||||
meta: {
|
|
||||||
defaultExpanded: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
plugins: () => [
|
|
||||||
createFreeLinesPlugin({
|
|
||||||
renderInsideLine: WorkflowLineAddButton,
|
|
||||||
}),
|
|
||||||
createMinimapPlugin({
|
|
||||||
disableLayer: true,
|
|
||||||
}),
|
|
||||||
createFreeSnapPlugin({}),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[definition, nodeSpecs, onDefinitionChange, readonly]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollToInitialNode(ctx: FreeLayoutPluginContext) {
|
|
||||||
const nodes = ctx.document.getAllNodes()
|
|
||||||
const startNode = nodes.find((node) => String(node.flowNodeType ?? "") === "start")
|
|
||||||
const targetNode = startNode ?? findLeftTopNode(nodes)
|
|
||||||
if (!targetNode) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
window.requestAnimationFrame(() => {
|
|
||||||
const viewport = ctx.playground.config.getViewport(false)
|
|
||||||
void ctx.playground.scrollToView({
|
|
||||||
bounds: targetNode.transform.bounds,
|
|
||||||
scrollDelta: {
|
|
||||||
x: Math.max(viewport.width / 2 - 250, 0),
|
|
||||||
y: Math.max(viewport.height / 2 - 180, 0),
|
|
||||||
},
|
|
||||||
zoom: 1,
|
|
||||||
scrollToCenter: true,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function findLeftTopNode(nodes: WorkflowNodeEntity[]) {
|
|
||||||
return [...nodes].sort((left, right) => {
|
|
||||||
const leftBounds = left.transform.bounds
|
|
||||||
const rightBounds = right.transform.bounds
|
|
||||||
if (leftBounds.left !== rightBounds.left) {
|
|
||||||
return leftBounds.left - rightBounds.left
|
|
||||||
}
|
|
||||||
return leftBounds.top - rightBounds.top
|
|
||||||
})[0]
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
import { Field, type WorkflowNodeRegistry, useNodeRender } from "@flowgram.ai/free-layout-editor"
|
|
||||||
|
|
||||||
import type { AIWorkflowNodeSpec } from "@/lib/api/admin"
|
|
||||||
import { WorkflowConditionNodeContent } from "./workflow-condition-node-content"
|
|
||||||
import { WorkflowNodeCard } from "./workflow-node-card"
|
|
||||||
|
|
||||||
export function buildFlowgramNodeRegistries(nodeSpecs: AIWorkflowNodeSpec[]): WorkflowNodeRegistry[] {
|
|
||||||
const seen = new Set<string>()
|
|
||||||
const specs = nodeSpecs.length > 0
|
|
||||||
? nodeSpecs
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
type: "start",
|
|
||||||
title: "开始",
|
|
||||||
description: "流程入口",
|
|
||||||
icon: "PlayCircleIcon",
|
|
||||||
riskLevel: "low" as const,
|
|
||||||
interruptible: false,
|
|
||||||
requiresConfirmationPredecessor: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "end",
|
|
||||||
title: "结束",
|
|
||||||
description: "流程结束",
|
|
||||||
icon: "FlagIcon",
|
|
||||||
riskLevel: "low" as const,
|
|
||||||
interruptible: false,
|
|
||||||
requiresConfirmationPredecessor: false,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return specs
|
|
||||||
.filter((spec) => {
|
|
||||||
if (!spec.type || seen.has(spec.type)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
seen.add(spec.type)
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
.map((spec) => ({
|
|
||||||
type: spec.type,
|
|
||||||
meta: {
|
|
||||||
defaultExpanded: true,
|
|
||||||
isStart: spec.type === "start",
|
|
||||||
deleteDisable: spec.type === "start",
|
|
||||||
copyDisable: spec.type === "start",
|
|
||||||
defaultPorts: defaultPortsForNodeType(spec.type),
|
|
||||||
},
|
|
||||||
formMeta: {
|
|
||||||
render: () => (
|
|
||||||
<FlowgramNodeForm
|
|
||||||
nodeType={spec.type}
|
|
||||||
fallbackTitle={spec.title || spec.type}
|
|
||||||
icon={spec.icon}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
function FlowgramNodeForm({
|
|
||||||
nodeType,
|
|
||||||
fallbackTitle,
|
|
||||||
icon,
|
|
||||||
}: {
|
|
||||||
nodeType: string
|
|
||||||
fallbackTitle: string
|
|
||||||
icon: string
|
|
||||||
}) {
|
|
||||||
const { node, selected } = useNodeRender()
|
|
||||||
const nodeId = String(node.id ?? "")
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Field<string> name="title">
|
|
||||||
{({ field }) => (
|
|
||||||
<WorkflowNodeCard
|
|
||||||
title={field.value || fallbackTitle}
|
|
||||||
icon={icon}
|
|
||||||
selected={selected}
|
|
||||||
>
|
|
||||||
{nodeType === "condition" ? (
|
|
||||||
<Field<Record<string, unknown>> name="config">
|
|
||||||
{({ field: configField }) => (
|
|
||||||
<WorkflowConditionNodeContent
|
|
||||||
configValue={configField.value}
|
|
||||||
nodeId={nodeId}
|
|
||||||
onChange={configField.onChange}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
) : null}
|
|
||||||
</WorkflowNodeCard>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultPortsForNodeType(type: string) {
|
|
||||||
if (type === "start") {
|
|
||||||
return [{ type: "output" as const }]
|
|
||||||
}
|
|
||||||
if (type === "end") {
|
|
||||||
return [{ type: "input" as const }]
|
|
||||||
}
|
|
||||||
if (type === "condition") {
|
|
||||||
return [{ type: "input" as const }]
|
|
||||||
}
|
|
||||||
return [{ type: "input" as const }, { type: "output" as const }]
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import "@flowgram.ai/free-layout-editor/index.css"
|
|
||||||
|
|
||||||
import {
|
|
||||||
useNodeRender,
|
|
||||||
WorkflowNodeRenderer,
|
|
||||||
type WorkflowNodeProps,
|
|
||||||
} from "@flowgram.ai/free-layout-editor"
|
|
||||||
import { useLayoutEffect } from "react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { useWorkflowPortAdd } from "./workflow-port-add-context"
|
|
||||||
|
|
||||||
export function FlowgramNodeRenderer(props: WorkflowNodeProps) {
|
|
||||||
const { selected, node, form } = useNodeRender()
|
|
||||||
const requestPortAdd = useWorkflowPortAdd()
|
|
||||||
const nodeType = String(node.flowNodeType ?? "")
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (nodeType !== "condition") return
|
|
||||||
const frame = window.requestAnimationFrame(() => {
|
|
||||||
node.ports.updateDynamicPorts()
|
|
||||||
})
|
|
||||||
return () => window.cancelAnimationFrame(frame)
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<WorkflowNodeRenderer
|
|
||||||
node={props.node}
|
|
||||||
className={cn(
|
|
||||||
"overflow-visible rounded-lg border transition-colors",
|
|
||||||
selected ? "border-(--g-selection-background)" : "border-transparent"
|
|
||||||
)}
|
|
||||||
style={{ padding: 0 }}
|
|
||||||
portClassName="workflow-node-port"
|
|
||||||
portPrimaryColor="#2575FC"
|
|
||||||
portSecondaryColor="#c9cdd4"
|
|
||||||
portBackgroundColor="#FFFFFF"
|
|
||||||
onPortClick={(port, event) => {
|
|
||||||
if (port.portType !== "output" || typeof event === "function") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
event.stopPropagation()
|
|
||||||
requestPortAdd?.({ sourcePort: port, event })
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{form?.render()}
|
|
||||||
</WorkflowNodeRenderer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,793 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState, type ReactNode } from "react"
|
|
||||||
import { CheckIcon, ChevronsUpDownIcon, Trash2Icon } from "lucide-react"
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList,
|
|
||||||
} from "@/components/ui/command"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover"
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|
||||||
import { OptionCombobox } from "@/components/option-combobox"
|
|
||||||
import { fetchKnowledgeBasesAll, type AIWorkflowDefinition, type AIWorkflowNodeSpec, type KnowledgeBase } from "@/lib/api/admin"
|
|
||||||
import { Status } from "@/lib/generated/enums"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
import { VariableSelector } from "./variable-selector"
|
|
||||||
import {
|
|
||||||
buildVariableSpecDisplay,
|
|
||||||
createConditionBranchID,
|
|
||||||
isRefValue,
|
|
||||||
normalizeNodeConfig,
|
|
||||||
refField,
|
|
||||||
refNodeId,
|
|
||||||
type WorkflowConditionBranch,
|
|
||||||
type WorkflowVariableRef,
|
|
||||||
} from "./workflow-utils"
|
|
||||||
|
|
||||||
export type WorkflowBranchSummary = {
|
|
||||||
branchId: string
|
|
||||||
targetNodeId?: string
|
|
||||||
targetName?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const CONDITION_OPERATOR_OPTIONS = [
|
|
||||||
{ value: "eq", label: "等于" },
|
|
||||||
{ value: "neq", label: "不等于" },
|
|
||||||
{ value: "contains", label: "包含" },
|
|
||||||
{ value: "not_contains", label: "不包含" },
|
|
||||||
{ value: "gt", label: "大于" },
|
|
||||||
{ value: "gte", label: "大于等于" },
|
|
||||||
{ value: "lt", label: "小于" },
|
|
||||||
{ value: "lte", label: "小于等于" },
|
|
||||||
{ value: "exists", label: "存在" },
|
|
||||||
{ value: "empty", label: "为空" },
|
|
||||||
]
|
|
||||||
|
|
||||||
const inspectorInputClassName = "h-8 rounded-sm border-slate-200 bg-white px-2 text-sm shadow-none"
|
|
||||||
const inspectorComboboxClassName = "h-8 rounded-sm border-slate-200 bg-white text-sm shadow-none"
|
|
||||||
|
|
||||||
export function NodeConfigPanel({
|
|
||||||
node,
|
|
||||||
nodeSpec,
|
|
||||||
nodes,
|
|
||||||
availableVariables,
|
|
||||||
showHeader = true,
|
|
||||||
showConditionBranches = true,
|
|
||||||
onChange,
|
|
||||||
onDelete,
|
|
||||||
}: {
|
|
||||||
node: AIWorkflowDefinition["nodes"][number] | null
|
|
||||||
nodeSpec?: AIWorkflowNodeSpec
|
|
||||||
nodes: AIWorkflowDefinition["nodes"]
|
|
||||||
availableVariables?: WorkflowVariableRef[]
|
|
||||||
branchSummaries?: WorkflowBranchSummary[]
|
|
||||||
showHeader?: boolean
|
|
||||||
showConditionBranches?: boolean
|
|
||||||
onChange: (nodeId: string, data: AIWorkflowDefinition["nodes"][number]["data"]) => void
|
|
||||||
onDelete?: (nodeId: string) => void
|
|
||||||
}) {
|
|
||||||
if (!node) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full items-center justify-center p-6 text-sm text-muted-foreground">
|
|
||||||
未选择节点
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputsValues = node.data?.inputsValues ?? {}
|
|
||||||
const inputSchema = nodeSpec?.inputSchema ?? []
|
|
||||||
const outputSchema = nodeSpec?.outputSchema ?? []
|
|
||||||
const canDelete = node.type !== "start" && node.type !== "end"
|
|
||||||
const config = normalizeNodeConfig(node.data?.config)
|
|
||||||
const branches = config.branches ?? []
|
|
||||||
const updateData = (data: Partial<AIWorkflowDefinition["nodes"][number]["data"]>) => {
|
|
||||||
onChange(node.id, {
|
|
||||||
...(node.data ?? {}),
|
|
||||||
...data,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const updateConfig = (nextConfig: Record<string, unknown>) => updateData({ config: nextConfig })
|
|
||||||
const inputFields = inputSchema.map((input) => {
|
|
||||||
const value = inputsValues[input.name]
|
|
||||||
return (
|
|
||||||
<InspectorField
|
|
||||||
key={input.name}
|
|
||||||
label={input.label || input.name}
|
|
||||||
required={input.required}
|
|
||||||
fieldName={input.name}
|
|
||||||
fieldType={input.type}
|
|
||||||
>
|
|
||||||
<VariableSelector
|
|
||||||
value={isRefValue(value) ? value : undefined}
|
|
||||||
variables={availableVariables ?? []}
|
|
||||||
placeholder="选择变量"
|
|
||||||
triggerClassName={inspectorComboboxClassName}
|
|
||||||
onChange={(next) => {
|
|
||||||
updateData({
|
|
||||||
inputsValues: {
|
|
||||||
...inputsValues,
|
|
||||||
[input.name]: next,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{input.description ? <InspectorHint>{input.description}</InspectorHint> : null}
|
|
||||||
</InspectorField>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
const outputFields = outputSchema.map((output) => {
|
|
||||||
const item = buildVariableSpecDisplay(output)
|
|
||||||
return (
|
|
||||||
<InspectorField key={item.key} label={item.label} detail={item.subtitle}>
|
|
||||||
{item.description ? <InspectorHint>{item.description}</InspectorHint> : null}
|
|
||||||
</InspectorField>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
const updateBranch = (branch: WorkflowConditionBranch) => {
|
|
||||||
const nextBranches = branches.some((item) => item.id === branch.id)
|
|
||||||
? branches.map((item) => (item.id === branch.id ? branch : item))
|
|
||||||
: [...branches, branch]
|
|
||||||
updateConfig({ ...config, branches: nextBranches })
|
|
||||||
}
|
|
||||||
const deleteBranch = (branchId: string) => {
|
|
||||||
updateConfig({ ...config, branches: branches.filter((branch) => branch.id !== branchId) })
|
|
||||||
}
|
|
||||||
const addBranch = () => {
|
|
||||||
const targetNodeId = nodes.find((item) => item.id !== node.id && item.type !== "start")?.id ?? ""
|
|
||||||
updateBranch({
|
|
||||||
id: createConditionBranchID(branches),
|
|
||||||
name: "新分支",
|
|
||||||
targetNodeId,
|
|
||||||
condition: {
|
|
||||||
operator: "eq",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="pb-3">
|
|
||||||
{showHeader ? (
|
|
||||||
<div className="border-b px-4 py-3">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate text-sm font-medium">
|
|
||||||
{node.data?.title || nodeSpec?.title || node.type}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 truncate text-sm text-muted-foreground">{node.id}</div>
|
|
||||||
</div>
|
|
||||||
{canDelete ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="size-8 shrink-0 text-muted-foreground hover:text-destructive"
|
|
||||||
onClick={() => onDelete?.(node.id)}
|
|
||||||
aria-label="删除节点"
|
|
||||||
>
|
|
||||||
<Trash2Icon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="overflow-hidden border-y border-slate-200 bg-white">
|
|
||||||
<InspectorParameterTabs
|
|
||||||
key={node.id}
|
|
||||||
inputCount={inputSchema.length}
|
|
||||||
outputCount={outputSchema.length}
|
|
||||||
inputContent={inputFields}
|
|
||||||
outputContent={outputFields}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{node.type === "knowledge_retrieve" ? (
|
|
||||||
<KnowledgeRetrieveConfigPanel
|
|
||||||
config={config}
|
|
||||||
onChange={(nextConfig) => updateConfig(nextConfig)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{showConditionBranches && (node.type === "condition" || branches.length > 0) ? (
|
|
||||||
<ConditionBranchesEditor
|
|
||||||
branches={branches}
|
|
||||||
nodes={nodes}
|
|
||||||
currentNodeId={node.id}
|
|
||||||
variables={availableVariables ?? []}
|
|
||||||
onAdd={addBranch}
|
|
||||||
onChange={updateBranch}
|
|
||||||
onDelete={deleteBranch}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ConditionBranchConfigPanel({
|
|
||||||
node,
|
|
||||||
nodes,
|
|
||||||
branchId,
|
|
||||||
variables,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
node: AIWorkflowDefinition["nodes"][number]
|
|
||||||
nodes: AIWorkflowDefinition["nodes"]
|
|
||||||
branchId: string
|
|
||||||
variables: WorkflowVariableRef[]
|
|
||||||
onChange: (nodeId: string, data: AIWorkflowDefinition["nodes"][number]["data"]) => void
|
|
||||||
}) {
|
|
||||||
const config = normalizeNodeConfig(node.data?.config)
|
|
||||||
const branches = config.branches ?? []
|
|
||||||
const branch = branches.find((item) => item.id === branchId)
|
|
||||||
const targetOptions = buildTargetOptions(nodes, node.id)
|
|
||||||
|
|
||||||
if (!branch) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full items-center justify-center p-6 text-sm text-muted-foreground">
|
|
||||||
条件分支不存在
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateBranch = (nextBranch: WorkflowConditionBranch) => {
|
|
||||||
onChange(node.id, {
|
|
||||||
...(node.data ?? {}),
|
|
||||||
config: {
|
|
||||||
...config,
|
|
||||||
branches: branches.map((item) => (item.id === nextBranch.id ? nextBranch : item)),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="pb-3">
|
|
||||||
<div className="overflow-hidden border-y border-slate-200 bg-white">
|
|
||||||
<InspectorSection title="分支" meta={branch.default ? "默认" : "条件"}>
|
|
||||||
<InspectorRow label="目标节点">
|
|
||||||
<OptionCombobox
|
|
||||||
value={branch.targetNodeId}
|
|
||||||
options={targetOptions}
|
|
||||||
placeholder="选择目标节点"
|
|
||||||
triggerClassName={inspectorComboboxClassName}
|
|
||||||
preserveExternalSelection
|
|
||||||
onChange={(targetNodeId) => updateBranch({ ...branch, targetNodeId })}
|
|
||||||
/>
|
|
||||||
</InspectorRow>
|
|
||||||
|
|
||||||
<InspectorRow label="默认分支">
|
|
||||||
<label className="inline-flex h-8 items-center gap-2 text-sm text-slate-700">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={branch.default === true}
|
|
||||||
className="size-3.5"
|
|
||||||
onChange={(event) => updateBranch({
|
|
||||||
...branch,
|
|
||||||
default: event.target.checked,
|
|
||||||
condition: event.target.checked ? undefined : branch.condition,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
其他条件不匹配时执行
|
|
||||||
</label>
|
|
||||||
</InspectorRow>
|
|
||||||
</InspectorSection>
|
|
||||||
|
|
||||||
{branch.default ? (
|
|
||||||
<InspectorSection title="条件表达式">
|
|
||||||
<div className="px-3 py-2 text-sm text-slate-500">
|
|
||||||
默认分支不需要条件表达式,会在其他条件不匹配时执行。
|
|
||||||
</div>
|
|
||||||
</InspectorSection>
|
|
||||||
) : (
|
|
||||||
<InspectorSection title="条件表达式">
|
|
||||||
<ConditionFields branch={branch} variables={variables} onChange={updateBranch} />
|
|
||||||
</InspectorSection>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function KnowledgeRetrieveConfigPanel({
|
|
||||||
config,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
config: Record<string, unknown>
|
|
||||||
onChange: (config: Record<string, unknown>) => void
|
|
||||||
}) {
|
|
||||||
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([])
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const selectedKnowledgeIds = normalizeKnowledgeBaseIds(config.knowledgeBaseIds)
|
|
||||||
const knowledgeOptions = useMemo(
|
|
||||||
() => knowledgeBases.map((item) => ({ value: String(item.id), label: item.name })),
|
|
||||||
[knowledgeBases]
|
|
||||||
)
|
|
||||||
const selectedKnowledgeOptions = selectedKnowledgeIds
|
|
||||||
.map((id) => knowledgeOptions.find((option) => Number(option.value) === id))
|
|
||||||
.filter((option): option is { value: string; label: string } => Boolean(option))
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false
|
|
||||||
fetchKnowledgeBasesAll({ status: Status.Ok })
|
|
||||||
.then((items) => {
|
|
||||||
if (!cancelled) {
|
|
||||||
setKnowledgeBases(items ?? [])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!cancelled) {
|
|
||||||
setKnowledgeBases([])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const updateKnowledgeBaseIds = (ids: number[]) => {
|
|
||||||
onChange({ ...config, knowledgeBaseIds: uniquePositiveNumbers(ids) })
|
|
||||||
}
|
|
||||||
const toggleKnowledgeBase = (value: string) => {
|
|
||||||
const id = Number(value)
|
|
||||||
if (!Number.isFinite(id) || id <= 0) return
|
|
||||||
if (selectedKnowledgeIds.includes(id)) {
|
|
||||||
updateKnowledgeBaseIds(selectedKnowledgeIds.filter((item) => item !== id))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
updateKnowledgeBaseIds([...selectedKnowledgeIds, id])
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<InspectorSection title="节点配置" meta={`${selectedKnowledgeIds.length} 个知识库`}>
|
|
||||||
<InspectorRow label="知识库" required>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
|
||||||
<PopoverTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
className={cn("m-0 w-full justify-between font-normal", inspectorComboboxClassName)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className="truncate">
|
|
||||||
{selectedKnowledgeOptions.length === 0
|
|
||||||
? "选择知识库"
|
|
||||||
: selectedKnowledgeOptions.length === 1
|
|
||||||
? selectedKnowledgeOptions[0].label
|
|
||||||
: `已选择 ${selectedKnowledgeOptions.length} 个知识库`}
|
|
||||||
</span>
|
|
||||||
<ChevronsUpDownIcon className="ml-2 size-4 shrink-0 opacity-50" />
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent
|
|
||||||
className="w-(--radix-popover-trigger-width) p-0"
|
|
||||||
align="start"
|
|
||||||
data-workflow-preserve-selection
|
|
||||||
>
|
|
||||||
<Command>
|
|
||||||
<CommandInput placeholder="搜索知识库" />
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty>没有可用知识库</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{knowledgeOptions.map((option) => {
|
|
||||||
const selected = selectedKnowledgeIds.includes(Number(option.value))
|
|
||||||
return (
|
|
||||||
<CommandItem
|
|
||||||
key={option.value}
|
|
||||||
value={`${option.label} ${option.value}`}
|
|
||||||
onSelect={() => toggleKnowledgeBase(option.value)}
|
|
||||||
>
|
|
||||||
<CheckIcon
|
|
||||||
className={cn(
|
|
||||||
"mr-2 size-4 shrink-0",
|
|
||||||
selected ? "opacity-100" : "opacity-0"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="truncate">{option.label}</span>
|
|
||||||
</CommandItem>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
{selectedKnowledgeOptions.length === 0 ? (
|
|
||||||
<div className="text-sm text-slate-500">
|
|
||||||
未选择知识库,流程发布校验不会通过。
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</InspectorRow>
|
|
||||||
</InspectorSection>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConditionBranchesEditor({
|
|
||||||
branches,
|
|
||||||
nodes,
|
|
||||||
currentNodeId,
|
|
||||||
variables,
|
|
||||||
onAdd,
|
|
||||||
onChange,
|
|
||||||
onDelete,
|
|
||||||
}: {
|
|
||||||
branches: WorkflowConditionBranch[]
|
|
||||||
nodes: AIWorkflowDefinition["nodes"]
|
|
||||||
currentNodeId: string
|
|
||||||
variables: WorkflowVariableRef[]
|
|
||||||
onAdd: () => void
|
|
||||||
onChange: (branch: WorkflowConditionBranch) => void
|
|
||||||
onDelete: (branchId: string) => void
|
|
||||||
}) {
|
|
||||||
const targetOptions = buildTargetOptions(nodes, currentNodeId)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<InspectorSection
|
|
||||||
title="条件分支"
|
|
||||||
meta={`${branches.length} 项`}
|
|
||||||
action={
|
|
||||||
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-sm text-slate-600" onClick={onAdd}>
|
|
||||||
添加
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{branches.length === 0 ? (
|
|
||||||
<div className="px-3 py-3 text-sm text-slate-500">
|
|
||||||
暂无分支。条件节点需要至少一个默认分支或条件分支。
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="divide-y divide-slate-100">
|
|
||||||
{branches.map((branch) => {
|
|
||||||
return (
|
|
||||||
<div key={branch.id} className="bg-white px-3 py-2">
|
|
||||||
<div className="grid grid-cols-[44px_minmax(0,1fr)_auto] items-center gap-2">
|
|
||||||
<span className="inline-flex h-5 shrink-0 items-center justify-center rounded-sm border border-slate-200 bg-slate-50 px-1.5 font-mono text-xs font-semibold text-slate-600">
|
|
||||||
{branch.default ? "ELSE" : "IF"}
|
|
||||||
</span>
|
|
||||||
<Input
|
|
||||||
value={branch.name ?? ""}
|
|
||||||
placeholder={branch.id}
|
|
||||||
className={cn(inspectorInputClassName, "min-w-0 border-transparent bg-transparent px-1 font-medium")}
|
|
||||||
onChange={(event) => onChange({ ...branch, name: event.target.value })}
|
|
||||||
/>
|
|
||||||
{branch.default ? null : (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 px-2 text-sm text-slate-500 hover:text-destructive"
|
|
||||||
onClick={() => onDelete(branch.id)}
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-2 grid grid-cols-[72px_minmax(0,1fr)] items-center gap-2">
|
|
||||||
<div className="text-sm text-slate-500">目标节点</div>
|
|
||||||
<OptionCombobox
|
|
||||||
value={branch.targetNodeId}
|
|
||||||
options={targetOptions}
|
|
||||||
placeholder="选择目标节点"
|
|
||||||
triggerClassName={inspectorComboboxClassName}
|
|
||||||
preserveExternalSelection
|
|
||||||
onChange={(targetNodeId) => onChange({ ...branch, targetNodeId })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="mt-2 flex items-center gap-2 pl-[72px] text-sm text-slate-500">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={branch.default === true}
|
|
||||||
className="size-3.5"
|
|
||||||
onChange={(event) => onChange({
|
|
||||||
...branch,
|
|
||||||
default: event.target.checked,
|
|
||||||
condition: event.target.checked ? undefined : branch.condition,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
默认分支
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{branch.default ? null : (
|
|
||||||
<div className="border-t border-slate-200 pt-2">
|
|
||||||
<ConditionFields branch={branch} variables={variables} onChange={onChange} compact />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</InspectorSection>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConditionFields({
|
|
||||||
branch,
|
|
||||||
variables,
|
|
||||||
onChange,
|
|
||||||
compact = false,
|
|
||||||
}: {
|
|
||||||
branch: WorkflowConditionBranch
|
|
||||||
variables: WorkflowVariableRef[]
|
|
||||||
onChange: (branch: WorkflowConditionBranch) => void
|
|
||||||
compact?: boolean
|
|
||||||
}) {
|
|
||||||
const condition = branch.condition ?? {}
|
|
||||||
const selectedVariable = isRefValue(condition.left)
|
|
||||||
? variables.find((item) => item.nodeId === refNodeId(condition.left) && item.field === refField(condition.left))
|
|
||||||
: undefined
|
|
||||||
const valueOptions = selectedVariable?.valueOptions ?? []
|
|
||||||
const rightDisabled = ["exists", "empty"].includes(condition.operator ?? "")
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn("divide-y divide-slate-100", compact && "divide-y-0")}>
|
|
||||||
<div className={cn("grid grid-cols-[92px_minmax(0,1fr)] items-start gap-2 px-3 py-2", compact && "grid-cols-[72px_minmax(0,1fr)] px-0 py-1")}>
|
|
||||||
<div className="pt-1.5 text-sm text-slate-500">左值</div>
|
|
||||||
<VariableSelector
|
|
||||||
value={isRefValue(condition.left) ? condition.left : undefined}
|
|
||||||
variables={variables}
|
|
||||||
placeholder="选择变量"
|
|
||||||
triggerClassName={inspectorComboboxClassName}
|
|
||||||
onChange={(left) => onChange({
|
|
||||||
...branch,
|
|
||||||
condition: { ...condition, left },
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={cn("grid grid-cols-[92px_minmax(0,1fr)_minmax(0,1fr)] items-start gap-2 px-3 py-2", compact && "grid-cols-[72px_minmax(0,1fr)_96px] px-0 py-1")}>
|
|
||||||
<div className="pt-1.5 text-sm text-slate-500">判断</div>
|
|
||||||
<div>
|
|
||||||
<OptionCombobox
|
|
||||||
value={condition.operator ?? ""}
|
|
||||||
options={CONDITION_OPERATOR_OPTIONS}
|
|
||||||
placeholder="选择操作符"
|
|
||||||
triggerClassName={inspectorComboboxClassName}
|
|
||||||
preserveExternalSelection
|
|
||||||
onChange={(operator) => onChange({
|
|
||||||
...branch,
|
|
||||||
condition: { ...condition, operator },
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={cn(rightDisabled && "opacity-50")}>
|
|
||||||
{valueOptions.length > 0 && !rightDisabled ? (
|
|
||||||
<OptionCombobox
|
|
||||||
value={stringifyConditionRight(condition.right)}
|
|
||||||
options={valueOptions.map((option) => ({
|
|
||||||
value: stringifyConditionRight(option.value),
|
|
||||||
label: option.label || stringifyConditionRight(option.value),
|
|
||||||
description: option.description,
|
|
||||||
}))}
|
|
||||||
placeholder="选择取值"
|
|
||||||
triggerClassName={inspectorComboboxClassName}
|
|
||||||
preserveExternalSelection
|
|
||||||
onChange={(nextValue) => {
|
|
||||||
const selectedOption = valueOptions.find((option) => stringifyConditionRight(option.value) === nextValue)
|
|
||||||
onChange({
|
|
||||||
...branch,
|
|
||||||
condition: { ...condition, right: selectedOption?.value ?? nextValue },
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Input
|
|
||||||
value={stringifyConditionRight(condition.right)}
|
|
||||||
disabled={rightDisabled}
|
|
||||||
className={inspectorInputClassName}
|
|
||||||
onChange={(event) => onChange({
|
|
||||||
...branch,
|
|
||||||
condition: { ...condition, right: event.target.value },
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function InspectorParameterTabs({
|
|
||||||
inputCount,
|
|
||||||
outputCount,
|
|
||||||
inputContent,
|
|
||||||
outputContent,
|
|
||||||
}: {
|
|
||||||
inputCount: number
|
|
||||||
outputCount: number
|
|
||||||
inputContent: ReactNode
|
|
||||||
outputContent: ReactNode
|
|
||||||
}) {
|
|
||||||
const tabs = [
|
|
||||||
inputCount > 0 ? { value: "input", label: "输入", count: inputCount, content: inputContent } : null,
|
|
||||||
outputCount > 0 ? { value: "output", label: "输出", count: outputCount, content: outputContent } : null,
|
|
||||||
].filter((item): item is { value: string; label: string; count: number; content: ReactNode } => Boolean(item))
|
|
||||||
|
|
||||||
if (tabs.length === 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tabs.length === 1) {
|
|
||||||
return (
|
|
||||||
<InspectorSection title={tabs[0].label} meta={`${tabs[0].count} 项`}>
|
|
||||||
{tabs[0].content}
|
|
||||||
</InspectorSection>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="border-b border-slate-200 last:border-b-0">
|
|
||||||
<Tabs defaultValue={tabs[0].value} className="gap-0">
|
|
||||||
<div className="flex min-h-9 items-center border-b border-slate-100 bg-slate-100 px-3 py-1">
|
|
||||||
<TabsList variant="line" className="h-6 gap-1 rounded-none bg-transparent p-0">
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<TabsTrigger
|
|
||||||
key={tab.value}
|
|
||||||
value={tab.value}
|
|
||||||
>
|
|
||||||
<span>{tab.label}</span>
|
|
||||||
<span className="ml-1.5 font-mono text-xs text-slate-400 data-active:text-slate-500">{tab.count}</span>
|
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
|
||||||
</div>
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<TabsContent key={tab.value} value={tab.value} className="m-0">
|
|
||||||
{tab.content}
|
|
||||||
</TabsContent>
|
|
||||||
))}
|
|
||||||
</Tabs>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function InspectorSection({
|
|
||||||
title,
|
|
||||||
meta,
|
|
||||||
action,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string
|
|
||||||
meta?: string
|
|
||||||
action?: ReactNode
|
|
||||||
children: ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<section className="border-b border-slate-200 last:border-b-0">
|
|
||||||
<div className="flex min-h-8 items-center justify-between gap-2 border-b border-slate-100 bg-slate-50/80 px-3 py-1">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate text-sm font-semibold uppercase tracking-wide text-slate-500">{title}</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
|
||||||
{meta ? <span className="font-mono text-xs text-slate-400">{meta}</span> : null}
|
|
||||||
{action}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>{children}</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function InspectorRow({
|
|
||||||
label,
|
|
||||||
detail,
|
|
||||||
required,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
detail?: string
|
|
||||||
required?: boolean
|
|
||||||
children: ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-[112px_minmax(0,1fr)] gap-3 border-b border-slate-100 px-3 py-2 last:border-b-0">
|
|
||||||
<div className="min-w-0 pt-1">
|
|
||||||
<div className="flex min-w-0 items-center gap-1">
|
|
||||||
<span className="truncate text-sm font-medium text-slate-700">{label}</span>
|
|
||||||
{required ? <span className="text-xs text-destructive">*</span> : null}
|
|
||||||
</div>
|
|
||||||
{detail ? <div className="mt-0.5 truncate font-mono text-xs leading-5 text-slate-400">{detail}</div> : null}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">{children}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function InspectorField({
|
|
||||||
label,
|
|
||||||
detail,
|
|
||||||
fieldName,
|
|
||||||
fieldType,
|
|
||||||
required,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
detail?: string
|
|
||||||
fieldName?: string
|
|
||||||
fieldType?: string
|
|
||||||
required?: boolean
|
|
||||||
children: ReactNode
|
|
||||||
}) {
|
|
||||||
const metaItems = [
|
|
||||||
fieldName ? { label: "字段", value: fieldName } : null,
|
|
||||||
fieldType ? { label: "类型", value: fieldType } : null,
|
|
||||||
].filter((item): item is { label: string; value: string } => Boolean(item))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="border-b border-slate-100 px-3 py-2.5 last:border-b-0">
|
|
||||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex min-w-0 items-start gap-1">
|
|
||||||
<span className="min-w-0 break-words text-sm font-medium leading-5 text-slate-700">{label}</span>
|
|
||||||
{required ? <span className="shrink-0 text-xs text-destructive">*</span> : null}
|
|
||||||
</div>
|
|
||||||
{detail ? <div className="mt-1 break-all font-mono text-xs leading-5 text-slate-400">{detail}</div> : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{metaItems.length > 0 ? (
|
|
||||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
|
||||||
{metaItems.map((item) => (
|
|
||||||
<span
|
|
||||||
key={item.label}
|
|
||||||
className="inline-flex min-w-0 max-w-full items-center gap-1 rounded-sm border border-slate-200 bg-slate-50 px-1.5 py-0.5 font-mono text-xs leading-5 text-slate-500"
|
|
||||||
>
|
|
||||||
<span className="shrink-0 text-slate-400">{item.label}</span>
|
|
||||||
<span className="min-w-0 break-all">{item.value}</span>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="mt-2 min-w-0">{children}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function InspectorHint({ children }: { children: ReactNode }) {
|
|
||||||
return <div className="mt-1.5 text-sm leading-5 text-slate-500">{children}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildTargetOptions(nodes: AIWorkflowDefinition["nodes"], currentNodeId: string) {
|
|
||||||
return nodes
|
|
||||||
.filter((node) => node.id !== currentNodeId && node.type !== "start")
|
|
||||||
.map((node) => ({
|
|
||||||
value: node.id,
|
|
||||||
label: node.data?.title || node.type || node.id,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
function stringifyConditionRight(value: unknown) {
|
|
||||||
if (value === undefined || value === null) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if (typeof value === "string") {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
return JSON.stringify(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeKnowledgeBaseIds(value: unknown) {
|
|
||||||
if (!Array.isArray(value)) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
return uniquePositiveNumbers(
|
|
||||||
value
|
|
||||||
.map((item) => Number(item))
|
|
||||||
.filter((item) => Number.isFinite(item))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function uniquePositiveNumbers(input: number[]) {
|
|
||||||
return Array.from(new Set(input.filter((item) => item > 0)))
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { OptionCombobox } from "@/components/option-combobox"
|
|
||||||
|
|
||||||
import {
|
|
||||||
buildVariableOption,
|
|
||||||
createRefValue,
|
|
||||||
refField,
|
|
||||||
refNodeId,
|
|
||||||
type WorkflowVariableRef,
|
|
||||||
type WorkflowVariableSelector,
|
|
||||||
} from "./workflow-utils"
|
|
||||||
|
|
||||||
export function VariableSelector({
|
|
||||||
value,
|
|
||||||
variables,
|
|
||||||
onChange,
|
|
||||||
placeholder = "选择变量",
|
|
||||||
triggerClassName,
|
|
||||||
}: {
|
|
||||||
value?: WorkflowVariableSelector
|
|
||||||
variables: WorkflowVariableRef[]
|
|
||||||
onChange: (value: WorkflowVariableSelector) => void
|
|
||||||
placeholder?: string
|
|
||||||
triggerClassName?: string
|
|
||||||
}) {
|
|
||||||
const selected = value ? `${refNodeId(value)}.${refField(value)}` : ""
|
|
||||||
const options = variables.map(buildVariableOption)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<OptionCombobox
|
|
||||||
value={selected}
|
|
||||||
options={options}
|
|
||||||
placeholder={placeholder}
|
|
||||||
triggerClassName={triggerClassName}
|
|
||||||
preserveExternalSelection
|
|
||||||
onChange={(next) => {
|
|
||||||
const variable = variables.find((item) => `${item.nodeId}.${item.field}` === next)
|
|
||||||
if (variable) {
|
|
||||||
onChange(createRefValue(variable.nodeId, variable.field))
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { createContext, useContext, useMemo, type ReactNode } from "react"
|
|
||||||
|
|
||||||
export type SelectedWorkflowBranch = {
|
|
||||||
nodeId: string
|
|
||||||
branchId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type WorkflowBranchSelectionContextValue = {
|
|
||||||
selectedBranch: SelectedWorkflowBranch | null
|
|
||||||
onSelectBranch: (branch: SelectedWorkflowBranch | null) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const WorkflowBranchSelectionContext = createContext<WorkflowBranchSelectionContextValue>({
|
|
||||||
selectedBranch: null,
|
|
||||||
onSelectBranch: () => {},
|
|
||||||
})
|
|
||||||
|
|
||||||
export function WorkflowBranchSelectionProvider({
|
|
||||||
selectedBranch,
|
|
||||||
onSelectBranch,
|
|
||||||
children,
|
|
||||||
}: WorkflowBranchSelectionContextValue & {
|
|
||||||
children: ReactNode
|
|
||||||
}) {
|
|
||||||
const value = useMemo(
|
|
||||||
() => ({ selectedBranch, onSelectBranch }),
|
|
||||||
[onSelectBranch, selectedBranch]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<WorkflowBranchSelectionContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</WorkflowBranchSelectionContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWorkflowBranchSelection() {
|
|
||||||
return useContext(WorkflowBranchSelectionContext)
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import type { ReactNode } from "react"
|
|
||||||
import { Maximize2Icon, MinusIcon, PlusIcon, SparklesIcon } from "lucide-react"
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
|
||||||
|
|
||||||
export function WorkflowCanvasControls({
|
|
||||||
zoomPercent,
|
|
||||||
onZoomIn,
|
|
||||||
onZoomOut,
|
|
||||||
onResetZoom,
|
|
||||||
onFitView,
|
|
||||||
onAutoLayout,
|
|
||||||
autoLayoutDisabled = false,
|
|
||||||
}: {
|
|
||||||
zoomPercent?: string
|
|
||||||
onZoomIn?: () => void
|
|
||||||
onZoomOut?: () => void
|
|
||||||
onResetZoom?: () => void
|
|
||||||
onFitView?: () => void
|
|
||||||
onAutoLayout?: () => void
|
|
||||||
autoLayoutDisabled?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="inline-flex h-9 items-center gap-0.5 rounded-md border border-slate-200/80 bg-white/95 p-1 shadow-sm backdrop-blur">
|
|
||||||
<CanvasControlButton label="缩小" onClick={onZoomOut}>
|
|
||||||
<MinusIcon className="size-3.5" />
|
|
||||||
</CanvasControlButton>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 w-12 px-1 text-xs tabular-nums text-slate-700 hover:bg-slate-100 hover:text-slate-950"
|
|
||||||
aria-label="重置为 100%"
|
|
||||||
disabled={!onResetZoom}
|
|
||||||
onClick={onResetZoom}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{zoomPercent ?? "100%"}
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>重置为 100%</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<CanvasControlButton label="放大" onClick={onZoomIn}>
|
|
||||||
<PlusIcon className="size-3.5" />
|
|
||||||
</CanvasControlButton>
|
|
||||||
<span className="mx-1 h-4 w-px shrink-0 bg-slate-200" />
|
|
||||||
<CanvasControlButton label="适配画布" onClick={onFitView}>
|
|
||||||
<Maximize2Icon className="size-3.5" />
|
|
||||||
</CanvasControlButton>
|
|
||||||
<CanvasControlButton label="自动布局" onClick={onAutoLayout} disabled={autoLayoutDisabled}>
|
|
||||||
<SparklesIcon className="size-3.5" />
|
|
||||||
</CanvasControlButton>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CanvasControlButton({
|
|
||||||
label,
|
|
||||||
onClick,
|
|
||||||
disabled = false,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
onClick?: () => void
|
|
||||||
disabled?: boolean
|
|
||||||
children: ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-xs"
|
|
||||||
className="size-7 text-slate-700 hover:bg-slate-100 hover:text-slate-950"
|
|
||||||
aria-label={label}
|
|
||||||
disabled={disabled || !onClick}
|
|
||||||
onClick={onClick}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>{label}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
import { PlusIcon, XIcon } from "lucide-react"
|
|
||||||
import { useService, WorkflowLinesManager } from "@flowgram.ai/free-layout-editor"
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { useWorkflowBranchSelection } from "./workflow-branch-selection"
|
|
||||||
import {
|
|
||||||
createConditionBranchID,
|
|
||||||
isConditionBranchEdge,
|
|
||||||
isBranchRowActionTarget,
|
|
||||||
normalizeNodeConfig,
|
|
||||||
type WorkflowConditionBranch,
|
|
||||||
} from "./workflow-utils"
|
|
||||||
|
|
||||||
export function WorkflowConditionNodeContent({
|
|
||||||
configValue,
|
|
||||||
nodeId,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
configValue: Record<string, unknown> | undefined
|
|
||||||
nodeId: string
|
|
||||||
onChange: (value: Record<string, unknown>) => void
|
|
||||||
}) {
|
|
||||||
const linesManager = useService(WorkflowLinesManager)
|
|
||||||
const { selectedBranch, onSelectBranch } = useWorkflowBranchSelection()
|
|
||||||
const config = normalizeNodeConfig(configValue)
|
|
||||||
const branches = ensureConditionBranches(config.branches ?? [])
|
|
||||||
|
|
||||||
const updateBranches = (nextBranches: WorkflowConditionBranch[]) => {
|
|
||||||
onChange({
|
|
||||||
...config,
|
|
||||||
branches: ensureConditionBranches(nextBranches),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const deleteBranch = (branchId: string) => {
|
|
||||||
linesManager.getAllLines().forEach((line) => {
|
|
||||||
if (isConditionBranchEdge(line.toJSON(), nodeId, branchId)) {
|
|
||||||
line.dispose()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
updateBranches(branches.filter((branch) => branch.id !== branchId))
|
|
||||||
if (selectedBranch?.nodeId === nodeId && selectedBranch.branchId === branchId) {
|
|
||||||
onSelectBranch?.(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{branches.map((branch, index) => (
|
|
||||||
<WorkflowConditionBranchRow
|
|
||||||
key={branch.id}
|
|
||||||
branch={branch}
|
|
||||||
index={index}
|
|
||||||
selected={selectedBranch?.nodeId === nodeId && selectedBranch.branchId === branch.id}
|
|
||||||
onSelect={() => onSelectBranch?.({ nodeId, branchId: branch.id })}
|
|
||||||
onDelete={() => deleteBranch(branch.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 px-2 text-xs text-muted-foreground"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
updateBranches([
|
|
||||||
...branches,
|
|
||||||
{
|
|
||||||
id: createConditionBranchID(branches),
|
|
||||||
name: "新条件",
|
|
||||||
targetNodeId: "",
|
|
||||||
condition: { operator: "eq" },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PlusIcon className="size-3.5" />
|
|
||||||
添加条件
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function WorkflowConditionBranchRow({
|
|
||||||
branch,
|
|
||||||
index,
|
|
||||||
selected,
|
|
||||||
onSelect,
|
|
||||||
onDelete,
|
|
||||||
}: {
|
|
||||||
branch: WorkflowConditionBranch
|
|
||||||
index: number
|
|
||||||
selected: boolean
|
|
||||||
onSelect: () => void
|
|
||||||
onDelete: () => void
|
|
||||||
}) {
|
|
||||||
const branchType = branch.default ? "else" : index === 0 ? "if" : "elseif"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"relative flex min-h-10 cursor-pointer items-center gap-2 rounded-lg border px-2.5 py-1.5 text-xs transition-colors",
|
|
||||||
selected
|
|
||||||
? "border-(--g-selection-background) bg-background shadow-sm"
|
|
||||||
: "border-border/50 bg-background/70 hover:border-border hover:bg-background"
|
|
||||||
)}
|
|
||||||
onPointerDownCapture={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
if (isBranchRowActionTarget(event.target)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
onSelect()
|
|
||||||
}}
|
|
||||||
onMouseDownCapture={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
}}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="shrink-0 rounded-md border bg-muted px-1.5 py-0.5 text-[10px] font-semibold text-muted-foreground">
|
|
||||||
{branchType}
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 flex-1 truncate font-medium text-foreground/90">
|
|
||||||
{branch.name || (branch.default ? "默认分支" : branch.id)}
|
|
||||||
</span>
|
|
||||||
{branch.default ? null : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex size-5 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
|
||||||
aria-label={`删除条件 ${branch.name || branch.id}`}
|
|
||||||
onPointerDownCapture={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
}}
|
|
||||||
onMouseDownCapture={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
}}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
onDelete()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<XIcon className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
data-port-id={branch.id}
|
|
||||||
data-port-type="output"
|
|
||||||
className="absolute -right-4 top-1/2 size-0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureConditionBranches(branches: WorkflowConditionBranch[]) {
|
|
||||||
const normalized = branches.some((branch) => branch.default)
|
|
||||||
? branches
|
|
||||||
: [...branches, { id: "default", name: "默认分支", targetNodeId: "", default: true }]
|
|
||||||
return [
|
|
||||||
...normalized.filter((branch) => !branch.default),
|
|
||||||
...normalized.filter((branch) => branch.default).slice(0, 1),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState, type PointerEvent as ReactPointerEvent } from "react"
|
|
||||||
import { XIcon } from "lucide-react"
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
|
||||||
import type { AIWorkflowDefinition, AIWorkflowNodeSpec } from "@/lib/api/admin"
|
|
||||||
|
|
||||||
import type { SelectedWorkflowBranch } from "./workflow-branch-selection"
|
|
||||||
import { ConditionBranchConfigPanel, NodeConfigPanel } from "./node-config-panel"
|
|
||||||
import { WorkflowNodeIcon } from "./workflow-node-icon"
|
|
||||||
import {
|
|
||||||
getAvailableVariables,
|
|
||||||
normalizeNodeConfig,
|
|
||||||
type WorkflowNodeData,
|
|
||||||
} from "./workflow-utils"
|
|
||||||
|
|
||||||
const PANEL_DEFAULT_WIDTH = 460
|
|
||||||
const PANEL_MIN_WIDTH = 320
|
|
||||||
const PANEL_MAX_WIDTH = 600
|
|
||||||
|
|
||||||
export function WorkflowConfigPanel({
|
|
||||||
definition,
|
|
||||||
nodeSpecs,
|
|
||||||
selectedNodeId,
|
|
||||||
selectedBranch,
|
|
||||||
onClose,
|
|
||||||
onChangeNodeData,
|
|
||||||
onDeleteNode,
|
|
||||||
}: {
|
|
||||||
definition: AIWorkflowDefinition
|
|
||||||
nodeSpecs: AIWorkflowNodeSpec[]
|
|
||||||
selectedNodeId: string
|
|
||||||
selectedBranch: SelectedWorkflowBranch | null
|
|
||||||
onClose: () => void
|
|
||||||
onChangeNodeData: (nodeId: string, data: WorkflowNodeData) => void
|
|
||||||
onDeleteNode: (nodeId: string) => void
|
|
||||||
}) {
|
|
||||||
const [panelWidth, setPanelWidth] = useState(PANEL_DEFAULT_WIDTH)
|
|
||||||
const selectedNode = definition.nodes.find((node) => node.id === selectedNodeId) ?? null
|
|
||||||
const selectedNodeSpec = selectedNode
|
|
||||||
? nodeSpecs.find((spec) => spec.type === selectedNode.type)
|
|
||||||
: undefined
|
|
||||||
const availableVariables = selectedNode
|
|
||||||
? getAvailableVariables(definition, selectedNode.id, nodeSpecs)
|
|
||||||
: []
|
|
||||||
const selectedBranchItem = selectedNode && selectedBranch?.nodeId === selectedNode.id
|
|
||||||
? normalizeNodeConfig(selectedNode.data?.config).branches?.find((branch) => branch.id === selectedBranch.branchId) ?? null
|
|
||||||
: null
|
|
||||||
const panelTitle = selectedBranchItem
|
|
||||||
? selectedBranchItem.name || selectedBranchItem.id
|
|
||||||
: selectedNode?.data?.title || selectedNodeSpec?.title || selectedNode?.type || ""
|
|
||||||
const panelDescription = selectedBranchItem
|
|
||||||
? ""
|
|
||||||
: selectedNodeSpec?.description || ""
|
|
||||||
const panelIcon = selectedBranchItem ? "GitBranchIcon" : selectedNodeSpec?.icon
|
|
||||||
|
|
||||||
if (!selectedNode) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatePanelTitle = (title: string) => {
|
|
||||||
if (selectedBranchItem && selectedBranch) {
|
|
||||||
const config = normalizeNodeConfig(selectedNode.data?.config)
|
|
||||||
onChangeNodeData(selectedNode.id, {
|
|
||||||
...(selectedNode.data ?? {}),
|
|
||||||
config: {
|
|
||||||
...config,
|
|
||||||
branches: (config.branches ?? []).map((branch) => (
|
|
||||||
branch.id === selectedBranch.branchId ? { ...branch, name: title } : branch
|
|
||||||
)),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
onChangeNodeData(selectedNode.id, {
|
|
||||||
...(selectedNode.data ?? {}),
|
|
||||||
title,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const startResize = (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
||||||
event.preventDefault()
|
|
||||||
const startX = event.clientX
|
|
||||||
const startWidth = panelWidth
|
|
||||||
const maxWidth = Math.max(PANEL_MIN_WIDTH, Math.min(PANEL_MAX_WIDTH, window.innerWidth - 320))
|
|
||||||
|
|
||||||
const resize = (moveEvent: PointerEvent) => {
|
|
||||||
const nextWidth = startWidth + startX - moveEvent.clientX
|
|
||||||
setPanelWidth(Math.min(Math.max(nextWidth, PANEL_MIN_WIDTH), maxWidth))
|
|
||||||
}
|
|
||||||
const stopResize = () => {
|
|
||||||
window.removeEventListener("pointermove", resize)
|
|
||||||
window.removeEventListener("pointerup", stopResize)
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener("pointermove", resize)
|
|
||||||
window.addEventListener("pointerup", stopResize)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-workflow-preserve-selection
|
|
||||||
className="pointer-events-none absolute inset-y-3 right-3 z-50 flex max-w-[calc(100%-1.5rem)]"
|
|
||||||
style={{ width: panelWidth }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
role="separator"
|
|
||||||
aria-orientation="vertical"
|
|
||||||
aria-label="调整属性面板宽度"
|
|
||||||
className="group pointer-events-auto absolute inset-y-2 left-0 z-10 flex w-3 -translate-x-1.5 cursor-col-resize items-center justify-center"
|
|
||||||
onPointerDown={startResize}
|
|
||||||
>
|
|
||||||
<span className="h-12 w-1 rounded-full bg-slate-300/80 transition-colors group-hover:bg-blue-400" />
|
|
||||||
</div>
|
|
||||||
<section className="pointer-events-auto flex min-h-0 w-full flex-col overflow-hidden rounded-lg bg-white shadow-[0_18px_45px_rgba(15,23,42,0.18)] backdrop-blur">
|
|
||||||
<div className="shrink-0 border-slate-200 px-4 py-3">
|
|
||||||
<div className="flex min-w-0 items-center justify-between gap-3">
|
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2.5">
|
|
||||||
<WorkflowNodeIcon
|
|
||||||
icon={panelIcon}
|
|
||||||
size="sm"
|
|
||||||
className="rounded-md shadow-none"
|
|
||||||
/>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<Input
|
|
||||||
value={panelTitle}
|
|
||||||
placeholder={selectedBranchItem ? selectedBranchItem.id : selectedNodeSpec?.title || selectedNode.type}
|
|
||||||
className="h-7 w-full rounded-md border-transparent bg-transparent px-1 text-sm font-semibold leading-5 text-slate-900 shadow-none transition-colors hover:border-slate-200 hover:bg-slate-50 focus-visible:border-blue-300 focus-visible:bg-white focus-visible:ring-2 focus-visible:ring-blue-100"
|
|
||||||
onChange={(event) => updatePanelTitle(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="size-7 shrink-0 rounded-md text-slate-500 hover:bg-slate-100 hover:text-slate-700"
|
|
||||||
aria-label="关闭属性面板"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<XIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{panelDescription ? (
|
|
||||||
<div className="mt-1 text-sm leading-5 text-slate-500">
|
|
||||||
{panelDescription}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<ScrollArea className="min-h-0 flex-1">
|
|
||||||
<div>
|
|
||||||
{selectedBranchItem && selectedBranch ? (
|
|
||||||
<ConditionBranchConfigPanel
|
|
||||||
node={selectedNode}
|
|
||||||
nodes={definition.nodes}
|
|
||||||
branchId={selectedBranch.branchId}
|
|
||||||
variables={availableVariables}
|
|
||||||
onChange={onChangeNodeData}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<NodeConfigPanel
|
|
||||||
node={selectedNode}
|
|
||||||
nodeSpec={selectedNodeSpec}
|
|
||||||
nodes={definition.nodes}
|
|
||||||
availableVariables={availableVariables}
|
|
||||||
showHeader={false}
|
|
||||||
showConditionBranches={selectedNode.type !== "condition"}
|
|
||||||
onChange={onChangeNodeData}
|
|
||||||
onDelete={onDeleteNode}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { AlertTriangleIcon, CheckCircle2Icon } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
import type { WorkflowDraftValidation } from "./workflow-utils"
|
|
||||||
|
|
||||||
export function WorkflowEditorStatus({
|
|
||||||
validation,
|
|
||||||
nodeCount,
|
|
||||||
edgeCount,
|
|
||||||
}: {
|
|
||||||
validation: WorkflowDraftValidation
|
|
||||||
nodeCount: number
|
|
||||||
edgeCount: number
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="pointer-events-none inline-flex w-fit max-w-full items-center gap-2 px-1 text-xs text-slate-500">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-flex shrink-0 items-center gap-1",
|
|
||||||
validation.valid ? "text-emerald-600" : "text-amber-700"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{validation.valid ? (
|
|
||||||
<CheckCircle2Icon className="size-3" />
|
|
||||||
) : (
|
|
||||||
<AlertTriangleIcon className="size-3" />
|
|
||||||
)}
|
|
||||||
{validation.valid ? "检查通过" : `${validation.errors.length} 个问题`}
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 text-slate-300">/</span>
|
|
||||||
<span className="shrink-0">{nodeCount} 节点</span>
|
|
||||||
<span className="shrink-0 text-slate-300">·</span>
|
|
||||||
<span className="shrink-0">{edgeCount} 连线</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import type { ReactNode } from "react"
|
|
||||||
import {
|
|
||||||
CheckIcon,
|
|
||||||
Redo2Icon,
|
|
||||||
RotateCcwIcon,
|
|
||||||
SaveIcon,
|
|
||||||
SendIcon,
|
|
||||||
Undo2Icon,
|
|
||||||
} from "lucide-react"
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
|
|
||||||
export function WorkflowEditorToolbar({
|
|
||||||
toolbarExtra,
|
|
||||||
onUndo,
|
|
||||||
undoDisabled = false,
|
|
||||||
onRedo,
|
|
||||||
redoDisabled = false,
|
|
||||||
onRestoreDefault,
|
|
||||||
restoreDefaultDisabled = false,
|
|
||||||
onValidate,
|
|
||||||
validateDisabled = false,
|
|
||||||
onSaveDraft,
|
|
||||||
saveDraftDisabled = false,
|
|
||||||
onPublish,
|
|
||||||
publishDisabled = false,
|
|
||||||
}: {
|
|
||||||
toolbarExtra?: ReactNode
|
|
||||||
onUndo?: () => void
|
|
||||||
undoDisabled?: boolean
|
|
||||||
onRedo?: () => void
|
|
||||||
redoDisabled?: boolean
|
|
||||||
onRestoreDefault?: () => void
|
|
||||||
restoreDefaultDisabled?: boolean
|
|
||||||
onValidate?: () => void
|
|
||||||
validateDisabled?: boolean
|
|
||||||
onSaveDraft?: () => void
|
|
||||||
saveDraftDisabled?: boolean
|
|
||||||
onPublish?: () => void
|
|
||||||
publishDisabled?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="inline-flex min-h-9 w-fit max-w-full items-center rounded-md bg-white/95 px-1.5 py-1 shadow-sm backdrop-blur">
|
|
||||||
<div className="flex min-w-0 items-center gap-0.5 overflow-x-auto overflow-y-hidden">
|
|
||||||
{toolbarExtra}
|
|
||||||
<span className="mx-1 h-4 w-px shrink-0 bg-slate-200" />
|
|
||||||
<Button type="button" variant="ghost" size="sm" className="h-7 shrink-0 px-2 text-xs text-slate-700 hover:bg-slate-100 hover:text-slate-950" disabled={undoDisabled} onClick={onUndo}>
|
|
||||||
<Undo2Icon className="size-3.5" />
|
|
||||||
撤销
|
|
||||||
</Button>
|
|
||||||
<Button type="button" variant="ghost" size="sm" className="h-7 shrink-0 px-2 text-xs text-slate-700 hover:bg-slate-100 hover:text-slate-950" disabled={redoDisabled} onClick={onRedo}>
|
|
||||||
<Redo2Icon className="size-3.5" />
|
|
||||||
重做
|
|
||||||
</Button>
|
|
||||||
<span className="mx-1 h-4 w-px shrink-0 bg-slate-200" />
|
|
||||||
<Button type="button" variant="ghost" size="sm" className="h-7 shrink-0 px-2 text-xs text-slate-700 hover:bg-slate-100 hover:text-slate-950" disabled={restoreDefaultDisabled} onClick={onRestoreDefault}>
|
|
||||||
<RotateCcwIcon className="size-3.5" />
|
|
||||||
恢复默认
|
|
||||||
</Button>
|
|
||||||
<span className="mx-1 h-4 w-px shrink-0 bg-slate-200" />
|
|
||||||
<Button type="button" variant="ghost" size="sm" className="h-7 shrink-0 px-2 text-xs text-slate-700 hover:bg-slate-100 hover:text-slate-950" disabled={validateDisabled} onClick={onValidate}>
|
|
||||||
<CheckIcon className="size-3.5" />
|
|
||||||
检查
|
|
||||||
</Button>
|
|
||||||
<Button type="button" variant="ghost" size="sm" className="h-7 shrink-0 px-2 text-xs text-slate-700 hover:bg-slate-100 hover:text-slate-950" disabled={saveDraftDisabled} onClick={onSaveDraft}>
|
|
||||||
<SaveIcon className="size-3.5" />
|
|
||||||
保存
|
|
||||||
</Button>
|
|
||||||
<Button type="button" size="sm" className="h-7 shrink-0 bg-[#2575FC] px-2 text-xs hover:bg-[#1b63d8]" disabled={publishDisabled} onClick={onPublish}>
|
|
||||||
<SendIcon className="size-3.5" />
|
|
||||||
发布
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,425 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"
|
|
||||||
|
|
||||||
import {
|
|
||||||
EditorRenderer,
|
|
||||||
FreeLayoutEditorProvider,
|
|
||||||
WorkflowDocument,
|
|
||||||
type WorkflowLineEntity,
|
|
||||||
WorkflowLinesManager,
|
|
||||||
WorkflowSelectService,
|
|
||||||
type WorkflowJSON,
|
|
||||||
type WorkflowNodeJSON,
|
|
||||||
type WorkflowPortEntity,
|
|
||||||
useClientContext,
|
|
||||||
useUndoRedo,
|
|
||||||
usePlaygroundTools,
|
|
||||||
useService,
|
|
||||||
} from "@flowgram.ai/free-layout-editor"
|
|
||||||
|
|
||||||
import type { AIWorkflowDefinition, AIWorkflowNodeSpec } from "@/lib/api/admin"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
import { useFlowgramEditorProps } from "./flowgram-editor-provider"
|
|
||||||
import {
|
|
||||||
WorkflowBranchSelectionProvider,
|
|
||||||
type SelectedWorkflowBranch,
|
|
||||||
} from "./workflow-branch-selection"
|
|
||||||
import { WorkflowCanvasControls } from "./workflow-canvas-controls"
|
|
||||||
import { WorkflowConfigPanel } from "./workflow-config-sidebar"
|
|
||||||
import { WorkflowEditorStatus } from "./workflow-editor-status"
|
|
||||||
import { WorkflowEditorToolbar } from "./workflow-editor-toolbar"
|
|
||||||
import {
|
|
||||||
WorkflowPortAddProvider,
|
|
||||||
type WorkflowPortAddRequest,
|
|
||||||
} from "./workflow-port-add-context"
|
|
||||||
import {
|
|
||||||
WorkflowPortNodeMenu,
|
|
||||||
type WorkflowPortNodeMenuState,
|
|
||||||
} from "./workflow-port-node-menu"
|
|
||||||
import {
|
|
||||||
createWorkflowNodeFromSpec,
|
|
||||||
deleteWorkflowNode,
|
|
||||||
shouldClearWorkflowSelectionOnPointerDown,
|
|
||||||
updateWorkflowNodeData,
|
|
||||||
validateWorkflowDefinition,
|
|
||||||
type WorkflowNodeData,
|
|
||||||
} from "./workflow-utils"
|
|
||||||
|
|
||||||
export function WorkflowEditor({
|
|
||||||
definition,
|
|
||||||
nodeSpecs,
|
|
||||||
onDefinitionChange,
|
|
||||||
onRestoreDefault,
|
|
||||||
restoreDefaultDisabled = false,
|
|
||||||
historyDisabled = false,
|
|
||||||
onValidate,
|
|
||||||
validateDisabled = false,
|
|
||||||
onSaveDraft,
|
|
||||||
saveDraftDisabled = false,
|
|
||||||
onPublish,
|
|
||||||
publishDisabled = false,
|
|
||||||
toolbarExtra,
|
|
||||||
}: {
|
|
||||||
definition: AIWorkflowDefinition
|
|
||||||
nodeSpecs: AIWorkflowNodeSpec[]
|
|
||||||
onDefinitionChange: (definition: AIWorkflowDefinition) => void
|
|
||||||
onRestoreDefault?: () => void
|
|
||||||
restoreDefaultDisabled?: boolean
|
|
||||||
historyDisabled?: boolean
|
|
||||||
onValidate?: () => void
|
|
||||||
validateDisabled?: boolean
|
|
||||||
onSaveDraft?: () => void
|
|
||||||
saveDraftDisabled?: boolean
|
|
||||||
onPublish?: () => void
|
|
||||||
publishDisabled?: boolean
|
|
||||||
toolbarExtra?: ReactNode
|
|
||||||
}) {
|
|
||||||
const [localDefinition, setLocalDefinition] = useState(definition)
|
|
||||||
const [selectedNodeId, setSelectedNodeId] = useState("")
|
|
||||||
const [selectedBranch, setSelectedBranch] = useState<SelectedWorkflowBranch | null>(null)
|
|
||||||
const branchSelectAtRef = useRef(0)
|
|
||||||
|
|
||||||
const validation = useMemo(
|
|
||||||
() => validateWorkflowDefinition(localDefinition, nodeSpecs),
|
|
||||||
[localDefinition, nodeSpecs]
|
|
||||||
)
|
|
||||||
|
|
||||||
const editorProps = useFlowgramEditorProps({
|
|
||||||
definition: localDefinition,
|
|
||||||
nodeSpecs,
|
|
||||||
onDefinitionChange: (next) => {
|
|
||||||
setLocalDefinition(next)
|
|
||||||
onDefinitionChange(next)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleSelectBranch = useCallback(
|
|
||||||
(branch: SelectedWorkflowBranch | null) => {
|
|
||||||
if (!branch) {
|
|
||||||
setSelectedBranch(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
branchSelectAtRef.current = Date.now()
|
|
||||||
setSelectedNodeId(branch.nodeId)
|
|
||||||
setSelectedBranch(branch)
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<WorkflowBranchSelectionProvider
|
|
||||||
selectedBranch={selectedBranch}
|
|
||||||
onSelectBranch={handleSelectBranch}
|
|
||||||
>
|
|
||||||
<FreeLayoutEditorProvider {...editorProps}>
|
|
||||||
<WorkflowEditorInner
|
|
||||||
definition={localDefinition}
|
|
||||||
nodeSpecs={nodeSpecs}
|
|
||||||
selectedNodeId={selectedNodeId}
|
|
||||||
selectedBranch={selectedBranch}
|
|
||||||
validation={validation}
|
|
||||||
toolbarExtra={toolbarExtra}
|
|
||||||
onDefinitionChange={(next) => {
|
|
||||||
setLocalDefinition(next)
|
|
||||||
onDefinitionChange(next)
|
|
||||||
}}
|
|
||||||
onSelectNode={(nodeId) => {
|
|
||||||
setSelectedNodeId(nodeId)
|
|
||||||
if (!nodeId || Date.now() - branchSelectAtRef.current > 160) {
|
|
||||||
setSelectedBranch(null)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onSelectBranch={handleSelectBranch}
|
|
||||||
historyDisabled={historyDisabled}
|
|
||||||
onRestoreDefault={onRestoreDefault}
|
|
||||||
restoreDefaultDisabled={restoreDefaultDisabled}
|
|
||||||
onValidate={onValidate}
|
|
||||||
validateDisabled={validateDisabled}
|
|
||||||
onSaveDraft={onSaveDraft}
|
|
||||||
saveDraftDisabled={saveDraftDisabled}
|
|
||||||
onPublish={onPublish}
|
|
||||||
publishDisabled={publishDisabled}
|
|
||||||
/>
|
|
||||||
</FreeLayoutEditorProvider>
|
|
||||||
</WorkflowBranchSelectionProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function WorkflowEditorInner({
|
|
||||||
definition,
|
|
||||||
nodeSpecs,
|
|
||||||
selectedNodeId,
|
|
||||||
selectedBranch,
|
|
||||||
validation,
|
|
||||||
toolbarExtra,
|
|
||||||
onDefinitionChange,
|
|
||||||
onSelectNode,
|
|
||||||
onSelectBranch,
|
|
||||||
historyDisabled,
|
|
||||||
onRestoreDefault,
|
|
||||||
restoreDefaultDisabled,
|
|
||||||
onValidate,
|
|
||||||
validateDisabled,
|
|
||||||
onSaveDraft,
|
|
||||||
saveDraftDisabled,
|
|
||||||
onPublish,
|
|
||||||
publishDisabled,
|
|
||||||
}: {
|
|
||||||
definition: AIWorkflowDefinition
|
|
||||||
nodeSpecs: AIWorkflowNodeSpec[]
|
|
||||||
selectedNodeId: string
|
|
||||||
selectedBranch: SelectedWorkflowBranch | null
|
|
||||||
validation: ReturnType<typeof validateWorkflowDefinition>
|
|
||||||
toolbarExtra?: ReactNode
|
|
||||||
onDefinitionChange: (definition: AIWorkflowDefinition) => void
|
|
||||||
onSelectNode: (nodeId: string) => void
|
|
||||||
onSelectBranch: (branch: SelectedWorkflowBranch | null) => void
|
|
||||||
historyDisabled?: boolean
|
|
||||||
onRestoreDefault?: () => void
|
|
||||||
restoreDefaultDisabled?: boolean
|
|
||||||
onValidate?: () => void
|
|
||||||
validateDisabled?: boolean
|
|
||||||
onSaveDraft?: () => void
|
|
||||||
saveDraftDisabled?: boolean
|
|
||||||
onPublish?: () => void
|
|
||||||
publishDisabled?: boolean
|
|
||||||
}) {
|
|
||||||
const context = useClientContext()
|
|
||||||
const playgroundTools = usePlaygroundTools()
|
|
||||||
const undoRedo = useUndoRedo()
|
|
||||||
const workflowDocument = useService(WorkflowDocument)
|
|
||||||
const linesManager = useService(WorkflowLinesManager)
|
|
||||||
const selectService = useService(WorkflowSelectService)
|
|
||||||
const editorRootRef = useRef<HTMLDivElement>(null)
|
|
||||||
const [autoLayouting, setAutoLayouting] = useState(false)
|
|
||||||
const [nodeMenu, setNodeMenu] = useState<(
|
|
||||||
WorkflowPortNodeMenuState & {
|
|
||||||
sourcePort: WorkflowPortEntity
|
|
||||||
targetPort?: WorkflowPortEntity
|
|
||||||
line?: WorkflowLineEntity
|
|
||||||
}
|
|
||||||
) | null>(null)
|
|
||||||
const zoomPercent = `${Math.round(playgroundTools.zoom * 100)}%`
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const disposable = selectService.onSelectionChanged(() => {
|
|
||||||
const selectedNode = selectService.selectedNodes.length === 1
|
|
||||||
? selectService.selectedNodes[0]
|
|
||||||
: null
|
|
||||||
onSelectNode(selectedNode?.id ?? "")
|
|
||||||
})
|
|
||||||
return () => disposable.dispose()
|
|
||||||
}, [onSelectNode, selectService])
|
|
||||||
|
|
||||||
const emitCurrentDefinition = () => {
|
|
||||||
onDefinitionChange(context.document.toJSON() as AIWorkflowDefinition)
|
|
||||||
}
|
|
||||||
|
|
||||||
const undo = async () => {
|
|
||||||
await undoRedo.undo()
|
|
||||||
emitCurrentDefinition()
|
|
||||||
}
|
|
||||||
|
|
||||||
const redo = async () => {
|
|
||||||
await undoRedo.redo()
|
|
||||||
emitCurrentDefinition()
|
|
||||||
}
|
|
||||||
|
|
||||||
const openNodeMenuFromPort = useCallback((request: WorkflowPortAddRequest) => {
|
|
||||||
const rootRect = editorRootRef.current?.getBoundingClientRect()
|
|
||||||
setNodeMenu({
|
|
||||||
sourcePort: request.sourcePort,
|
|
||||||
targetPort: request.targetPort,
|
|
||||||
line: request.line,
|
|
||||||
x: rootRect ? request.event.clientX - rootRect.left + 10 : request.event.clientX,
|
|
||||||
y: rootRect ? request.event.clientY - rootRect.top - 10 : request.event.clientY,
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const addNodeFromPort = async (spec: AIWorkflowNodeSpec) => {
|
|
||||||
if (!nodeMenu) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const sourcePort = nodeMenu.sourcePort
|
|
||||||
const nextNode = createWorkflowNodeFromSpec(
|
|
||||||
spec,
|
|
||||||
context.document.toJSON().nodes ?? definition.nodes,
|
|
||||||
nextNodePositionFromAddMenu(nodeMenu)
|
|
||||||
)
|
|
||||||
const created = workflowDocument.createWorkflowNodeByType(
|
|
||||||
spec.type,
|
|
||||||
nextNode.meta?.position,
|
|
||||||
nextNode as WorkflowNodeJSON
|
|
||||||
)
|
|
||||||
linesManager.createLine({
|
|
||||||
from: sourcePort.node.id,
|
|
||||||
fromPort: sourcePort.portID,
|
|
||||||
to: created.id,
|
|
||||||
toPort: "",
|
|
||||||
})
|
|
||||||
if (nodeMenu.targetPort) {
|
|
||||||
linesManager.createLine({
|
|
||||||
from: created.id,
|
|
||||||
fromPort: "",
|
|
||||||
to: nodeMenu.targetPort.node.id,
|
|
||||||
toPort: nodeMenu.targetPort.portID,
|
|
||||||
})
|
|
||||||
if (nodeMenu.line && !nodeMenu.line.disposed) {
|
|
||||||
nodeMenu.line.dispose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setNodeMenu(null)
|
|
||||||
await selectService.selectNodeAndScrollToView(created)
|
|
||||||
onSelectNode(created.id)
|
|
||||||
emitCurrentDefinition()
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateNodeData = (nodeId: string, data: WorkflowNodeData) => {
|
|
||||||
const next = updateWorkflowNodeData(definition, nodeId, data)
|
|
||||||
context.operation.fromJSON(next as WorkflowJSON)
|
|
||||||
onDefinitionChange(context.document.toJSON() as AIWorkflowDefinition)
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeNode = (nodeId: string) => {
|
|
||||||
const next = deleteWorkflowNode(definition, nodeId)
|
|
||||||
context.operation.fromJSON(next as WorkflowJSON)
|
|
||||||
const nextSelectedNodeId = next.nodes[0]?.id ?? ""
|
|
||||||
onSelectNode(nextSelectedNodeId)
|
|
||||||
onDefinitionChange(context.document.toJSON() as AIWorkflowDefinition)
|
|
||||||
}
|
|
||||||
|
|
||||||
const autoLayout = async () => {
|
|
||||||
if (autoLayouting || definition.nodes.length < 2) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setAutoLayouting(true)
|
|
||||||
try {
|
|
||||||
await playgroundTools.autoLayout({
|
|
||||||
enableAnimation: true,
|
|
||||||
animationDuration: 240,
|
|
||||||
disableFitView: true,
|
|
||||||
})
|
|
||||||
playgroundTools.fitView(true)
|
|
||||||
emitCurrentDefinition()
|
|
||||||
} finally {
|
|
||||||
setAutoLayouting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetZoom = () => {
|
|
||||||
context.playground.config.updateConfig({
|
|
||||||
zoom: 1,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeConfigPanel = () => {
|
|
||||||
selectService.clear()
|
|
||||||
onSelectNode("")
|
|
||||||
onSelectBranch(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearSelectionFromCanvas = () => {
|
|
||||||
setNodeMenu(null)
|
|
||||||
closeConfigPanel()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={editorRootRef}
|
|
||||||
data-workflow-editor-root
|
|
||||||
className="relative isolate h-full min-h-0 w-full flex-1 overflow-hidden border bg-[var(--g-editor-background)]"
|
|
||||||
onPointerDownCapture={(event) => {
|
|
||||||
if (shouldClearWorkflowSelectionOnPointerDown(event.target)) {
|
|
||||||
clearSelectionFromCanvas()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
data-workflow-preserve-selection
|
|
||||||
className={cn(
|
|
||||||
"absolute left-3 top-3 z-50 flex max-w-[calc(100%-1.5rem)] flex-col items-start gap-1.5",
|
|
||||||
selectedNodeId && "max-w-[calc(100%-25rem)]"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<WorkflowEditorToolbar
|
|
||||||
toolbarExtra={toolbarExtra}
|
|
||||||
onUndo={() => void undo()}
|
|
||||||
undoDisabled={historyDisabled || !undoRedo.canUndo}
|
|
||||||
onRedo={() => void redo()}
|
|
||||||
redoDisabled={historyDisabled || !undoRedo.canRedo}
|
|
||||||
onRestoreDefault={onRestoreDefault}
|
|
||||||
restoreDefaultDisabled={restoreDefaultDisabled}
|
|
||||||
onValidate={onValidate}
|
|
||||||
validateDisabled={validateDisabled}
|
|
||||||
onSaveDraft={onSaveDraft}
|
|
||||||
saveDraftDisabled={saveDraftDisabled}
|
|
||||||
onPublish={onPublish}
|
|
||||||
publishDisabled={publishDisabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div data-workflow-preserve-selection className="absolute bottom-4 left-4 z-50">
|
|
||||||
<WorkflowCanvasControls
|
|
||||||
zoomPercent={zoomPercent}
|
|
||||||
onZoomIn={() => playgroundTools.zoomin(true)}
|
|
||||||
onZoomOut={() => playgroundTools.zoomout(true)}
|
|
||||||
onResetZoom={resetZoom}
|
|
||||||
onFitView={() => playgroundTools.fitView(true)}
|
|
||||||
onAutoLayout={() => void autoLayout()}
|
|
||||||
autoLayoutDisabled={autoLayouting || definition.nodes.length < 2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div data-workflow-preserve-selection className="absolute bottom-4 right-4 z-50">
|
|
||||||
<WorkflowEditorStatus
|
|
||||||
validation={validation}
|
|
||||||
nodeCount={definition.nodes.length}
|
|
||||||
edgeCount={definition.edges.length}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<WorkflowPortAddProvider onRequestAdd={openNodeMenuFromPort}>
|
|
||||||
<EditorRenderer className="h-full w-full" />
|
|
||||||
</WorkflowPortAddProvider>
|
|
||||||
|
|
||||||
<WorkflowPortNodeMenu
|
|
||||||
open={Boolean(nodeMenu)}
|
|
||||||
position={nodeMenu}
|
|
||||||
nodeSpecs={nodeSpecs}
|
|
||||||
onSelect={(spec) => void addNodeFromPort(spec)}
|
|
||||||
onClose={() => setNodeMenu(null)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<WorkflowConfigPanel
|
|
||||||
definition={definition}
|
|
||||||
nodeSpecs={nodeSpecs}
|
|
||||||
selectedNodeId={selectedNodeId}
|
|
||||||
selectedBranch={selectedBranch}
|
|
||||||
onClose={closeConfigPanel}
|
|
||||||
onChangeNodeData={updateNodeData}
|
|
||||||
onDeleteNode={removeNode}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextNodePositionFromAddMenu(
|
|
||||||
menu: WorkflowPortNodeMenuState & {
|
|
||||||
sourcePort: WorkflowPortEntity
|
|
||||||
targetPort?: WorkflowPortEntity
|
|
||||||
line?: WorkflowLineEntity
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
if (menu.line && !menu.line.disposed) {
|
|
||||||
return {
|
|
||||||
x: menu.line.center.labelX,
|
|
||||||
y: menu.line.center.labelY - 40,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
x: menu.sourcePort.point.x + 120,
|
|
||||||
y: menu.sourcePort.point.y - 40,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import type { LineRenderProps } from "@flowgram.ai/free-lines-plugin"
|
|
||||||
import { PlusIcon } from "lucide-react"
|
|
||||||
import { usePlayground } from "@flowgram.ai/free-layout-editor"
|
|
||||||
|
|
||||||
import { useWorkflowPortAdd } from "./workflow-port-add-context"
|
|
||||||
|
|
||||||
export function WorkflowLineAddButton({
|
|
||||||
line,
|
|
||||||
selected,
|
|
||||||
hovered,
|
|
||||||
color,
|
|
||||||
}: LineRenderProps) {
|
|
||||||
const playground = usePlayground()
|
|
||||||
const requestPortAdd = useWorkflowPortAdd()
|
|
||||||
const { fromPort, toPort } = line
|
|
||||||
const visible = !line.disposed && !playground.config.readonly && (selected || hovered)
|
|
||||||
|
|
||||||
if (!visible) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="absolute flex size-6 items-center justify-center rounded-full border border-white bg-white text-[#2575FC] shadow-sm transition-transform" // hover:scale-105
|
|
||||||
style={{
|
|
||||||
transform: `translate(-50%, -50%) translate(${line.center.labelX}px, ${line.center.labelY}px)`,
|
|
||||||
color,
|
|
||||||
pointerEvents: "all",
|
|
||||||
}}
|
|
||||||
aria-label="添加节点"
|
|
||||||
data-line-id={line.id}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
if (!fromPort || !toPort) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
requestPortAdd?.({
|
|
||||||
sourcePort: fromPort,
|
|
||||||
targetPort: toPort,
|
|
||||||
line,
|
|
||||||
event,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PlusIcon className="size-3.5" strokeWidth={2.4} />
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import type { ReactNode } from "react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { WorkflowNodeIcon } from "./workflow-node-icon"
|
|
||||||
|
|
||||||
export function WorkflowNodeCard({
|
|
||||||
title,
|
|
||||||
icon,
|
|
||||||
selected,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string
|
|
||||||
icon: string
|
|
||||||
selected: boolean
|
|
||||||
children?: ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-workflow-preserve-selection
|
|
||||||
className={cn(
|
|
||||||
"group relative rounded-lg bg-[#FFFFFF] p-0.5 transition-all",
|
|
||||||
"w-[242px]",
|
|
||||||
selected
|
|
||||||
? "border-[var(--g-selection-background)] shadow-[0_8px_24px_rgba(20,24,38,0.14)]"
|
|
||||||
: "border-border/80 shadow-sm hover:border-border hover:shadow-md"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="overflow-visible rounded-lg border border-transparent bg-[#FFFFFF]">
|
|
||||||
<div className="flex min-h-12 items-center gap-2 px-3 py-2.5">
|
|
||||||
<WorkflowNodeIcon icon={icon} />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="truncate text-sm font-semibold leading-5 text-foreground">
|
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{children ? <div className="border-t bg-muted/20 px-3 py-2.5">{children}</div> : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { FileTextIcon, type LucideIcon } from "lucide-react"
|
|
||||||
import * as LucideIcons from "lucide-react"
|
|
||||||
|
|
||||||
const lucideIconComponents = LucideIcons as unknown as Record<string, LucideIcon>
|
|
||||||
|
|
||||||
export function WorkflowNodeIcon({
|
|
||||||
icon,
|
|
||||||
size = "md",
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
icon?: string
|
|
||||||
size?: "sm" | "md"
|
|
||||||
className?: string
|
|
||||||
}) {
|
|
||||||
const Icon = icon ? lucideIconComponents[icon] ?? FileTextIcon : FileTextIcon
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"flex shrink-0 items-center justify-center rounded-lg bg-[#2575FC] text-white shadow-sm",
|
|
||||||
size === "md" ? "size-7" : "size-6",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon className={size === "md" ? "size-4" : "size-3.5"} />
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { createContext, useContext } from "react"
|
|
||||||
|
|
||||||
import type { WorkflowLineEntity, WorkflowPortEntity } from "@flowgram.ai/free-layout-editor"
|
|
||||||
|
|
||||||
export type WorkflowPortAddRequest = {
|
|
||||||
sourcePort: WorkflowPortEntity
|
|
||||||
targetPort?: WorkflowPortEntity
|
|
||||||
line?: WorkflowLineEntity
|
|
||||||
event: React.MouseEvent
|
|
||||||
}
|
|
||||||
|
|
||||||
const WorkflowPortAddContext = createContext<((request: WorkflowPortAddRequest) => void) | null>(null)
|
|
||||||
|
|
||||||
export function WorkflowPortAddProvider({
|
|
||||||
onRequestAdd,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
onRequestAdd: (request: WorkflowPortAddRequest) => void
|
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<WorkflowPortAddContext.Provider value={onRequestAdd}>
|
|
||||||
{children}
|
|
||||||
</WorkflowPortAddContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWorkflowPortAdd() {
|
|
||||||
return useContext(WorkflowPortAddContext)
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef } from "react"
|
|
||||||
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
|
||||||
import type { AIWorkflowNodeSpec } from "@/lib/api/admin"
|
|
||||||
import { WorkflowNodeIcon } from "./workflow-node-icon"
|
|
||||||
|
|
||||||
export type WorkflowPortNodeMenuState = {
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WorkflowPortNodeMenu({
|
|
||||||
open,
|
|
||||||
position,
|
|
||||||
nodeSpecs,
|
|
||||||
onSelect,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
open: boolean
|
|
||||||
position: WorkflowPortNodeMenuState | null
|
|
||||||
nodeSpecs: AIWorkflowNodeSpec[]
|
|
||||||
onSelect: (spec: AIWorkflowNodeSpec) => void
|
|
||||||
onClose: () => void
|
|
||||||
}) {
|
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
|
||||||
const insertableNodeSpecs = useMemo(
|
|
||||||
() => nodeSpecs.filter((spec) => spec.type !== "start"),
|
|
||||||
[nodeSpecs]
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const closeOnPointerDown = (event: PointerEvent) => {
|
|
||||||
const target = event.target
|
|
||||||
if (target instanceof Node && menuRef.current?.contains(target)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
const closeOnEscape = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener("pointerdown", closeOnPointerDown)
|
|
||||||
window.addEventListener("keydown", closeOnEscape)
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("pointerdown", closeOnPointerDown)
|
|
||||||
window.removeEventListener("keydown", closeOnEscape)
|
|
||||||
}
|
|
||||||
}, [onClose, open])
|
|
||||||
|
|
||||||
if (!open || !position) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={menuRef}
|
|
||||||
data-workflow-preserve-selection
|
|
||||||
className="pointer-events-auto absolute z-[80] w-64 overflow-hidden rounded-lg border border-slate-200 bg-white py-1.5 shadow-[0_14px_35px_rgba(15,23,42,0.16)]"
|
|
||||||
style={{
|
|
||||||
left: position.x,
|
|
||||||
top: position.y,
|
|
||||||
}}
|
|
||||||
onPointerDown={(event) => event.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="border-b border-slate-100 px-3 pb-2 pt-1 text-xs font-medium text-slate-500">
|
|
||||||
添加节点
|
|
||||||
</div>
|
|
||||||
<ScrollArea className="h-80 max-h-[min(20rem,calc(100vh-8rem))]">
|
|
||||||
<div className="p-1.5">
|
|
||||||
{insertableNodeSpecs.map((spec) => (
|
|
||||||
<button
|
|
||||||
key={spec.type}
|
|
||||||
type="button"
|
|
||||||
className="flex w-full items-start gap-2 rounded-md px-2 py-2 text-left text-sm transition-colors hover:bg-slate-50"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
onSelect(spec)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<WorkflowNodeIcon icon={spec.icon} size="sm" className="mt-0.5" />
|
|
||||||
<span className="min-w-0">
|
|
||||||
<span className="block truncate font-medium text-slate-900">
|
|
||||||
{spec.title || spec.type}
|
|
||||||
</span>
|
|
||||||
{spec.description ? (
|
|
||||||
<span className="line-clamp-2 text-xs text-slate-500">
|
|
||||||
{spec.description}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,488 +0,0 @@
|
|||||||
import assert from "node:assert/strict"
|
|
||||||
import { describe, it } from "node:test"
|
|
||||||
import { readFile } from "node:fs/promises"
|
|
||||||
import vm from "node:vm"
|
|
||||||
import ts from "typescript"
|
|
||||||
|
|
||||||
function plain(value) {
|
|
||||||
return JSON.parse(JSON.stringify(value))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadModule() {
|
|
||||||
const source = await readFile(new URL("./workflow-utils.ts", import.meta.url), "utf8")
|
|
||||||
const compiled = ts.transpileModule(source, {
|
|
||||||
compilerOptions: {
|
|
||||||
target: ts.ScriptTarget.ES2017,
|
|
||||||
module: ts.ModuleKind.CommonJS,
|
|
||||||
},
|
|
||||||
fileName: "workflow-utils.ts",
|
|
||||||
})
|
|
||||||
const sandbox = {
|
|
||||||
exports: {},
|
|
||||||
module: { exports: {} },
|
|
||||||
}
|
|
||||||
sandbox.exports = sandbox.module.exports
|
|
||||||
vm.runInNewContext(compiled.outputText, sandbox)
|
|
||||||
return sandbox.module.exports
|
|
||||||
}
|
|
||||||
|
|
||||||
function workflowNode(id, type, position = { x: 0, y: 0 }, data = {}) {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
type,
|
|
||||||
meta: { position },
|
|
||||||
data: {
|
|
||||||
title: type,
|
|
||||||
config: {},
|
|
||||||
inputsValues: {},
|
|
||||||
...data,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function workflowEdge(sourceNodeID, targetNodeID, extra = {}) {
|
|
||||||
return {
|
|
||||||
sourceNodeID,
|
|
||||||
targetNodeID,
|
|
||||||
...extra,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("FlowGram value helpers", () => {
|
|
||||||
it("creates and reads reference values", async () => {
|
|
||||||
const { createRefValue, isRefValue, refField, refNodeId } = await loadModule()
|
|
||||||
|
|
||||||
const value = createRefValue("start_1", "userMessage")
|
|
||||||
|
|
||||||
assert.deepEqual(plain(value), { type: "ref", content: ["start_1", "userMessage"] })
|
|
||||||
assert.equal(isRefValue(value), true)
|
|
||||||
assert.equal(refNodeId(value), "start_1")
|
|
||||||
assert.equal(refField(value), "userMessage")
|
|
||||||
assert.equal(isRefValue({ type: "constant", content: "hello" }), false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("validateWorkflowDefinition", () => {
|
|
||||||
it("rejects a workflow without exactly one start node", async () => {
|
|
||||||
const { validateWorkflowDefinition } = await loadModule()
|
|
||||||
|
|
||||||
const result = validateWorkflowDefinition({
|
|
||||||
schemaVersion: 2,
|
|
||||||
nodes: [workflowNode("end_1", "end")],
|
|
||||||
edges: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.equal(result.valid, false)
|
|
||||||
assert.match(result.errors.join("\n"), /exactly one start/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects dangling FlowGram edges", async () => {
|
|
||||||
const { validateWorkflowDefinition } = await loadModule()
|
|
||||||
|
|
||||||
const result = validateWorkflowDefinition({
|
|
||||||
schemaVersion: 2,
|
|
||||||
nodes: [workflowNode("start_1", "start"), workflowNode("end_1", "end")],
|
|
||||||
edges: [workflowEdge("start_1", "missing_1")],
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.equal(result.valid, false)
|
|
||||||
assert.match(result.errors.join("\n"), /target node does not exist: missing_1/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects missing required inputs from node specs", async () => {
|
|
||||||
const { validateWorkflowDefinition } = await loadModule()
|
|
||||||
|
|
||||||
const result = validateWorkflowDefinition(
|
|
||||||
{
|
|
||||||
schemaVersion: 2,
|
|
||||||
nodes: [
|
|
||||||
workflowNode("start_1", "start"),
|
|
||||||
workflowNode("reply_1", "send_reply", { x: 240, y: 0 }, { title: "发送回复" }),
|
|
||||||
workflowNode("end_1", "end", { x: 480, y: 0 }),
|
|
||||||
],
|
|
||||||
edges: [workflowEdge("start_1", "reply_1"), workflowEdge("reply_1", "end_1")],
|
|
||||||
},
|
|
||||||
[
|
|
||||||
{
|
|
||||||
type: "send_reply",
|
|
||||||
title: "发送回复",
|
|
||||||
inputSchema: [{ name: "replyText", label: "回复内容", type: "string", required: true }],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.equal(result.valid, false)
|
|
||||||
assert.match(result.errors.join("\n"), /发送回复 missing required input: 回复内容/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("accepts a valid schema v2 workflow", async () => {
|
|
||||||
const { createRefValue, validateWorkflowDefinition } = await loadModule()
|
|
||||||
|
|
||||||
const result = validateWorkflowDefinition(
|
|
||||||
{
|
|
||||||
schemaVersion: 2,
|
|
||||||
nodes: [
|
|
||||||
workflowNode("start_1", "start"),
|
|
||||||
workflowNode("reply_1", "send_reply", { x: 240, y: 0 }, {
|
|
||||||
inputsValues: { replyText: createRefValue("start_1", "userMessage") },
|
|
||||||
}),
|
|
||||||
workflowNode("end_1", "end", { x: 480, y: 0 }),
|
|
||||||
],
|
|
||||||
edges: [workflowEdge("start_1", "reply_1"), workflowEdge("reply_1", "end_1")],
|
|
||||||
},
|
|
||||||
[
|
|
||||||
{
|
|
||||||
type: "send_reply",
|
|
||||||
inputSchema: [{ name: "replyText", type: "string", required: true }],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.deepEqual(plain(result), { valid: true, errors: [] })
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects knowledge retrieve nodes without node knowledge bases", async () => {
|
|
||||||
const { createRefValue, validateWorkflowDefinition } = await loadModule()
|
|
||||||
|
|
||||||
const result = validateWorkflowDefinition({
|
|
||||||
schemaVersion: 2,
|
|
||||||
nodes: [
|
|
||||||
workflowNode("start_1", "start"),
|
|
||||||
workflowNode("retrieve_1", "knowledge_retrieve", { x: 240, y: 0 }, {
|
|
||||||
title: "知识检索",
|
|
||||||
inputsValues: { query: createRefValue("start_1", "userMessage") },
|
|
||||||
config: { knowledgeBaseIds: [] },
|
|
||||||
}),
|
|
||||||
workflowNode("end_1", "end", { x: 480, y: 0 }),
|
|
||||||
],
|
|
||||||
edges: [workflowEdge("start_1", "retrieve_1"), workflowEdge("retrieve_1", "end_1")],
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.equal(result.valid, false)
|
|
||||||
assert.match(result.errors.join("\n"), /需要选择至少一个知识库/)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("createWorkflowNodeFromSpec", () => {
|
|
||||||
it("creates a FlowGram schema v2 node with default inputs", async () => {
|
|
||||||
const { createWorkflowNodeFromSpec } = await loadModule()
|
|
||||||
|
|
||||||
const node = createWorkflowNodeFromSpec(
|
|
||||||
{
|
|
||||||
type: "llm_reply",
|
|
||||||
title: "AI 回复",
|
|
||||||
defaultInputs: {
|
|
||||||
userMessage: { type: "ref", content: ["start_1", "userMessage"] },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
[{ id: "llm_reply_1" }],
|
|
||||||
{ x: 120, y: 240 }
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.deepEqual(plain(node), {
|
|
||||||
id: "llm_reply_2",
|
|
||||||
type: "llm_reply",
|
|
||||||
meta: { position: { x: 120, y: 240 } },
|
|
||||||
data: {
|
|
||||||
title: "AI 回复",
|
|
||||||
config: {},
|
|
||||||
inputsValues: {
|
|
||||||
userMessage: { type: "ref", content: ["start_1", "userMessage"] },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("getAvailableVariables", () => {
|
|
||||||
it("returns upstream output variables in dependency order", async () => {
|
|
||||||
const { getAvailableVariables } = await loadModule()
|
|
||||||
|
|
||||||
const variables = getAvailableVariables(
|
|
||||||
{
|
|
||||||
schemaVersion: 2,
|
|
||||||
nodes: [
|
|
||||||
workflowNode("start_1", "start", { x: 0, y: 0 }, { title: "开始" }),
|
|
||||||
workflowNode("retrieve_1", "knowledge_retrieve", { x: 240, y: 0 }, { title: "知识检索" }),
|
|
||||||
workflowNode("reply_1", "llm_reply", { x: 480, y: 0 }, { title: "AI 回复" }),
|
|
||||||
workflowNode("end_1", "end", { x: 720, y: 0 }),
|
|
||||||
],
|
|
||||||
edges: [
|
|
||||||
workflowEdge("start_1", "retrieve_1"),
|
|
||||||
workflowEdge("retrieve_1", "reply_1"),
|
|
||||||
workflowEdge("reply_1", "end_1"),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"reply_1",
|
|
||||||
[
|
|
||||||
{
|
|
||||||
type: "start",
|
|
||||||
outputSchema: [{ name: "userMessage", label: "用户消息", type: "string", description: "input" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "knowledge_retrieve",
|
|
||||||
outputSchema: [{ name: "documents", label: "文档", type: "array<object>", description: "docs" }],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.deepEqual(plain(variables), [
|
|
||||||
{
|
|
||||||
nodeId: "start_1",
|
|
||||||
nodeName: "开始",
|
|
||||||
field: "userMessage",
|
|
||||||
label: "用户消息",
|
|
||||||
type: "string",
|
|
||||||
description: "input",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "retrieve_1",
|
|
||||||
nodeName: "知识检索",
|
|
||||||
field: "documents",
|
|
||||||
label: "文档",
|
|
||||||
type: "array<object>",
|
|
||||||
description: "docs",
|
|
||||||
},
|
|
||||||
])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("workflow variable display helpers", () => {
|
|
||||||
it("builds business-first variable options with technical details", async () => {
|
|
||||||
const { buildVariableOption } = await loadModule()
|
|
||||||
|
|
||||||
assert.deepEqual(plain(buildVariableOption({
|
|
||||||
nodeId: "start_1",
|
|
||||||
nodeName: "开始",
|
|
||||||
field: "userMessage",
|
|
||||||
label: "用户消息",
|
|
||||||
type: "string",
|
|
||||||
description: "客户本轮发送的消息内容",
|
|
||||||
})), {
|
|
||||||
value: "start_1.userMessage",
|
|
||||||
label: "开始 / 用户消息",
|
|
||||||
subtitle: "start_1.userMessage · string",
|
|
||||||
description: "客户本轮发送的消息内容",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("builds variable spec display rows for readonly node outputs", async () => {
|
|
||||||
const { buildVariableSpecDisplay } = await loadModule()
|
|
||||||
|
|
||||||
assert.deepEqual(plain(buildVariableSpecDisplay({
|
|
||||||
name: "replyText",
|
|
||||||
label: "回复内容",
|
|
||||||
type: "string",
|
|
||||||
description: "发送给客户的最终回复文本",
|
|
||||||
})), {
|
|
||||||
key: "replyText",
|
|
||||||
label: "回复内容",
|
|
||||||
subtitle: "replyText · string",
|
|
||||||
description: "发送给客户的最终回复文本",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("workflow branch interaction helpers", () => {
|
|
||||||
it("detects branch row action targets inside buttons", async () => {
|
|
||||||
const { isBranchRowActionTarget } = await loadModule()
|
|
||||||
|
|
||||||
assert.equal(isBranchRowActionTarget({
|
|
||||||
closest(selector) {
|
|
||||||
return selector === "button" ? {} : null
|
|
||||||
},
|
|
||||||
}), true)
|
|
||||||
assert.equal(isBranchRowActionTarget({
|
|
||||||
closest() {
|
|
||||||
return null
|
|
||||||
},
|
|
||||||
}), false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("clears workflow selection only when clicking outside preserved regions", async () => {
|
|
||||||
const { shouldClearWorkflowSelectionOnPointerDown } = await loadModule()
|
|
||||||
|
|
||||||
assert.equal(shouldClearWorkflowSelectionOnPointerDown({
|
|
||||||
closest(selector) {
|
|
||||||
return selector === "[data-workflow-preserve-selection]" ? {} : null
|
|
||||||
},
|
|
||||||
}), false)
|
|
||||||
assert.equal(shouldClearWorkflowSelectionOnPointerDown({
|
|
||||||
closest() {
|
|
||||||
return null
|
|
||||||
},
|
|
||||||
}), true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("workflow definition mutations", () => {
|
|
||||||
it("updates node data without changing unrelated nodes", async () => {
|
|
||||||
const { updateWorkflowNodeData } = await loadModule()
|
|
||||||
const definition = {
|
|
||||||
schemaVersion: 2,
|
|
||||||
nodes: [
|
|
||||||
workflowNode("start_1", "start"),
|
|
||||||
workflowNode("reply_1", "send_reply", { x: 240, y: 0 }),
|
|
||||||
],
|
|
||||||
edges: [workflowEdge("start_1", "reply_1")],
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = updateWorkflowNodeData(definition, "reply_1", {
|
|
||||||
title: "发送回复",
|
|
||||||
config: { staticReply: "hello" },
|
|
||||||
inputsValues: {},
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.equal(next.nodes[0].data.title, "start")
|
|
||||||
assert.deepEqual(plain(next.nodes[1].data), {
|
|
||||||
title: "发送回复",
|
|
||||||
config: { staticReply: "hello" },
|
|
||||||
inputsValues: {},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("deletes non-start nodes and related edges while keeping start protected", async () => {
|
|
||||||
const { deleteWorkflowNode } = await loadModule()
|
|
||||||
const definition = {
|
|
||||||
schemaVersion: 2,
|
|
||||||
nodes: [
|
|
||||||
workflowNode("start_1", "start"),
|
|
||||||
workflowNode("reply_1", "send_reply", { x: 240, y: 0 }),
|
|
||||||
workflowNode("end_1", "end", { x: 480, y: 0 }),
|
|
||||||
],
|
|
||||||
edges: [workflowEdge("start_1", "reply_1"), workflowEdge("reply_1", "end_1")],
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = deleteWorkflowNode(definition, "reply_1")
|
|
||||||
assert.deepEqual(next.nodes.map((node) => node.id), ["start_1", "end_1"])
|
|
||||||
assert.deepEqual(next.edges, [])
|
|
||||||
|
|
||||||
const protectedDefinition = deleteWorkflowNode(definition, "start_1")
|
|
||||||
assert.deepEqual(protectedDefinition, definition)
|
|
||||||
|
|
||||||
const withoutEnd = deleteWorkflowNode(definition, "end_1")
|
|
||||||
assert.deepEqual(withoutEnd.nodes.map((node) => node.id), ["start_1", "reply_1"])
|
|
||||||
assert.deepEqual(withoutEnd.edges, [workflowEdge("start_1", "reply_1")])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("upserts and deletes condition branches in node config", async () => {
|
|
||||||
const { deleteConditionBranch, upsertConditionBranch } = await loadModule()
|
|
||||||
const definition = {
|
|
||||||
schemaVersion: 2,
|
|
||||||
nodes: [
|
|
||||||
workflowNode("condition_1", "condition", { x: 240, y: 0 }, {
|
|
||||||
config: {
|
|
||||||
branches: [{ id: "default", name: "默认", targetNodeId: "end_1", default: true }],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
workflowNode("end_1", "end", { x: 480, y: 0 }),
|
|
||||||
],
|
|
||||||
edges: [
|
|
||||||
workflowEdge("condition_1", "end_1", { sourcePortID: "default" }),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = upsertConditionBranch(definition, "condition_1", {
|
|
||||||
id: "vip",
|
|
||||||
name: "VIP",
|
|
||||||
targetNodeId: "end_1",
|
|
||||||
condition: {
|
|
||||||
left: { type: "ref", content: ["start_1", "priority"] },
|
|
||||||
operator: "eq",
|
|
||||||
right: "vip",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.deepEqual(plain(updated.nodes[0].data.config.branches.map((branch) => branch.id)), ["default", "vip"])
|
|
||||||
|
|
||||||
updated.edges.push(workflowEdge("condition_1", "end_1", { sourcePortID: "vip" }))
|
|
||||||
|
|
||||||
const deleted = deleteConditionBranch(updated, "condition_1", "vip")
|
|
||||||
assert.deepEqual(plain(deleted.nodes[0].data.config.branches.map((branch) => branch.id)), ["default"])
|
|
||||||
assert.deepEqual(plain(deleted.edges.map((edge) => edge.sourcePortID)), ["default"])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("matches condition branch lines by source node and branch port", async () => {
|
|
||||||
const { isConditionBranchEdge } = await loadModule()
|
|
||||||
|
|
||||||
assert.equal(isConditionBranchEdge(
|
|
||||||
workflowEdge("condition_1", "vip_reply", { sourcePortID: "vip" }),
|
|
||||||
"condition_1",
|
|
||||||
"vip"
|
|
||||||
), true)
|
|
||||||
assert.equal(isConditionBranchEdge(
|
|
||||||
workflowEdge("condition_1", "default_reply", { sourcePortID: "default" }),
|
|
||||||
"condition_1",
|
|
||||||
"vip"
|
|
||||||
), false)
|
|
||||||
assert.equal(isConditionBranchEdge(
|
|
||||||
workflowEdge("other_condition", "vip_reply", { sourcePortID: "vip" }),
|
|
||||||
"condition_1",
|
|
||||||
"vip"
|
|
||||||
), false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("adds FlowGram source ports for condition edges without removing existing lines", async () => {
|
|
||||||
const { normalizeConditionPortsForFlowgram } = await loadModule()
|
|
||||||
const definition = {
|
|
||||||
schemaVersion: 2,
|
|
||||||
nodes: [
|
|
||||||
workflowNode("condition_1", "condition", { x: 240, y: 0 }, {
|
|
||||||
config: {
|
|
||||||
branches: [
|
|
||||||
{ id: "vip", name: "VIP", targetNodeId: "vip_reply", condition: { operator: "eq" } },
|
|
||||||
{ id: "default", name: "默认", targetNodeId: "normal_reply", default: true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
workflowNode("vip_reply", "llm_reply", { x: 520, y: 0 }),
|
|
||||||
workflowNode("normal_reply", "llm_reply", { x: 520, y: 120 }),
|
|
||||||
],
|
|
||||||
edges: [
|
|
||||||
workflowEdge("condition_1", "vip_reply"),
|
|
||||||
workflowEdge("condition_1", "normal_reply"),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = normalizeConditionPortsForFlowgram(definition)
|
|
||||||
|
|
||||||
assert.deepEqual(plain(next.nodes[0].data.portKeys), ["vip", "default"])
|
|
||||||
assert.deepEqual(plain(next.nodes[0].data.ports), ["vip", "default"])
|
|
||||||
assert.deepEqual(plain(next.edges), [
|
|
||||||
workflowEdge("condition_1", "vip_reply", { sourcePortID: "vip" }),
|
|
||||||
workflowEdge("condition_1", "normal_reply", { sourcePortID: "default" }),
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("syncs branch targets from condition source ports while preserving unrelated edges", async () => {
|
|
||||||
const { syncConditionBranchTargetsFromEdges } = await loadModule()
|
|
||||||
const definition = {
|
|
||||||
schemaVersion: 2,
|
|
||||||
nodes: [
|
|
||||||
workflowNode("condition_1", "condition", { x: 240, y: 0 }, {
|
|
||||||
config: {
|
|
||||||
branches: [
|
|
||||||
{ id: "vip", name: "VIP", targetNodeId: "", condition: { operator: "eq" } },
|
|
||||||
{ id: "default", name: "默认", targetNodeId: "", default: true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
portKeys: ["vip", "default"],
|
|
||||||
ports: ["vip", "default"],
|
|
||||||
}),
|
|
||||||
workflowNode("vip_reply", "llm_reply", { x: 520, y: 0 }),
|
|
||||||
workflowNode("normal_reply", "llm_reply", { x: 520, y: 120 }),
|
|
||||||
],
|
|
||||||
edges: [
|
|
||||||
workflowEdge("condition_1", "vip_reply", { sourcePortID: "vip" }),
|
|
||||||
workflowEdge("condition_1", "normal_reply", { sourcePortID: "default" }),
|
|
||||||
workflowEdge("vip_reply", "normal_reply"),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = syncConditionBranchTargetsFromEdges(definition)
|
|
||||||
|
|
||||||
assert.equal(next.nodes[0].data.config.branches[0].targetNodeId, "vip_reply")
|
|
||||||
assert.equal(next.nodes[0].data.config.branches[1].targetNodeId, "normal_reply")
|
|
||||||
assert.equal(next.edges.length, 3)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,557 +0,0 @@
|
|||||||
import type { AIWorkflowDefinition, AIWorkflowNodeSpec, AIWorkflowValue } from "@/lib/api/admin"
|
|
||||||
|
|
||||||
export type WorkflowNodePosition = {
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WorkflowVariableType =
|
|
||||||
| "string"
|
|
||||||
| "number"
|
|
||||||
| "integer"
|
|
||||||
| "boolean"
|
|
||||||
| "object"
|
|
||||||
| "array<string>"
|
|
||||||
| "array<int>"
|
|
||||||
| "array<object>"
|
|
||||||
| "any"
|
|
||||||
|
|
||||||
export type WorkflowVariableValueOption = {
|
|
||||||
value: unknown
|
|
||||||
label: string
|
|
||||||
description?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WorkflowVariableSpec = {
|
|
||||||
name: string
|
|
||||||
label?: string
|
|
||||||
type: WorkflowVariableType
|
|
||||||
required?: boolean
|
|
||||||
description?: string
|
|
||||||
operators?: string[]
|
|
||||||
valueOptions?: WorkflowVariableValueOption[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WorkflowValue = AIWorkflowValue
|
|
||||||
export type WorkflowVariableSelector = Extract<AIWorkflowValue, { type: "ref" }>
|
|
||||||
|
|
||||||
export type WorkflowCondition = {
|
|
||||||
expression?: string
|
|
||||||
left?: WorkflowValue
|
|
||||||
operator?: string
|
|
||||||
right?: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WorkflowConditionBranch = {
|
|
||||||
id: string
|
|
||||||
name?: string
|
|
||||||
targetNodeId: string
|
|
||||||
condition?: WorkflowCondition
|
|
||||||
default?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WorkflowNodeConfig = Record<string, unknown> & {
|
|
||||||
branches?: WorkflowConditionBranch[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WorkflowVariableRef = {
|
|
||||||
nodeId: string
|
|
||||||
nodeName: string
|
|
||||||
field: string
|
|
||||||
label?: string
|
|
||||||
type: string
|
|
||||||
description: string
|
|
||||||
operators?: string[]
|
|
||||||
valueOptions?: WorkflowVariableValueOption[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WorkflowVariableOption = {
|
|
||||||
value: string
|
|
||||||
label: string
|
|
||||||
subtitle: string
|
|
||||||
description: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WorkflowVariableSpecDisplay = {
|
|
||||||
key: string
|
|
||||||
label: string
|
|
||||||
subtitle: string
|
|
||||||
description: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WorkflowNodeSpec = AIWorkflowNodeSpec
|
|
||||||
|
|
||||||
export type WorkflowDraftValidation = {
|
|
||||||
valid: boolean
|
|
||||||
errors: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WorkflowNode = AIWorkflowDefinition["nodes"][number]
|
|
||||||
export type WorkflowNodeData = WorkflowNode["data"]
|
|
||||||
export type WorkflowEdge = AIWorkflowDefinition["edges"][number]
|
|
||||||
|
|
||||||
export function createRefValue(nodeId: string, field: string): WorkflowVariableSelector {
|
|
||||||
return { type: "ref", content: [nodeId, field] }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isRefValue(value: WorkflowValue | undefined): value is WorkflowVariableSelector {
|
|
||||||
return value?.type === "ref" && Array.isArray(value.content) && value.content.length >= 2
|
|
||||||
}
|
|
||||||
|
|
||||||
export function refNodeId(value: WorkflowValue | undefined): string {
|
|
||||||
return isRefValue(value) ? value.content[0] : ""
|
|
||||||
}
|
|
||||||
|
|
||||||
export function refField(value: WorkflowValue | undefined): string {
|
|
||||||
return isRefValue(value) ? value.content[1] : ""
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildVariableOption(variable: WorkflowVariableRef): WorkflowVariableOption {
|
|
||||||
const ref = `${variable.nodeId}.${variable.field}`
|
|
||||||
const label = `${variable.nodeName} / ${variable.label || variable.field}`
|
|
||||||
return {
|
|
||||||
value: ref,
|
|
||||||
label,
|
|
||||||
subtitle: [ref, variable.type].filter(Boolean).join(" · "),
|
|
||||||
description: variable.description,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildVariableSpecDisplay(variable: WorkflowVariableSpec): WorkflowVariableSpecDisplay {
|
|
||||||
return {
|
|
||||||
key: variable.name,
|
|
||||||
label: variable.label || variable.name,
|
|
||||||
subtitle: [variable.name, variable.type].filter(Boolean).join(" · "),
|
|
||||||
description: variable.description || "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isBranchRowActionTarget(target: EventTarget | null): boolean {
|
|
||||||
const maybeElement = target as { closest?: (selector: string) => Element | null } | null
|
|
||||||
return typeof maybeElement?.closest === "function" && maybeElement.closest("button") !== null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldClearWorkflowSelectionOnPointerDown(target: EventTarget | null): boolean {
|
|
||||||
const maybeElement = target as { closest?: (selector: string) => Element | null } | null
|
|
||||||
if (typeof maybeElement?.closest !== "function") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return maybeElement.closest("[data-workflow-preserve-selection]") === null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getNodeTitle(
|
|
||||||
node: AIWorkflowDefinition["nodes"][number] | undefined,
|
|
||||||
specs: AIWorkflowNodeSpec[] = []
|
|
||||||
) {
|
|
||||||
if (!node) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return node.data?.title || specs.find((item) => item.type === node.type)?.title || node.type || node.id
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateWorkflowDefinition(
|
|
||||||
definition: AIWorkflowDefinition,
|
|
||||||
nodeSpecs: AIWorkflowNodeSpec[] = []
|
|
||||||
): WorkflowDraftValidation {
|
|
||||||
const errors: string[] = []
|
|
||||||
const nodes = definition.nodes ?? []
|
|
||||||
const edges = definition.edges ?? []
|
|
||||||
const startNodes = nodes.filter((node) => node.type === "start")
|
|
||||||
const endNodes = nodes.filter((node) => node.type === "end")
|
|
||||||
|
|
||||||
if (startNodes.length !== 1) {
|
|
||||||
errors.push("workflow must contain exactly one start node")
|
|
||||||
}
|
|
||||||
if (endNodes.length === 0) {
|
|
||||||
errors.push("workflow must contain at least one end node")
|
|
||||||
}
|
|
||||||
|
|
||||||
const nodeIds = new Set<string>()
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (!node.id?.trim()) {
|
|
||||||
errors.push("node id is required")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (nodeIds.has(node.id)) {
|
|
||||||
errors.push(`duplicate node id: ${node.id}`)
|
|
||||||
}
|
|
||||||
nodeIds.add(node.id)
|
|
||||||
if (!node.type?.trim()) {
|
|
||||||
errors.push(`node type is required: ${node.id}`)
|
|
||||||
}
|
|
||||||
if (node.type === "knowledge_retrieve") {
|
|
||||||
const config = normalizeNodeConfig(node.data?.config)
|
|
||||||
const knowledgeBaseIds = Array.isArray(config.knowledgeBaseIds) ? config.knowledgeBaseIds : []
|
|
||||||
if (knowledgeBaseIds.length === 0) {
|
|
||||||
errors.push(`${getNodeTitle(node, nodeSpecs)} 需要选择至少一个知识库`)
|
|
||||||
} else if (knowledgeBaseIds.some((id) => Number(id) <= 0)) {
|
|
||||||
errors.push(`${getNodeTitle(node, nodeSpecs)} 知识库 ID 必须大于 0`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const edge of edges) {
|
|
||||||
if (!nodeIds.has(edge.sourceNodeID)) {
|
|
||||||
errors.push(`edge source node does not exist: ${edge.sourceNodeID}`)
|
|
||||||
}
|
|
||||||
if (!nodeIds.has(edge.targetNodeID)) {
|
|
||||||
errors.push(`edge target node does not exist: ${edge.targetNodeID}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const specByType = new Map(nodeSpecs.map((spec) => [spec.type, spec]))
|
|
||||||
for (const node of nodes) {
|
|
||||||
const spec = specByType.get(node.type)
|
|
||||||
if (!spec) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const inputsValues = node.data?.inputsValues ?? {}
|
|
||||||
for (const input of spec.inputSchema ?? []) {
|
|
||||||
if (input.required && !inputsValues[input.name]) {
|
|
||||||
errors.push(`${getNodeTitle(node, nodeSpecs)} missing required input: ${input.label || input.name}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: errors.length === 0, errors }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createWorkflowNodeFromSpec(
|
|
||||||
spec: AIWorkflowNodeSpec,
|
|
||||||
existingNodes: Pick<AIWorkflowDefinition["nodes"][number], "id">[],
|
|
||||||
position: WorkflowNodePosition
|
|
||||||
): AIWorkflowDefinition["nodes"][number] {
|
|
||||||
const id = uniqueNodeId(existingNodes, spec.type)
|
|
||||||
const defaultConfig = spec.type === "condition"
|
|
||||||
? { branches: [{ id: "default", name: "默认分支", targetNodeId: "", default: true }] }
|
|
||||||
: {}
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
type: spec.type,
|
|
||||||
meta: { position },
|
|
||||||
data: {
|
|
||||||
title: spec.title || spec.type,
|
|
||||||
config: defaultConfig,
|
|
||||||
inputsValues: spec.defaultInputs ?? {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateWorkflowNodeData(
|
|
||||||
definition: AIWorkflowDefinition,
|
|
||||||
nodeId: string,
|
|
||||||
data: WorkflowNodeData
|
|
||||||
): AIWorkflowDefinition {
|
|
||||||
return {
|
|
||||||
...definition,
|
|
||||||
nodes: definition.nodes.map((node) => (
|
|
||||||
node.id === nodeId ? { ...node, data } : node
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteWorkflowNode(
|
|
||||||
definition: AIWorkflowDefinition,
|
|
||||||
nodeId: string
|
|
||||||
): AIWorkflowDefinition {
|
|
||||||
const node = definition.nodes.find((item) => item.id === nodeId)
|
|
||||||
if (!node || node.type === "start") {
|
|
||||||
return definition
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...definition,
|
|
||||||
nodes: definition.nodes.filter((item) => item.id !== nodeId),
|
|
||||||
edges: definition.edges.filter((edge) => (
|
|
||||||
edge.sourceNodeID !== nodeId && edge.targetNodeID !== nodeId
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function upsertConditionBranch(
|
|
||||||
definition: AIWorkflowDefinition,
|
|
||||||
nodeId: string,
|
|
||||||
branch: WorkflowConditionBranch
|
|
||||||
): AIWorkflowDefinition {
|
|
||||||
const node = definition.nodes.find((item) => item.id === nodeId)
|
|
||||||
if (!node) {
|
|
||||||
return definition
|
|
||||||
}
|
|
||||||
const config = normalizeNodeConfig(node.data?.config)
|
|
||||||
const branches = config.branches ?? []
|
|
||||||
const nextBranches = branches.some((item) => item.id === branch.id)
|
|
||||||
? branches.map((item) => (item.id === branch.id ? branch : item))
|
|
||||||
: [...branches, branch]
|
|
||||||
return updateWorkflowNodeData(definition, nodeId, {
|
|
||||||
...(node.data ?? {}),
|
|
||||||
config: { ...config, branches: nextBranches },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteConditionBranch(
|
|
||||||
definition: AIWorkflowDefinition,
|
|
||||||
nodeId: string,
|
|
||||||
branchId: string
|
|
||||||
): AIWorkflowDefinition {
|
|
||||||
const node = definition.nodes.find((item) => item.id === nodeId)
|
|
||||||
if (!node) {
|
|
||||||
return definition
|
|
||||||
}
|
|
||||||
const config = normalizeNodeConfig(node.data?.config)
|
|
||||||
const nextDefinition = updateWorkflowNodeData(definition, nodeId, {
|
|
||||||
...(node.data ?? {}),
|
|
||||||
config: {
|
|
||||||
...config,
|
|
||||||
branches: (config.branches ?? []).filter((branch) => branch.id !== branchId),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
...nextDefinition,
|
|
||||||
edges: nextDefinition.edges.filter((edge) => !isConditionBranchEdge(edge, nodeId, branchId)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isConditionBranchEdge(
|
|
||||||
edge: { sourceNodeID?: unknown; sourcePortID?: unknown },
|
|
||||||
nodeId: string,
|
|
||||||
branchId: string
|
|
||||||
): boolean {
|
|
||||||
return String(edge.sourceNodeID ?? "") === nodeId && String(edge.sourcePortID ?? "") === branchId
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeConditionPortsForFlowgram(
|
|
||||||
definition: AIWorkflowDefinition
|
|
||||||
): AIWorkflowDefinition {
|
|
||||||
const branchIdsByNodeId = new Map<string, Set<string>>()
|
|
||||||
for (const node of definition.nodes) {
|
|
||||||
if (node.type !== "condition") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const config = normalizeNodeConfig(node.data?.config)
|
|
||||||
const branches = ensureConditionBranches(config.branches ?? [])
|
|
||||||
branchIdsByNodeId.set(node.id, new Set(branches.map((branch) => branch.id)))
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...definition,
|
|
||||||
nodes: definition.nodes.map((node) => {
|
|
||||||
if (node.type !== "condition") {
|
|
||||||
return node
|
|
||||||
}
|
|
||||||
const config = normalizeNodeConfig(node.data?.config)
|
|
||||||
const branches = ensureConditionBranches(config.branches ?? [])
|
|
||||||
return {
|
|
||||||
...node,
|
|
||||||
data: {
|
|
||||||
...(node.data ?? {}),
|
|
||||||
config: { ...config, branches },
|
|
||||||
portKeys: branches.map((branch) => branch.id),
|
|
||||||
ports: branches.map((branch) => branch.id),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
edges: definition.edges.filter((edge) => {
|
|
||||||
if (!edge.sourcePortID) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
const branchIds = branchIdsByNodeId.get(edge.sourceNodeID)
|
|
||||||
return !branchIds || branchIds.has(edge.sourcePortID)
|
|
||||||
}).map((edge) => {
|
|
||||||
const source = definition.nodes.find((node) => node.id === edge.sourceNodeID)
|
|
||||||
if (!source || source.type !== "condition" || edge.sourcePortID) {
|
|
||||||
return edge
|
|
||||||
}
|
|
||||||
const branch = findConditionBranchForTarget(source, edge.targetNodeID)
|
|
||||||
return branch ? { ...edge, sourcePortID: branch.id } : edge
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function syncConditionBranchTargetsFromEdges(
|
|
||||||
definition: AIWorkflowDefinition
|
|
||||||
): AIWorkflowDefinition {
|
|
||||||
return {
|
|
||||||
...definition,
|
|
||||||
nodes: definition.nodes.map((node) => {
|
|
||||||
if (node.type !== "condition") {
|
|
||||||
return node
|
|
||||||
}
|
|
||||||
const config = normalizeNodeConfig(node.data?.config)
|
|
||||||
const branches = ensureConditionBranches(config.branches ?? [])
|
|
||||||
const nextBranches = branches.map((branch) => {
|
|
||||||
const edge = definition.edges.find((item) => (
|
|
||||||
item.sourceNodeID === node.id && item.sourcePortID === branch.id
|
|
||||||
))
|
|
||||||
return edge ? { ...branch, targetNodeId: edge.targetNodeID } : branch
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
...node,
|
|
||||||
data: {
|
|
||||||
...(node.data ?? {}),
|
|
||||||
config: { ...config, branches: nextBranches },
|
|
||||||
portKeys: nextBranches.map((branch) => branch.id),
|
|
||||||
ports: nextBranches.map((branch) => branch.id),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeNodeConfig(config: unknown): WorkflowNodeConfig {
|
|
||||||
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
const record = config as Record<string, unknown>
|
|
||||||
const branches = Array.isArray(record.branches)
|
|
||||||
? record.branches
|
|
||||||
.map(normalizeConditionBranch)
|
|
||||||
.filter((branch): branch is WorkflowConditionBranch => branch !== null)
|
|
||||||
: undefined
|
|
||||||
return {
|
|
||||||
...record,
|
|
||||||
...(branches ? { branches } : {}),
|
|
||||||
} as WorkflowNodeConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureConditionBranches(branches: WorkflowConditionBranch[]) {
|
|
||||||
if (branches.some((branch) => branch.default)) {
|
|
||||||
return orderConditionBranches(branches)
|
|
||||||
}
|
|
||||||
return orderConditionBranches([
|
|
||||||
...branches,
|
|
||||||
{ id: "default", name: "默认分支", targetNodeId: "", default: true },
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
function orderConditionBranches(branches: WorkflowConditionBranch[]) {
|
|
||||||
return [
|
|
||||||
...branches.filter((branch) => !branch.default),
|
|
||||||
...branches.filter((branch) => branch.default).slice(0, 1),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
function findConditionBranchForTarget(
|
|
||||||
node: AIWorkflowDefinition["nodes"][number],
|
|
||||||
targetNodeId: string
|
|
||||||
) {
|
|
||||||
const branches = ensureConditionBranches(normalizeNodeConfig(node.data?.config).branches ?? [])
|
|
||||||
return branches.find((branch) => branch.targetNodeId === targetNodeId)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createConditionBranchID(existingBranches: WorkflowConditionBranch[]) {
|
|
||||||
const existingIDs = new Set(existingBranches.map((branch) => branch.id))
|
|
||||||
for (let index = 1; index < 10000; index++) {
|
|
||||||
const id = `branch_${index}`
|
|
||||||
if (!existingIDs.has(id)) {
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return `branch_${Date.now()}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAvailableVariables(
|
|
||||||
definition: AIWorkflowDefinition,
|
|
||||||
nodeId: string,
|
|
||||||
nodeSpecs: AIWorkflowNodeSpec[]
|
|
||||||
): WorkflowVariableRef[] {
|
|
||||||
const ancestorIds = collectAncestorNodeIds(definition, nodeId)
|
|
||||||
const specByType = new Map(nodeSpecs.map((spec) => [spec.type, spec]))
|
|
||||||
const ret: WorkflowVariableRef[] = []
|
|
||||||
|
|
||||||
for (const id of ancestorIds) {
|
|
||||||
const node = definition.nodes.find((item) => item.id === id)
|
|
||||||
if (!node) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const spec = specByType.get(node.type)
|
|
||||||
for (const output of spec?.outputSchema ?? []) {
|
|
||||||
ret.push({
|
|
||||||
nodeId: node.id,
|
|
||||||
nodeName: getNodeTitle(node, nodeSpecs),
|
|
||||||
field: output.name,
|
|
||||||
label: output.label,
|
|
||||||
type: output.type,
|
|
||||||
description: output.description || "",
|
|
||||||
operators: output.operators,
|
|
||||||
valueOptions: output.valueOptions,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectAncestorNodeIds(definition: AIWorkflowDefinition, nodeId: string): string[] {
|
|
||||||
const incoming = new Map<string, string[]>()
|
|
||||||
for (const edge of definition.edges ?? []) {
|
|
||||||
const list = incoming.get(edge.targetNodeID) ?? []
|
|
||||||
list.push(edge.sourceNodeID)
|
|
||||||
incoming.set(edge.targetNodeID, list)
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: string[] = []
|
|
||||||
const seen = new Set<string>()
|
|
||||||
const visit = (id: string) => {
|
|
||||||
for (const source of incoming.get(id) ?? []) {
|
|
||||||
if (seen.has(source)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen.add(source)
|
|
||||||
visit(source)
|
|
||||||
result.push(source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
visit(nodeId)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
function uniqueNodeId(existingNodes: Pick<AIWorkflowDefinition["nodes"][number], "id">[], nodeType: string) {
|
|
||||||
const normalizedType = nodeType.replace(/[^a-zA-Z0-9_]/g, "_") || "node"
|
|
||||||
const existingIDs = new Set(existingNodes.map((node) => node.id))
|
|
||||||
for (let index = 1; index < 10000; index++) {
|
|
||||||
const id = `${normalizedType}_${index}`
|
|
||||||
if (!existingIDs.has(id)) {
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return `${normalizedType}_${Date.now()}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeConditionBranch(value: unknown): WorkflowConditionBranch | null {
|
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const record = value as Record<string, unknown>
|
|
||||||
const id = typeof record.id === "string" ? record.id : ""
|
|
||||||
const targetNodeId = typeof record.targetNodeId === "string" ? record.targetNodeId : ""
|
|
||||||
if (!id) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name: typeof record.name === "string" ? record.name : undefined,
|
|
||||||
targetNodeId,
|
|
||||||
default: record.default === true,
|
|
||||||
condition: normalizeCondition(record.condition),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeCondition(value: unknown): WorkflowCondition | undefined {
|
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
const record = value as Record<string, unknown>
|
|
||||||
return {
|
|
||||||
expression: typeof record.expression === "string" ? record.expression : undefined,
|
|
||||||
left: isWorkflowValue(record.left) ? record.left : undefined,
|
|
||||||
operator: typeof record.operator === "string" ? record.operator : undefined,
|
|
||||||
right: record.right,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isWorkflowValue(value: unknown): value is WorkflowValue {
|
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
const type = (value as Record<string, unknown>).type
|
|
||||||
return type === "ref" || type === "constant" || type === "template"
|
|
||||||
}
|
|
||||||
@@ -1,35 +1,373 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react"
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
|
||||||
import { ArrowLeftIcon } from "lucide-react"
|
import { ArrowLeftIcon } from "lucide-react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import {
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from "@/components/ui/tabs"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import {
|
||||||
|
createAIWorkflow,
|
||||||
|
fetchAIWorkflow,
|
||||||
|
fetchAIWorkflowNodeSpecs,
|
||||||
|
fetchAIWorkflowUsage,
|
||||||
|
fetchAIWorkflowVersions,
|
||||||
|
publishAIWorkflow,
|
||||||
|
restoreAIWorkflowVersion,
|
||||||
|
updateAIWorkflow,
|
||||||
|
validateAIWorkflow,
|
||||||
|
type AIWorkflow,
|
||||||
|
type AIWorkflowDefinition,
|
||||||
|
type AIWorkflowNodeSpec,
|
||||||
|
type AIWorkflowUsage,
|
||||||
|
type AIWorkflowVersion,
|
||||||
|
} from "@/lib/api/admin"
|
||||||
import { formatDateTime } from "@/lib/utils"
|
import { formatDateTime } from "@/lib/utils"
|
||||||
import { createAIWorkflow, fetchAIWorkflow, fetchAIWorkflowNodeSpecs, fetchAIWorkflowUsage, fetchAIWorkflowVersions, publishAIWorkflow, restoreAIWorkflowVersion, updateAIWorkflow, validateAIWorkflow, type AIWorkflow, type AIWorkflowDefinition, type AIWorkflowNodeSpec, type AIWorkflowUsage, type AIWorkflowVersion } from "@/lib/api/admin"
|
|
||||||
|
|
||||||
import { WorkflowEditor } from "./workflow-editor"
|
import { WorkflowEditor } from "./editor/workflow-editor"
|
||||||
|
|
||||||
const emptyDefinition: AIWorkflowDefinition = { schemaVersion: 2, nodes: [{ id: "start_1", type: "start", meta: { position: { x: 0, y: 80 } }, data: { title: "开始", config: {}, inputsValues: {} } }, { id: "end_1", type: "end", meta: { position: { x: 260, y: 80 } }, data: { title: "结束", config: {}, inputsValues: {} } }], edges: [{ sourceNodeID: "start_1", targetNodeID: "end_1", sourcePortID: "edge_start_end" }] }
|
const emptyDefinition: AIWorkflowDefinition = {
|
||||||
|
schemaVersion: 2,
|
||||||
export function WorkflowWorkbench({ workflowID, onClose, onSaved }: { workflowID?: number; onClose?: () => void; onSaved?: () => void }) {
|
nodes: [
|
||||||
const router = useRouter()
|
{
|
||||||
const [active, setActive] = useState<AIWorkflow | null>(null); const [nodeSpecs, setNodeSpecs] = useState<AIWorkflowNodeSpec[]>([])
|
id: "start_1",
|
||||||
const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [definition, setDefinition] = useState<AIWorkflowDefinition>(emptyDefinition)
|
type: "start",
|
||||||
const [versions, setVersions] = useState<AIWorkflowVersion[]>([]); const [usage, setUsage] = useState<AIWorkflowUsage[]>([]); const [saving, setSaving] = useState(false); const [dirty, setDirty] = useState(false)
|
meta: { position: { x: 0, y: 80 } },
|
||||||
const [metadataOpen, setMetadataOpen] = useState(false); const [metadataName, setMetadataName] = useState(""); const [metadataDescription, setMetadataDescription] = useState("")
|
data: { title: "开始", config: {}, inputsValues: {} },
|
||||||
const load = useCallback(async () => { const specs = await fetchAIWorkflowNodeSpecs(); setNodeSpecs(specs ?? []); if (!workflowID) return; const [item, versionPage, uses] = await Promise.all([fetchAIWorkflow(workflowID), fetchAIWorkflowVersions({ workflowId: workflowID, limit: 50 }), fetchAIWorkflowUsage(workflowID)]); setActive(item); setName(item.name); setDescription(item.description); setDefinition(item.draftDefinition); setVersions(versionPage.results ?? []); setUsage(uses ?? []); setDirty(false) }, [workflowID])
|
},
|
||||||
useEffect(() => { void load().catch((error) => toast.error(error instanceof Error ? error.message : "加载工作流失败")) }, [load])
|
{
|
||||||
const openMetadata = () => { setMetadataName(name); setMetadataDescription(description); setMetadataOpen(true) }
|
id: "end_1",
|
||||||
const applyMetadata = () => { setName(metadataName); setDescription(metadataDescription); setDirty(true); setMetadataOpen(false) }
|
type: "end",
|
||||||
async function save() { if (!name.trim()) return toast.error("请填写工作流名称"); setSaving(true); try { if (active) { await updateAIWorkflow({ id: active.id, name: name.trim(), description: description.trim(), definition }); await load() } else { const created = await createAIWorkflow({ name: name.trim(), description: description.trim(), definition }); setActive(created); setName(created.name); setDescription(created.description); setDefinition(created.draftDefinition) }; onSaved?.(); toast.success("草稿已保存") } catch (error) { toast.error(error instanceof Error ? error.message : "保存失败") } finally { setSaving(false) } }
|
meta: { position: { x: 260, y: 80 } },
|
||||||
async function publish() { if (!active) return toast.error("请先保存草稿"); setSaving(true); try { const version = await publishAIWorkflow(active.id, definition); await load(); toast.success(`已发布 v${version.version}`) } catch (error) { toast.error(error instanceof Error ? error.message : "发布失败") } finally { setSaving(false) } }
|
data: { title: "结束", config: {}, inputsValues: {} },
|
||||||
async function restore(version: AIWorkflowVersion) { if (!active) return; try { await restoreAIWorkflowVersion(active.id, version.id); await load(); toast.success(`已将 v${version.version} 恢复为草稿`) } catch (error) { toast.error(error instanceof Error ? error.message : "恢复失败") } }
|
},
|
||||||
return <div className="flex h-full min-h-0 flex-col overflow-hidden bg-background"><header className="shrink-0 border-b px-6 py-4"><div className="flex items-center gap-4"><Button variant="ghost" size="icon" onClick={() => router.push("/dashboard/ai-workflows")}><ArrowLeftIcon className="size-4" /></Button><div className="min-w-0 flex-1"><div className="flex items-center gap-2"><h1 className="truncate text-lg font-semibold">{name || "新建工作流"}</h1><Badge variant={active?.publishedVersionId ? "secondary" : "outline"}>{active?.publishedVersionId ? "已发布" : "草稿"}</Badge>{dirty ? <span className="text-xs text-amber-600">未保存</span> : null}</div><p className="mt-1 line-clamp-1 text-sm text-muted-foreground">{description || "暂未填写业务说明"}</p></div><div className="flex shrink-0 gap-2"><Button variant="ghost" onClick={openMetadata}>编辑信息</Button><Button variant="outline" disabled={saving} onClick={() => void validateAIWorkflow(definition).then((result) => toast[result.valid ? "success" : "error"](result.valid ? "校验通过" : `发现 ${result.errors.length} 个问题`))}>校验</Button><Button variant="outline" disabled={saving} onClick={() => void save()}>保存草稿</Button><Button disabled={saving || !active} onClick={() => void publish()}>发布版本</Button></div></div></header><Tabs defaultValue="editor" className="flex min-h-0 flex-1 flex-col"><div className="shrink-0 border-b px-6"><TabsList className="h-11 bg-transparent"><TabsTrigger value="editor">编辑画布</TabsTrigger><TabsTrigger value="versions">版本历史 ({versions.length})</TabsTrigger><TabsTrigger value="usage">使用情况 ({usage.length})</TabsTrigger></TabsList></div><TabsContent value="editor" className="min-h-0 flex-1 overflow-hidden data-[state=inactive]:hidden"><WorkflowEditor definition={definition} nodeSpecs={nodeSpecs} onDefinitionChange={(next) => { setDefinition(next); setDirty(true) }} onSaveDraft={() => void save()} onPublish={() => void publish()} saveDraftDisabled={saving} publishDisabled={saving || !active} /></TabsContent><TabsContent value="versions" className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-6">{versions.length ? versions.map((version) => <div key={version.id} className="mb-3 flex items-center gap-4 rounded-lg border p-4"><Badge>v{version.version}</Badge><div className="min-w-0 flex-1"><div className="text-sm font-medium">发布于 {formatDateTime(version.publishedAt || version.createdAt)}</div><div className="text-xs text-muted-foreground">发布人:{version.publishedByName || "-"}</div></div><Button variant="outline" size="sm" onClick={() => void restore(version)}>恢复为草稿</Button></div>) : <p className="text-sm text-muted-foreground">尚未发布版本。</p>}</TabsContent><TabsContent value="usage" className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-6">{usage.length ? usage.map((item) => <div key={`${item.aiAgentId}-${item.workflowVersionId}`} className="mb-3 flex items-center justify-between rounded-lg border p-4"><div><div className="font-medium">{item.aiAgentName}</div><div className="mt-1 text-sm text-muted-foreground">固定关联 v{item.workflowVersion}</div></div><Badge variant={item.enabled ? "secondary" : "outline"}>{item.enabled ? "启用" : "已停用"}</Badge></div>) : <p className="text-sm text-muted-foreground">暂未被任何 Agent 使用。</p>}</TabsContent></Tabs><Dialog open={metadataOpen} onOpenChange={setMetadataOpen}><DialogContent><DialogHeader><DialogTitle>编辑工作流信息</DialogTitle></DialogHeader><div className="space-y-4"><Input value={metadataName} onChange={(event) => setMetadataName(event.target.value)} placeholder="工作流名称" /><Textarea value={metadataDescription} onChange={(event) => setMetadataDescription(event.target.value)} placeholder="适用场景、目标与业务边界" /></div><DialogFooter><Button variant="outline" onClick={() => setMetadataOpen(false)}>取消</Button><Button onClick={applyMetadata}>确认</Button></DialogFooter></DialogContent></Dialog></div>
|
],
|
||||||
|
edges: [
|
||||||
|
{
|
||||||
|
sourceNodeID: "start_1",
|
||||||
|
targetNodeID: "end_1",
|
||||||
|
sourcePortID: "edge_start_end",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkflowWorkbenchProps = {
|
||||||
|
workflowID?: number
|
||||||
|
onClose?: () => void
|
||||||
|
onSaved?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkflowWorkbench({
|
||||||
|
workflowID,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: WorkflowWorkbenchProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [active, setActive] = useState<AIWorkflow | null>(null)
|
||||||
|
const [nodeSpecs, setNodeSpecs] = useState<AIWorkflowNodeSpec[]>([])
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [description, setDescription] = useState("")
|
||||||
|
const [definition, setDefinition] =
|
||||||
|
useState<AIWorkflowDefinition>(emptyDefinition)
|
||||||
|
const [versions, setVersions] = useState<AIWorkflowVersion[]>([])
|
||||||
|
const [usage, setUsage] = useState<AIWorkflowUsage[]>([])
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [dirty, setDirty] = useState(false)
|
||||||
|
const [metadataOpen, setMetadataOpen] = useState(false)
|
||||||
|
const [metadataName, setMetadataName] = useState("")
|
||||||
|
const [metadataDescription, setMetadataDescription] = useState("")
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const specs = await fetchAIWorkflowNodeSpecs()
|
||||||
|
setNodeSpecs(specs ?? [])
|
||||||
|
if (!workflowID) return
|
||||||
|
|
||||||
|
const [item, versionPage, uses] = await Promise.all([
|
||||||
|
fetchAIWorkflow(workflowID),
|
||||||
|
fetchAIWorkflowVersions({ workflowId: workflowID, limit: 50 }),
|
||||||
|
fetchAIWorkflowUsage(workflowID),
|
||||||
|
])
|
||||||
|
setActive(item)
|
||||||
|
setName(item.name)
|
||||||
|
setDescription(item.description)
|
||||||
|
setDefinition(item.draftDefinition)
|
||||||
|
setVersions(versionPage.results ?? [])
|
||||||
|
setUsage(uses ?? [])
|
||||||
|
setDirty(false)
|
||||||
|
}, [workflowID])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load().catch((error) =>
|
||||||
|
toast.error(error instanceof Error ? error.message : "加载工作流失败")
|
||||||
|
)
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
function openMetadata() {
|
||||||
|
setMetadataName(name)
|
||||||
|
setMetadataDescription(description)
|
||||||
|
setMetadataOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMetadata() {
|
||||||
|
setName(metadataName)
|
||||||
|
setDescription(metadataDescription)
|
||||||
|
setDirty(true)
|
||||||
|
setMetadataOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!name.trim()) {
|
||||||
|
toast.error("请填写工作流名称")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
if (active) {
|
||||||
|
await updateAIWorkflow({
|
||||||
|
id: active.id,
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
definition,
|
||||||
|
})
|
||||||
|
await load()
|
||||||
|
} else {
|
||||||
|
const created = await createAIWorkflow({
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
definition,
|
||||||
|
})
|
||||||
|
setActive(created)
|
||||||
|
setName(created.name)
|
||||||
|
setDescription(created.description)
|
||||||
|
setDefinition(created.draftDefinition)
|
||||||
|
setDirty(false)
|
||||||
|
}
|
||||||
|
onSaved?.()
|
||||||
|
toast.success("草稿已保存")
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "保存失败")
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publish() {
|
||||||
|
if (!active) {
|
||||||
|
toast.error("请先保存草稿")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const version = await publishAIWorkflow(active.id, definition)
|
||||||
|
await load()
|
||||||
|
toast.success(`已发布 v${version.version}`)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "发布失败")
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restore(version: AIWorkflowVersion) {
|
||||||
|
if (!active) return
|
||||||
|
try {
|
||||||
|
await restoreAIWorkflowVersion(active.id, version.id)
|
||||||
|
await load()
|
||||||
|
toast.success(`已将 v${version.version} 恢复为草稿`)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "恢复失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-background">
|
||||||
|
<header className="shrink-0 border-b px-6 py-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() =>
|
||||||
|
onClose ? onClose() : router.push("/dashboard/ai-workflows")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="truncate text-lg font-semibold">
|
||||||
|
{name || "新建工作流"}
|
||||||
|
</h1>
|
||||||
|
<Badge
|
||||||
|
variant={active?.publishedVersionId ? "secondary" : "outline"}
|
||||||
|
>
|
||||||
|
{active?.publishedVersionId ? "已发布" : "草稿"}
|
||||||
|
</Badge>
|
||||||
|
{dirty ? (
|
||||||
|
<span className="text-xs text-amber-600">未保存</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 line-clamp-1 text-sm text-muted-foreground">
|
||||||
|
{description || "暂未填写业务说明"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 gap-2">
|
||||||
|
<Button variant="ghost" onClick={openMetadata}>
|
||||||
|
编辑信息
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => void save()}
|
||||||
|
>
|
||||||
|
保存草稿
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={saving || !active}
|
||||||
|
onClick={() => void publish()}
|
||||||
|
>
|
||||||
|
发布版本
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Tabs defaultValue="editor" className="flex min-h-0 flex-1 flex-col">
|
||||||
|
<div className="shrink-0 border-b px-6">
|
||||||
|
<TabsList className="h-11 bg-transparent">
|
||||||
|
<TabsTrigger value="editor">编辑画布</TabsTrigger>
|
||||||
|
<TabsTrigger value="versions">
|
||||||
|
版本历史 ({versions.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="usage">使用情况 ({usage.length})</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TabsContent
|
||||||
|
value="editor"
|
||||||
|
className="min-h-0 flex-1 overflow-hidden data-[state=inactive]:hidden"
|
||||||
|
>
|
||||||
|
{nodeSpecs.length ? (
|
||||||
|
<WorkflowEditor
|
||||||
|
definition={definition}
|
||||||
|
nodeSpecs={nodeSpecs}
|
||||||
|
onDefinitionChange={(next) => {
|
||||||
|
setDefinition(next)
|
||||||
|
setDirty(true)
|
||||||
|
}}
|
||||||
|
onValidate={() => validateAIWorkflow(definition)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
正在加载节点能力…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent
|
||||||
|
value="versions"
|
||||||
|
className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-6"
|
||||||
|
>
|
||||||
|
{versions.length ? (
|
||||||
|
versions.map((version) => (
|
||||||
|
<div
|
||||||
|
key={version.id}
|
||||||
|
className="mb-3 flex items-center gap-4 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<Badge>v{version.version}</Badge>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
发布于{" "}
|
||||||
|
{formatDateTime(version.publishedAt || version.createdAt)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
发布人:{version.publishedByName || "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void restore(version)}
|
||||||
|
>
|
||||||
|
恢复为草稿
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">尚未发布版本。</p>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent
|
||||||
|
value="usage"
|
||||||
|
className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-6"
|
||||||
|
>
|
||||||
|
{usage.length ? (
|
||||||
|
usage.map((item) => (
|
||||||
|
<div
|
||||||
|
key={`${item.aiAgentId}-${item.workflowVersionId}`}
|
||||||
|
className="mb-3 flex items-center justify-between rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{item.aiAgentName}</div>
|
||||||
|
<div className="mt-1 text-sm text-muted-foreground">
|
||||||
|
固定关联 v{item.workflowVersion}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant={item.enabled ? "secondary" : "outline"}>
|
||||||
|
{item.enabled ? "启用" : "已停用"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
暂未被任何 Agent 使用。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<Dialog open={metadataOpen} onOpenChange={setMetadataOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>编辑工作流信息</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input
|
||||||
|
value={metadataName}
|
||||||
|
onChange={(event) => setMetadataName(event.target.value)}
|
||||||
|
placeholder="工作流名称"
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
value={metadataDescription}
|
||||||
|
onChange={(event) => setMetadataDescription(event.target.value)}
|
||||||
|
placeholder="适用场景、目标与业务边界"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setMetadataOpen(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={applyMetadata}>确认</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -366,15 +366,3 @@
|
|||||||
@apply font-sans;
|
@apply font-sans;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-node-port:hover .bg:not(.hasError),
|
|
||||||
.workflow-node-port.hovered .bg:not(.hasError) {
|
|
||||||
cursor: crosshair;
|
|
||||||
transform: scale(1, 1) !important;
|
|
||||||
background: #2575fc !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-node-port:hover .bg > .symbol,
|
|
||||||
.workflow-node-port.hovered .bg > .symbol {
|
|
||||||
opacity: 1 !important;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Toaster } from "@/components/ui/sonner"
|
|||||||
import { AppI18nProvider } from "@/i18n/provider"
|
import { AppI18nProvider } from "@/i18n/provider"
|
||||||
|
|
||||||
import "@/app/globals.css"
|
import "@/app/globals.css"
|
||||||
|
import "@flowgram.ai/free-layout-editor/index.css"
|
||||||
import "md-editor-rt/lib/style.css"
|
import "md-editor-rt/lib/style.css"
|
||||||
import "@/styles/main.scss"
|
import "@/styles/main.scss"
|
||||||
|
|
||||||
|
|||||||
+11
-8
@@ -375,7 +375,17 @@ export type AIWorkflowVariableSpec = {
|
|||||||
|
|
||||||
export type AIWorkflowDefinition = {
|
export type AIWorkflowDefinition = {
|
||||||
schemaVersion: number
|
schemaVersion: number
|
||||||
nodes: {
|
nodes: AIWorkflowCanvasNode[]
|
||||||
|
annotations?: AIWorkflowCanvasNode[]
|
||||||
|
edges: {
|
||||||
|
sourceNodeID: string
|
||||||
|
targetNodeID: string
|
||||||
|
sourcePortID?: string
|
||||||
|
targetPortID?: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AIWorkflowCanvasNode = {
|
||||||
id: string
|
id: string
|
||||||
type: string
|
type: string
|
||||||
meta: {
|
meta: {
|
||||||
@@ -389,13 +399,6 @@ export type AIWorkflowDefinition = {
|
|||||||
inputsValues?: Record<string, AIWorkflowValue>
|
inputsValues?: Record<string, AIWorkflowValue>
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
}[]
|
|
||||||
edges: {
|
|
||||||
sourceNodeID: string
|
|
||||||
targetNodeID: string
|
|
||||||
sourcePortID?: string
|
|
||||||
targetPortID?: string
|
|
||||||
}[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AIWorkflow = {
|
export type AIWorkflow = {
|
||||||
|
|||||||
@@ -16,10 +16,15 @@
|
|||||||
"@dnd-kit/modifiers": "^9.0.0",
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@flowgram.ai/export-plugin": "1.0.11",
|
||||||
|
"@flowgram.ai/free-auto-layout-plugin": "1.0.11",
|
||||||
"@flowgram.ai/free-layout-editor": "1.0.11",
|
"@flowgram.ai/free-layout-editor": "1.0.11",
|
||||||
"@flowgram.ai/free-lines-plugin": "1.0.11",
|
"@flowgram.ai/free-lines-plugin": "1.0.11",
|
||||||
|
"@flowgram.ai/free-node-panel-plugin": "1.0.11",
|
||||||
"@flowgram.ai/free-snap-plugin": "1.0.11",
|
"@flowgram.ai/free-snap-plugin": "1.0.11",
|
||||||
|
"@flowgram.ai/free-stack-plugin": "1.0.11",
|
||||||
"@flowgram.ai/minimap-plugin": "1.0.11",
|
"@flowgram.ai/minimap-plugin": "1.0.11",
|
||||||
|
"@flowgram.ai/panel-manager-plugin": "1.0.11",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@tiptap/extension-image": "^3.20.2",
|
"@tiptap/extension-image": "^3.20.2",
|
||||||
|
|||||||
Generated
+106
@@ -23,18 +23,33 @@ importers:
|
|||||||
'@dnd-kit/utilities':
|
'@dnd-kit/utilities':
|
||||||
specifier: ^3.2.2
|
specifier: ^3.2.2
|
||||||
version: 3.2.2(react@19.2.3)
|
version: 3.2.2(react@19.2.3)
|
||||||
|
'@flowgram.ai/export-plugin':
|
||||||
|
specifier: 1.0.11
|
||||||
|
version: 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@flowgram.ai/free-auto-layout-plugin':
|
||||||
|
specifier: 1.0.11
|
||||||
|
version: 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(styled-components@6.4.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))
|
||||||
'@flowgram.ai/free-layout-editor':
|
'@flowgram.ai/free-layout-editor':
|
||||||
specifier: 1.0.11
|
specifier: 1.0.11
|
||||||
version: 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(styled-components@6.4.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))
|
version: 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(styled-components@6.4.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))
|
||||||
'@flowgram.ai/free-lines-plugin':
|
'@flowgram.ai/free-lines-plugin':
|
||||||
specifier: 1.0.11
|
specifier: 1.0.11
|
||||||
version: 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(styled-components@6.4.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))
|
version: 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(styled-components@6.4.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))
|
||||||
|
'@flowgram.ai/free-node-panel-plugin':
|
||||||
|
specifier: 1.0.11
|
||||||
|
version: 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
'@flowgram.ai/free-snap-plugin':
|
'@flowgram.ai/free-snap-plugin':
|
||||||
specifier: 1.0.11
|
specifier: 1.0.11
|
||||||
version: 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(styled-components@6.4.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))
|
version: 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(styled-components@6.4.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))
|
||||||
|
'@flowgram.ai/free-stack-plugin':
|
||||||
|
specifier: 1.0.11
|
||||||
|
version: 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(styled-components@6.4.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))
|
||||||
'@flowgram.ai/minimap-plugin':
|
'@flowgram.ai/minimap-plugin':
|
||||||
specifier: 1.0.11
|
specifier: 1.0.11
|
||||||
version: 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
version: 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@flowgram.ai/panel-manager-plugin':
|
||||||
|
specifier: 1.0.11
|
||||||
|
version: 1.0.11(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(reflect-metadata@0.2.2)
|
||||||
'@hookform/resolvers':
|
'@hookform/resolvers':
|
||||||
specifier: ^5.2.2
|
specifier: ^5.2.2
|
||||||
version: 5.2.2(react-hook-form@7.71.2(react@19.2.3))
|
version: 5.2.2(react-hook-form@7.71.2(react@19.2.3))
|
||||||
@@ -572,6 +587,12 @@ packages:
|
|||||||
react: '>=16.8'
|
react: '>=16.8'
|
||||||
react-dom: '>=16.8'
|
react-dom: '>=16.8'
|
||||||
|
|
||||||
|
'@flowgram.ai/export-plugin@1.0.11':
|
||||||
|
resolution: {integrity: sha512-drJley1YMVqANTcl+n/TDvxa75F07z7u1jme/Y1H8P1h9aOAhem+q1oJZgw7WSAK7eG7Sy1qxNb6yeqaKlzz6g==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8'
|
||||||
|
react-dom: '>=16.8'
|
||||||
|
|
||||||
'@flowgram.ai/form-core@1.0.11':
|
'@flowgram.ai/form-core@1.0.11':
|
||||||
resolution: {integrity: sha512-qucie8ekXkfJbXMvPSUGR1CZufBZ1qqHbYNITZhxwR3X54MF7vieFD/6fb7H7jic2z5ad4aCOJcIDe5uD0hQjg==}
|
resolution: {integrity: sha512-qucie8ekXkfJbXMvPSUGR1CZufBZ1qqHbYNITZhxwR3X54MF7vieFD/6fb7H7jic2z5ad4aCOJcIDe5uD0hQjg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -622,6 +643,12 @@ packages:
|
|||||||
react-dom: '>=16.8'
|
react-dom: '>=16.8'
|
||||||
styled-components: '>=5'
|
styled-components: '>=5'
|
||||||
|
|
||||||
|
'@flowgram.ai/free-node-panel-plugin@1.0.11':
|
||||||
|
resolution: {integrity: sha512-1XqzHZhimJLK6G3zUhHUKBHgLlnWNRmWYmpCci0e9yKzOBduS6xZTfMSgc7bl5TeaG9u++nooTqirx6xP5kwXQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8'
|
||||||
|
react-dom: '>=16.8'
|
||||||
|
|
||||||
'@flowgram.ai/free-snap-plugin@1.0.11':
|
'@flowgram.ai/free-snap-plugin@1.0.11':
|
||||||
resolution: {integrity: sha512-6YPtxwIYkUgj2BBBQgJvIPeSgDZQJpHSDl9r6FyJyalAdQKHeKniCBAR8b3tgaQf/oKR1v/8zt01h+IzMFhodQ==}
|
resolution: {integrity: sha512-6YPtxwIYkUgj2BBBQgJvIPeSgDZQJpHSDl9r6FyJyalAdQKHeKniCBAR8b3tgaQf/oKR1v/8zt01h+IzMFhodQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -681,6 +708,15 @@ packages:
|
|||||||
react: '>=16.8'
|
react: '>=16.8'
|
||||||
react-dom: '>=16.8'
|
react-dom: '>=16.8'
|
||||||
|
|
||||||
|
'@flowgram.ai/panel-manager-plugin@1.0.11':
|
||||||
|
resolution: {integrity: sha512-piax49+HwMiU+zHT5L8ttQ7O64gqJSpvlHXt68jrVDMlExMPX62R89imvTSuFDyPVjOGjoqsVaRFb8cUlNtbtw==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8'
|
||||||
|
react-dom: '>=16.8'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@flowgram.ai/playground-react@1.0.11':
|
'@flowgram.ai/playground-react@1.0.11':
|
||||||
resolution: {integrity: sha512-4NORKKYGoH2lyRzf7VW2NFlaHZbE1dpXVAi1ehr27g4L24TUxJCWVRWi8vrCph9hrlucs86Iw1SAFOVP1dnsSQ==}
|
resolution: {integrity: sha512-4NORKKYGoH2lyRzf7VW2NFlaHZbE1dpXVAi1ehr27g4L24TUxJCWVRWi8vrCph9hrlucs86Iw1SAFOVP1dnsSQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -3407,6 +3443,9 @@ packages:
|
|||||||
minimist@1.2.8:
|
minimist@1.2.8:
|
||||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||||
|
|
||||||
|
modern-screenshot@4.6.7:
|
||||||
|
resolution: {integrity: sha512-0GhgI6i6le4AhKzCvLYjwEmsP47kTsX45iT5yuAzsLTi/7i3Rjxe8fbH2VjGJLuyOThwsa0CdQAPd4auoEtsZg==}
|
||||||
|
|
||||||
ms@2.1.3:
|
ms@2.1.3:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||||
|
|
||||||
@@ -4460,6 +4499,21 @@ packages:
|
|||||||
zod@4.3.6:
|
zod@4.3.6:
|
||||||
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
||||||
|
|
||||||
|
zustand@4.5.7:
|
||||||
|
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
|
||||||
|
engines: {node: '>=12.7.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '>=16.8'
|
||||||
|
immer: '>=9.0.6'
|
||||||
|
react: '>=16.8'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
immer:
|
||||||
|
optional: true
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
|
||||||
zustand@5.0.12:
|
zustand@5.0.12:
|
||||||
resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==}
|
resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==}
|
||||||
engines: {node: '>=12.20.0'}
|
engines: {node: '>=12.20.0'}
|
||||||
@@ -5153,6 +5207,20 @@ snapshots:
|
|||||||
react-dom: 19.2.3(react@19.2.3)
|
react-dom: 19.2.3(react@19.2.3)
|
||||||
reflect-metadata: 0.2.2
|
reflect-metadata: 0.2.2
|
||||||
|
|
||||||
|
'@flowgram.ai/export-plugin@1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
|
dependencies:
|
||||||
|
'@flowgram.ai/core': 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@flowgram.ai/document': 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@flowgram.ai/utils': 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
inversify: 6.2.2(reflect-metadata@0.2.2)
|
||||||
|
js-yaml: 4.1.1
|
||||||
|
lodash-es: 4.18.1
|
||||||
|
modern-screenshot: 4.6.7
|
||||||
|
nanoid: 5.1.16
|
||||||
|
react: 19.2.3
|
||||||
|
react-dom: 19.2.3(react@19.2.3)
|
||||||
|
reflect-metadata: 0.2.2
|
||||||
|
|
||||||
'@flowgram.ai/form-core@1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
'@flowgram.ai/form-core@1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@flowgram.ai/core': 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
'@flowgram.ai/core': 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
@@ -5263,6 +5331,18 @@ snapshots:
|
|||||||
reflect-metadata: 0.2.2
|
reflect-metadata: 0.2.2
|
||||||
styled-components: 6.4.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
styled-components: 6.4.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
|
||||||
|
'@flowgram.ai/free-node-panel-plugin@1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
|
dependencies:
|
||||||
|
'@flowgram.ai/core': 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@flowgram.ai/document': 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@flowgram.ai/free-history-plugin': 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@flowgram.ai/free-layout-core': 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@flowgram.ai/utils': 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
inversify: 6.2.2(reflect-metadata@0.2.2)
|
||||||
|
react: 19.2.3
|
||||||
|
react-dom: 19.2.3(react@19.2.3)
|
||||||
|
reflect-metadata: 0.2.2
|
||||||
|
|
||||||
'@flowgram.ai/free-snap-plugin@1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(styled-components@6.4.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))':
|
'@flowgram.ai/free-snap-plugin@1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(styled-components@6.4.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@flowgram.ai/core': 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
'@flowgram.ai/core': 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
@@ -5388,6 +5468,23 @@ snapshots:
|
|||||||
react-dom: 19.2.3(react@19.2.3)
|
react-dom: 19.2.3(react@19.2.3)
|
||||||
reflect-metadata: 0.2.2
|
reflect-metadata: 0.2.2
|
||||||
|
|
||||||
|
'@flowgram.ai/panel-manager-plugin@1.0.11(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(reflect-metadata@0.2.2)':
|
||||||
|
dependencies:
|
||||||
|
'@flowgram.ai/core': 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@flowgram.ai/utils': 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
clsx: 1.2.1
|
||||||
|
inversify: 6.2.2(reflect-metadata@0.2.2)
|
||||||
|
nanoid: 5.1.16
|
||||||
|
react-dom: 19.2.3(react@19.2.3)
|
||||||
|
use-sync-external-store: 1.6.0(react@19.2.3)
|
||||||
|
zustand: 4.5.7(@types/react@19.2.14)(react@19.2.3)
|
||||||
|
optionalDependencies:
|
||||||
|
react: 19.2.3
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/react'
|
||||||
|
- immer
|
||||||
|
- reflect-metadata
|
||||||
|
|
||||||
'@flowgram.ai/playground-react@1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
'@flowgram.ai/playground-react@1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@flowgram.ai/background-plugin': 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
'@flowgram.ai/background-plugin': 1.0.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
@@ -8145,6 +8242,8 @@ snapshots:
|
|||||||
|
|
||||||
minimist@1.2.8: {}
|
minimist@1.2.8: {}
|
||||||
|
|
||||||
|
modern-screenshot@4.6.7: {}
|
||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
|
||||||
msw@2.12.10(@types/node@20.19.37)(typescript@5.9.3):
|
msw@2.12.10(@types/node@20.19.37)(typescript@5.9.3):
|
||||||
@@ -9423,6 +9522,13 @@ snapshots:
|
|||||||
|
|
||||||
zod@4.3.6: {}
|
zod@4.3.6: {}
|
||||||
|
|
||||||
|
zustand@4.5.7(@types/react@19.2.14)(react@19.2.3):
|
||||||
|
dependencies:
|
||||||
|
use-sync-external-store: 1.6.0(react@19.2.3)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.14
|
||||||
|
react: 19.2.3
|
||||||
|
|
||||||
zustand@5.0.12(@types/react@19.2.14)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)):
|
zustand@5.0.12(@types/react@19.2.14)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.14
|
'@types/react': 19.2.14
|
||||||
|
|||||||
Reference in New Issue
Block a user