refactor: enhance node form panel and editor tools for improved validation and rendering
This commit is contained in:
@@ -39,7 +39,8 @@ export function BaseNode(props: WorkflowNodeProps) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"relative flex w-[360px] flex-col rounded-lg border bg-white",
|
"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)]",
|
"border-[rgba(6,7,9,0.15)] shadow-[0_2px_6px_rgba(0,0,0,0.04),0_4px_12px_rgba(0,0,0,0.02)]",
|
||||||
render.selected && "border-[#4e40e5]"
|
render.selected && "border-[#4e40e5]",
|
||||||
|
render.form?.state.invalid && "border-destructive"
|
||||||
)}
|
)}
|
||||||
draggable={!render.readonly}
|
draggable={!render.readonly}
|
||||||
onDragStart={(event) => {
|
onDragStart={(event) => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { WorkflowNodePanelService } from "@flowgram.ai/free-node-panel-plugin"
|
import { WorkflowNodePanelService } from "@flowgram.ai/free-node-panel-plugin"
|
||||||
import {
|
import {
|
||||||
type InteractiveType,
|
type InteractiveType,
|
||||||
|
getAntiOverlapPosition,
|
||||||
useClientContext,
|
useClientContext,
|
||||||
usePlayground,
|
usePlayground,
|
||||||
usePlaygroundTools,
|
usePlaygroundTools,
|
||||||
@@ -112,14 +113,35 @@ export function EditorTools({
|
|||||||
await nodePanel.callNodePanel({
|
await nodePanel.callNodePanel({
|
||||||
position,
|
position,
|
||||||
enableMultiAdd: true,
|
enableMultiAdd: true,
|
||||||
onSelect: (result) => {
|
onSelect: async (result) => {
|
||||||
if (!result) return
|
if (!result) return
|
||||||
|
const rect = playground.node.getBoundingClientRect()
|
||||||
|
const center = playground.config.getPosFromMouseEvent({
|
||||||
|
clientX: rect.left + rect.width / 2,
|
||||||
|
clientY: rect.top + rect.height / 2,
|
||||||
|
})
|
||||||
|
const existingBounds = document
|
||||||
|
.getAllNodes()
|
||||||
|
.map((item) => item.transform.bounds)
|
||||||
|
const position =
|
||||||
|
existingBounds.length > 0
|
||||||
|
? {
|
||||||
|
x:
|
||||||
|
Math.max(...existingBounds.map((bounds) => bounds.right)) +
|
||||||
|
200,
|
||||||
|
y: Math.min(...existingBounds.map((bounds) => bounds.top)),
|
||||||
|
}
|
||||||
|
: center
|
||||||
const node: WorkflowNodeEntity = document.createWorkflowNodeByType(
|
const node: WorkflowNodeEntity = document.createWorkflowNodeByType(
|
||||||
result.nodeType,
|
result.nodeType,
|
||||||
undefined,
|
getAntiOverlapPosition(document, position),
|
||||||
result.nodeJSON ?? ({} as WorkflowNodeJSON)
|
result.nodeJSON ?? ({} as WorkflowNodeJSON)
|
||||||
)
|
)
|
||||||
selection.selectNode(node)
|
selection.selectNode(node)
|
||||||
|
await new Promise<void>((resolve) =>
|
||||||
|
window.requestAnimationFrame(() => resolve())
|
||||||
|
)
|
||||||
|
tools.fitView(false)
|
||||||
},
|
},
|
||||||
onClose: () => undefined,
|
onClose: () => undefined,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,20 +1,45 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import {
|
||||||
|
startTransition,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
Field,
|
||||||
PlaygroundEntityContext,
|
PlaygroundEntityContext,
|
||||||
|
WorkflowDocument,
|
||||||
type WorkflowNodeEntity,
|
type WorkflowNodeEntity,
|
||||||
|
type WorkflowNodeJSON,
|
||||||
|
WorkflowSelectService,
|
||||||
useClientContext,
|
useClientContext,
|
||||||
useNodeRender,
|
useNodeRender,
|
||||||
|
useRefresh,
|
||||||
|
useService,
|
||||||
} from "@flowgram.ai/free-layout-editor"
|
} from "@flowgram.ai/free-layout-editor"
|
||||||
import { usePanelManager } from "@flowgram.ai/panel-manager-plugin"
|
import { usePanelManager } from "@flowgram.ai/panel-manager-plugin"
|
||||||
import { PlusIcon, Trash2Icon, XIcon } from "lucide-react"
|
import {
|
||||||
|
AlertCircleIcon,
|
||||||
|
CopyIcon,
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
PencilIcon,
|
||||||
|
PlusIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
XIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
import { OptionCombobox } from "@/components/option-combobox"
|
import { OptionCombobox } from "@/components/option-combobox"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import {
|
import {
|
||||||
fetchKnowledgeBasesAll,
|
fetchKnowledgeBasesAll,
|
||||||
type AIWorkflowDefinition,
|
type AIWorkflowDefinition,
|
||||||
@@ -23,11 +48,13 @@ import {
|
|||||||
type KnowledgeBase,
|
type KnowledgeBase,
|
||||||
} from "@/lib/api/admin"
|
} from "@/lib/api/admin"
|
||||||
import { Status } from "@/lib/generated/enums"
|
import { Status } from "@/lib/generated/enums"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
import { NODE_FORM_PANEL } from "./base-node"
|
import { NODE_FORM_PANEL } from "./base-node"
|
||||||
import {
|
import {
|
||||||
WorkflowEditorSurfaceProvider,
|
WorkflowEditorSurfaceProvider,
|
||||||
useWorkflowEditorContext,
|
useWorkflowEditorContext,
|
||||||
|
useWorkflowEditorSurface,
|
||||||
} from "./editor-context"
|
} from "./editor-context"
|
||||||
import { WorkflowNodeIcon } from "./node-icon"
|
import { WorkflowNodeIcon } from "./node-icon"
|
||||||
import {
|
import {
|
||||||
@@ -53,122 +80,257 @@ const operatorOptions = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export function NodeFormPanel({ nodeId }: { nodeId: string }) {
|
export function NodeFormPanel({ nodeId }: { nodeId: string }) {
|
||||||
const { document } = useClientContext()
|
const { document, playground, selection } = useClientContext()
|
||||||
|
const panelManager = usePanelManager()
|
||||||
|
const refresh = useRefresh()
|
||||||
const node = document.getNode(nodeId)
|
const node = document.getNode(nodeId)
|
||||||
if (!node) return null
|
|
||||||
|
useEffect(() => {
|
||||||
|
const disposable = playground.config.onReadonlyOrDisabledChange(() => {
|
||||||
|
panelManager.close(NODE_FORM_PANEL)
|
||||||
|
refresh()
|
||||||
|
})
|
||||||
|
return () => disposable.dispose()
|
||||||
|
}, [panelManager, playground, refresh])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const disposable = selection.onSelectionChanged(() => {
|
||||||
|
if (
|
||||||
|
selection.selection.length !== 1 ||
|
||||||
|
selection.selection[0] !== node
|
||||||
|
) {
|
||||||
|
startTransition(() => panelManager.close(NODE_FORM_PANEL))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => disposable.dispose()
|
||||||
|
}, [node, panelManager, selection])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!node) return
|
||||||
|
const disposable = node.onDispose(() =>
|
||||||
|
panelManager.close(NODE_FORM_PANEL)
|
||||||
|
)
|
||||||
|
return () => disposable.dispose()
|
||||||
|
}, [node, panelManager])
|
||||||
|
|
||||||
|
if (
|
||||||
|
!node ||
|
||||||
|
playground.config.readonly ||
|
||||||
|
node.getNodeMeta<{ sidebarDisabled?: boolean }>().sidebarDisabled
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlaygroundEntityContext.Provider value={node}>
|
<PlaygroundEntityContext.Provider key={node.id} value={node}>
|
||||||
<WorkflowEditorSurfaceProvider surface="sidebar">
|
<WorkflowEditorSurfaceProvider surface="sidebar">
|
||||||
<NodeForm node={node} />
|
<SidebarNodeRenderer node={node} />
|
||||||
</WorkflowEditorSurfaceProvider>
|
</WorkflowEditorSurfaceProvider>
|
||||||
</PlaygroundEntityContext.Provider>
|
</PlaygroundEntityContext.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function NodeForm({ node }: { node: WorkflowNodeEntity }) {
|
function SidebarNodeRenderer({ node }: { node: WorkflowNodeEntity }) {
|
||||||
const panelManager = usePanelManager()
|
|
||||||
const render = useNodeRender(node)
|
const render = useNodeRender(node)
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full overflow-hidden rounded-lg border border-[rgba(82,100,154,0.13)] bg-[#fbfbfb]">
|
||||||
|
{render.form?.render()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkflowNodeForm({ spec }: { spec: AIWorkflowNodeSpec }) {
|
||||||
|
const render = useNodeRender()
|
||||||
|
const surface = useWorkflowEditorSurface()
|
||||||
|
const isSidebar = surface === "sidebar"
|
||||||
const { document } = useClientContext()
|
const { document } = useClientContext()
|
||||||
const { nodeSpecs } = useWorkflowEditorContext()
|
const { nodeSpecs } = useWorkflowEditorContext()
|
||||||
const spec = nodeSpecs.find((item) => item.type === String(node.flowNodeType))
|
|
||||||
const data = render.data ?? {}
|
|
||||||
const definition = document.toJSON() as AIWorkflowDefinition
|
const definition = document.toJSON() as AIWorkflowDefinition
|
||||||
const variables = buildAvailableVariables(definition, node.id, nodeSpecs)
|
const variables = buildAvailableVariables(definition, render.node.id, nodeSpecs)
|
||||||
const canDelete = !["start", "end"].includes(String(node.flowNodeType))
|
|
||||||
|
|
||||||
function updateData(next: Record<string, unknown>) {
|
return (
|
||||||
render.updateData({ ...data, ...next })
|
<div className={cn("w-full select-none", isSidebar && "h-full")}>
|
||||||
|
<NodeFormHeader spec={spec} />
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-full rounded-b-lg bg-[#fbfbfb] px-3 pb-3",
|
||||||
|
isSidebar
|
||||||
|
? "h-[calc(100%-40px)] overflow-y-auto overscroll-contain pt-1"
|
||||||
|
: "space-y-1.5"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isSidebar && spec.description ? (
|
||||||
|
<p className="px-1 pb-2 text-xs leading-5 text-[rgba(6,7,9,0.5)]">
|
||||||
|
{spec.description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<InputFields spec={spec} variables={variables} />
|
||||||
|
{spec.type === "knowledge_retrieve" ? <KnowledgeFields /> : null}
|
||||||
|
{spec.type === "condition" ? (
|
||||||
|
<ConditionFields variables={variables} />
|
||||||
|
) : null}
|
||||||
|
<OutputFields spec={spec} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NodeFormHeader({ spec }: { spec: AIWorkflowNodeSpec }) {
|
||||||
|
const render = useNodeRender()
|
||||||
|
const panelManager = usePanelManager()
|
||||||
|
const { document } = useClientContext()
|
||||||
|
const selection = useService(WorkflowSelectService)
|
||||||
|
const surface = useWorkflowEditorSurface()
|
||||||
|
const isSidebar = surface === "sidebar"
|
||||||
|
const canDelete = !["start", "end"].includes(String(render.node.flowNodeType))
|
||||||
|
const canCopy = canDelete
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
const titleRef = useRef<HTMLInputElement>(null)
|
||||||
|
const closeMenuTimer = useRef<number | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editing) titleRef.current?.focus()
|
||||||
|
}, [editing])
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (closeMenuTimer.current) window.clearTimeout(closeMenuTimer.current)
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
function openMenu() {
|
||||||
|
if (closeMenuTimer.current) window.clearTimeout(closeMenuTimer.current)
|
||||||
|
setMenuOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleCloseMenu() {
|
||||||
|
if (closeMenuTimer.current) window.clearTimeout(closeMenuTimer.current)
|
||||||
|
closeMenuTimer.current = window.setTimeout(() => setMenuOpen(false), 120)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col bg-[#fbfbfb]">
|
<div className="flex h-10 w-full items-center gap-2 overflow-hidden rounded-t-lg bg-gradient-to-b from-[#f2f2ff] to-[#fbfbfb] px-2">
|
||||||
<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-6 shrink-0 items-center justify-center rounded bg-white/70 text-[#4e40e5]">
|
||||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-[#f2f3ff] text-[#4e40e5]">
|
<WorkflowNodeIcon name={spec.icon} className="size-3.5" />
|
||||||
<WorkflowNodeIcon name={spec?.icon} className="size-4" />
|
</span>
|
||||||
</span>
|
<Field<string> name="title">
|
||||||
<div className="min-w-0 flex-1">
|
{({ field, fieldState }) => (
|
||||||
<div className="text-sm font-semibold text-[#060709]">
|
<div className="relative min-w-0 flex-1">
|
||||||
{String(data.title || spec?.title || node.flowNodeType)}
|
{editing && !render.readonly ? (
|
||||||
|
<Input
|
||||||
|
ref={titleRef}
|
||||||
|
value={field.value ?? ""}
|
||||||
|
className="h-7 border-[#4e40e5] bg-white px-2 text-sm"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onBlur={() => setEditing(false)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" || event.key === "Escape") {
|
||||||
|
setEditing(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onChange={(event) => field.onChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="block h-7 w-full truncate text-left text-sm font-medium text-[#060709]"
|
||||||
|
title={field.value || spec.title}
|
||||||
|
onDoubleClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
if (!render.readonly) setEditing(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{field.value || spec.title}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{fieldState?.invalid ? (
|
||||||
|
<AlertCircleIcon className="absolute -left-1 -top-1 size-4 rounded-full bg-white text-destructive" />
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
</Field>
|
||||||
|
{!render.readonly ? (
|
||||||
|
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
aria-label="节点操作"
|
||||||
|
className="shrink-0 text-muted-foreground"
|
||||||
|
onMouseEnter={openMenu}
|
||||||
|
onMouseLeave={scheduleCloseMenu}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
className="w-36"
|
||||||
|
onMouseEnter={openMenu}
|
||||||
|
onMouseLeave={scheduleCloseMenu}
|
||||||
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
setEditing(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PencilIcon />
|
||||||
|
编辑名称
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
disabled={!canCopy}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
duplicateNode(render.node, document, selection)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CopyIcon />
|
||||||
|
创建副本
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
disabled={!canDelete}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
render.deleteNode()
|
||||||
|
panelManager.close(NODE_FORM_PANEL)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2Icon />
|
||||||
|
删除节点
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null}
|
||||||
|
{isSidebar ? (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-xs"
|
||||||
onClick={() => panelManager.close(NODE_FORM_PANEL)}
|
|
||||||
aria-label="关闭配置"
|
aria-label="关闭配置"
|
||||||
|
className="shrink-0"
|
||||||
|
onClick={() => panelManager.close(NODE_FORM_PANEL)}
|
||||||
>
|
>
|
||||||
<XIcon className="size-4" />
|
<XIcon />
|
||||||
</Button>
|
</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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function InputSection({
|
function InputFields({
|
||||||
spec,
|
spec,
|
||||||
inputsValues,
|
|
||||||
variables,
|
variables,
|
||||||
onChange,
|
|
||||||
}: {
|
}: {
|
||||||
spec?: AIWorkflowNodeSpec
|
spec: AIWorkflowNodeSpec
|
||||||
inputsValues: Record<string, AIWorkflowValue>
|
|
||||||
variables: ReturnType<typeof buildAvailableVariables>
|
variables: ReturnType<typeof buildAvailableVariables>
|
||||||
onChange: (value: Record<string, AIWorkflowValue>) => void
|
|
||||||
}) {
|
}) {
|
||||||
if (!spec?.inputSchema?.length) return null
|
if (!spec.inputSchema?.length) return null
|
||||||
const options = variables.map((variable) => ({
|
const options = variables.map((variable) => ({
|
||||||
value: `${variable.nodeId}.${variable.name}`,
|
value: `${variable.nodeId}.${variable.name}`,
|
||||||
label: variable.label || variable.name,
|
label: variable.label || variable.name,
|
||||||
@@ -176,40 +338,45 @@ function InputSection({
|
|||||||
subtitle: `${variable.nodeId}.${variable.name}`,
|
subtitle: `${variable.nodeId}.${variable.name}`,
|
||||||
description: variable.description,
|
description: variable.description,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormSection title="输入">
|
<>
|
||||||
{spec.inputSchema.map((input) => (
|
{spec.inputSchema.map((input) => (
|
||||||
<FormField
|
<Field<AIWorkflowValue | undefined>
|
||||||
key={input.name}
|
key={input.name}
|
||||||
label={input.label || input.name}
|
name={`inputsValues.${input.name}`}
|
||||||
required={input.required}
|
|
||||||
hint={input.description}
|
|
||||||
>
|
>
|
||||||
<OptionCombobox
|
{({ field, fieldState }) => (
|
||||||
value={refKey(inputsValues[input.name])}
|
<NodeFormRow
|
||||||
options={options}
|
label={input.label || input.name}
|
||||||
placeholder="选择上游变量"
|
type={input.type}
|
||||||
searchPlaceholder="搜索变量"
|
required={input.required}
|
||||||
preserveExternalSelection
|
description={input.description}
|
||||||
onChange={(value) => {
|
>
|
||||||
const parsed = parseRefKey(value)
|
<OptionCombobox
|
||||||
if (!parsed) return
|
value={refKey(field.value)}
|
||||||
onChange({ ...inputsValues, [input.name]: parsed })
|
options={options}
|
||||||
}}
|
placeholder="选择上游变量"
|
||||||
/>
|
searchPlaceholder="搜索变量"
|
||||||
</FormField>
|
preserveExternalSelection
|
||||||
|
triggerClassName={cn(
|
||||||
|
"h-8 bg-white text-xs",
|
||||||
|
fieldState?.invalid && "border-destructive"
|
||||||
|
)}
|
||||||
|
onChange={(value) => {
|
||||||
|
const parsed = parseRefKey(value)
|
||||||
|
if (parsed) field.onChange(parsed)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</NodeFormRow>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
))}
|
))}
|
||||||
</FormSection>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function KnowledgeSection({
|
function KnowledgeFields() {
|
||||||
config,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
config: Record<string, unknown>
|
|
||||||
onChange: (value: Record<string, unknown>) => void
|
|
||||||
}) {
|
|
||||||
const [items, setItems] = useState<KnowledgeBase[]>([])
|
const [items, setItems] = useState<KnowledgeBase[]>([])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
@@ -220,210 +387,316 @@ function KnowledgeSection({
|
|||||||
active = false
|
active = false
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
const values = normalizeIDs(config.knowledgeBaseIds).map(String)
|
|
||||||
return (
|
return (
|
||||||
<FormSection title="知识库">
|
<Field<Record<string, unknown>> name="config">
|
||||||
<FormField label="检索范围" required hint="可选择多个已启用知识库。">
|
{({ field }) => {
|
||||||
<OptionCombobox
|
const config = asRecord(field.value)
|
||||||
multiple
|
const values = normalizeIDs(config.knowledgeBaseIds).map(String)
|
||||||
values={values}
|
return (
|
||||||
options={items.map((item) => ({
|
<NodeFormRow
|
||||||
value: String(item.id),
|
label="检索范围"
|
||||||
label: item.name,
|
type="array<int>"
|
||||||
}))}
|
required
|
||||||
placeholder="选择知识库"
|
description="可选择多个已启用知识库。"
|
||||||
searchPlaceholder="搜索知识库"
|
>
|
||||||
onValuesChange={(next) =>
|
<OptionCombobox
|
||||||
onChange({
|
multiple
|
||||||
...config,
|
values={values}
|
||||||
knowledgeBaseIds: next.map(Number).filter((id) => id > 0),
|
options={items.map((item) => ({
|
||||||
})
|
value: String(item.id),
|
||||||
}
|
label: item.name,
|
||||||
/>
|
}))}
|
||||||
</FormField>
|
placeholder="选择知识库"
|
||||||
</FormSection>
|
searchPlaceholder="搜索知识库"
|
||||||
|
triggerClassName="min-h-8 bg-white text-xs"
|
||||||
|
onValuesChange={(next) =>
|
||||||
|
field.onChange({
|
||||||
|
...config,
|
||||||
|
knowledgeBaseIds: next
|
||||||
|
.map(Number)
|
||||||
|
.filter((id) => id > 0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</NodeFormRow>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Field>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConditionSection({
|
function ConditionFields({
|
||||||
branches,
|
|
||||||
variables,
|
variables,
|
||||||
onChange,
|
|
||||||
}: {
|
}: {
|
||||||
branches: WorkflowConditionBranch[]
|
|
||||||
variables: ReturnType<typeof buildAvailableVariables>
|
variables: ReturnType<typeof buildAvailableVariables>
|
||||||
onChange: (branches: WorkflowConditionBranch[]) => void
|
|
||||||
}) {
|
}) {
|
||||||
|
const render = useNodeRender()
|
||||||
const variableOptions = variables.map((variable) => ({
|
const variableOptions = variables.map((variable) => ({
|
||||||
value: `${variable.nodeId}.${variable.name}`,
|
value: `${variable.nodeId}.${variable.name}`,
|
||||||
label: variable.label || variable.name,
|
label: variable.label || variable.name,
|
||||||
group: variable.nodeTitle,
|
group: variable.nodeTitle,
|
||||||
subtitle: `${variable.nodeId}.${variable.name}`,
|
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 (
|
return (
|
||||||
<FormSection
|
<Field<Record<string, unknown>> name="config">
|
||||||
title="条件分支"
|
{({ field }) => {
|
||||||
action={
|
const config = asRecord(field.value)
|
||||||
<Button
|
const branches = normalizeConditionBranches({
|
||||||
variant="ghost"
|
data: { config },
|
||||||
size="sm"
|
})
|
||||||
onClick={() => {
|
const regular = branches.filter((branch) => !branch.default)
|
||||||
const next: WorkflowConditionBranch = {
|
const fallback = branches.find((branch) => branch.default)
|
||||||
id: nextBranchID(branches),
|
|
||||||
name: `条件 ${regular.length + 1}`,
|
function commit(next: WorkflowConditionBranch[]) {
|
||||||
targetNodeId: "",
|
const nextConfig = { ...config, branches: next }
|
||||||
condition: { operator: "eq" },
|
field.onChange(nextConfig)
|
||||||
}
|
render.updateData({
|
||||||
onChange([...regular, next, fallback].filter(Boolean) as WorkflowConditionBranch[])
|
...render.data,
|
||||||
}}
|
config: nextConfig,
|
||||||
>
|
portKeys: next.map((branch) => branch.id),
|
||||||
<PlusIcon className="size-4" />
|
ports: next.map((branch) => branch.id),
|
||||||
添加
|
})
|
||||||
</Button>
|
window.requestAnimationFrame(() =>
|
||||||
}
|
render.node.ports.updateDynamicPorts()
|
||||||
>
|
)
|
||||||
{[...regular, ...(fallback ? [fallback] : [])].map((branch) => (
|
}
|
||||||
<div key={branch.id} className="rounded-md bg-slate-50 p-3">
|
|
||||||
<div className="flex items-center gap-2">
|
function update(branch: WorkflowConditionBranch) {
|
||||||
<Input
|
commit(
|
||||||
value={branch.name ?? ""}
|
branches.map((item) => (item.id === branch.id ? branch : item))
|
||||||
onChange={(event) => update({ ...branch, name: event.target.value })}
|
)
|
||||||
/>
|
}
|
||||||
{!branch.default ? (
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{branches.map((branch, index) => (
|
||||||
|
<div
|
||||||
|
key={branch.id}
|
||||||
|
className="relative flex items-start gap-2 py-0.5"
|
||||||
|
>
|
||||||
|
<div className="flex h-8 w-[50px] shrink-0 items-center gap-1 text-xs">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="h-5 rounded px-1.5 font-mono text-[10px] uppercase text-[#4e40e5]"
|
||||||
|
>
|
||||||
|
{branch.default
|
||||||
|
? "else"
|
||||||
|
: index === 0
|
||||||
|
? "if"
|
||||||
|
: "elif"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{branch.default ? (
|
||||||
|
<div className="flex h-8 min-w-0 flex-1 items-center text-xs text-muted-foreground">
|
||||||
|
其他条件均不匹配
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid min-w-0 flex-1 gap-1.5">
|
||||||
|
<OptionCombobox
|
||||||
|
value={refKey(branch.condition?.left)}
|
||||||
|
options={variableOptions}
|
||||||
|
placeholder="选择变量"
|
||||||
|
preserveExternalSelection
|
||||||
|
triggerClassName="h-8 bg-white text-xs"
|
||||||
|
onChange={(value) =>
|
||||||
|
update({
|
||||||
|
...branch,
|
||||||
|
condition: {
|
||||||
|
...branch.condition,
|
||||||
|
left: parseRefKey(value),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<OptionCombobox
|
||||||
|
value={branch.condition?.operator ?? "eq"}
|
||||||
|
options={operatorOptions}
|
||||||
|
placeholder="运算符"
|
||||||
|
triggerClassName="h-8 min-w-0 flex-1 bg-white text-xs"
|
||||||
|
onChange={(operator) =>
|
||||||
|
update({
|
||||||
|
...branch,
|
||||||
|
condition: {
|
||||||
|
...branch.condition,
|
||||||
|
operator,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{!["exists", "empty"].includes(
|
||||||
|
branch.condition?.operator ?? ""
|
||||||
|
) ? (
|
||||||
|
<Input
|
||||||
|
value={String(branch.condition?.right ?? "")}
|
||||||
|
placeholder="比较值"
|
||||||
|
className="h-8 min-w-0 flex-1 bg-white text-xs"
|
||||||
|
onChange={(event) =>
|
||||||
|
update({
|
||||||
|
...branch,
|
||||||
|
condition: {
|
||||||
|
...branch.condition,
|
||||||
|
right: event.target.value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!branch.default && !render.readonly ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
aria-label="删除条件"
|
||||||
|
className="mt-1 shrink-0 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() =>
|
||||||
|
commit(
|
||||||
|
branches.filter((item) => item.id !== branch.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2Icon />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<span
|
||||||
|
data-port-id={branch.id}
|
||||||
|
data-port-type="output"
|
||||||
|
className="absolute -right-3 top-4 size-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!render.readonly ? (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="sm"
|
||||||
className="shrink-0 text-slate-500 hover:text-destructive"
|
className="h-7 px-1.5 text-xs text-[#4e40e5]"
|
||||||
onClick={() => onChange(branches.filter((item) => item.id !== branch.id))}
|
onClick={() => {
|
||||||
|
const branch: WorkflowConditionBranch = {
|
||||||
|
id: nextBranchID(branches),
|
||||||
|
name: `条件 ${regular.length + 1}`,
|
||||||
|
targetNodeId: "",
|
||||||
|
condition: { operator: "eq" },
|
||||||
|
}
|
||||||
|
commit(
|
||||||
|
[...regular, branch, fallback].filter(
|
||||||
|
Boolean
|
||||||
|
) as WorkflowConditionBranch[]
|
||||||
|
)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Trash2Icon className="size-4" />
|
<PlusIcon />
|
||||||
|
添加条件
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{branch.default ? (
|
)
|
||||||
<p className="mt-2 text-xs text-slate-500">其他条件均不匹配时进入此分支。</p>
|
}}
|
||||||
) : (
|
</Field>
|
||||||
<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 }) {
|
function OutputFields({ spec }: { spec: AIWorkflowNodeSpec }) {
|
||||||
if (!spec?.outputSchema?.length) return null
|
if (!spec.outputSchema?.length) return null
|
||||||
return (
|
return (
|
||||||
<FormSection title="输出">
|
<div className="mt-1 border-t border-[rgba(82,100,154,0.13)] pt-2">
|
||||||
<div className="divide-y rounded-md border">
|
{spec.outputSchema.map((output) => (
|
||||||
{spec.outputSchema.map((output) => (
|
<NodeFormRow
|
||||||
<div key={output.name} className="px-3 py-2.5">
|
key={output.name}
|
||||||
<div className="flex items-center justify-between gap-3 text-sm">
|
label={output.label || output.name}
|
||||||
<span className="font-medium">{output.label || output.name}</span>
|
type={output.type}
|
||||||
<span className="font-mono text-xs text-slate-500">{output.type}</span>
|
description={output.description}
|
||||||
</div>
|
>
|
||||||
{output.description ? (
|
<div
|
||||||
<p className="mt-1 text-xs leading-5 text-slate-500">{output.description}</p>
|
className="flex h-8 items-center truncate rounded-md bg-[#f3f3f6] px-2 font-mono text-xs text-muted-foreground"
|
||||||
) : null}
|
title={`${output.name}: ${output.description}`}
|
||||||
|
>
|
||||||
|
{output.name}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</NodeFormRow>
|
||||||
</div>
|
))}
|
||||||
</FormSection>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function FormSection({
|
function NodeFormRow({
|
||||||
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,
|
label,
|
||||||
|
type,
|
||||||
required,
|
required,
|
||||||
hint,
|
description,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
label: string
|
label: string
|
||||||
|
type?: string
|
||||||
required?: boolean
|
required?: boolean
|
||||||
hint?: string
|
description?: string
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div
|
||||||
<Label>
|
className="flex w-full items-start gap-2 py-0.5 text-xs"
|
||||||
{label}
|
onClick={(event) => event.stopPropagation()}
|
||||||
{required ? <span className="ml-1 text-destructive">*</span> : null}
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
</Label>
|
>
|
||||||
{children}
|
<div
|
||||||
{hint ? <p className="text-xs leading-5 text-slate-500">{hint}</p> : null}
|
className="flex min-h-8 w-[118px] min-w-[118px] items-center gap-1"
|
||||||
|
title={description}
|
||||||
|
>
|
||||||
|
{type ? (
|
||||||
|
<span className="flex size-[18px] shrink-0 items-center justify-center rounded bg-[#ececf1] font-mono text-[9px] uppercase text-muted-foreground">
|
||||||
|
{typeIcon(type)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="min-w-0 truncate text-[#060709]">{label}</span>
|
||||||
|
{required ? <span className="text-destructive">*</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function duplicateNode(
|
||||||
|
node: WorkflowNodeEntity,
|
||||||
|
document: WorkflowDocument,
|
||||||
|
selection: WorkflowSelectService
|
||||||
|
) {
|
||||||
|
const source = document.toNodeJSON(node) as WorkflowNodeJSON
|
||||||
|
const position = {
|
||||||
|
x: Number(source.meta?.position?.x ?? node.transform.position.x) + 48,
|
||||||
|
y: Number(source.meta?.position?.y ?? node.transform.position.y) + 48,
|
||||||
|
}
|
||||||
|
const used = new Set(document.getAllNodes().map((item) => item.id))
|
||||||
|
const baseID = `${source.id}_copy`
|
||||||
|
let id = baseID
|
||||||
|
let index = 2
|
||||||
|
while (used.has(id)) {
|
||||||
|
id = `${baseID}_${index}`
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
const copied = document.createWorkflowNodeByType(
|
||||||
|
String(node.flowNodeType),
|
||||||
|
position,
|
||||||
|
{
|
||||||
|
...source,
|
||||||
|
id,
|
||||||
|
meta: { ...source.meta, position },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
selection.selectNode(copied)
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeIcon(type: string) {
|
||||||
|
if (type === "string") return "S"
|
||||||
|
if (type === "boolean") return "B"
|
||||||
|
if (type === "number" || type === "integer") return "N"
|
||||||
|
if (type.startsWith("array")) return "A"
|
||||||
|
if (type === "object") return "O"
|
||||||
|
return "•"
|
||||||
|
}
|
||||||
|
|
||||||
function asRecord(value: unknown): Record<string, unknown> {
|
function asRecord(value: unknown): Record<string, unknown> {
|
||||||
return value && typeof value === "object" && !Array.isArray(value)
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
? (value as Record<string, unknown>)
|
? (value as Record<string, unknown>)
|
||||||
@@ -433,6 +706,10 @@ function asRecord(value: unknown): Record<string, unknown> {
|
|||||||
function normalizeIDs(value: unknown) {
|
function normalizeIDs(value: unknown) {
|
||||||
if (!Array.isArray(value)) return []
|
if (!Array.isArray(value)) return []
|
||||||
return Array.from(
|
return Array.from(
|
||||||
new Set(value.map(Number).filter((item) => Number.isInteger(item) && item > 0))
|
new Set(
|
||||||
|
value
|
||||||
|
.map(Number)
|
||||||
|
.filter((item) => Number.isInteger(item) && item > 0)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import {
|
import { type WorkflowNodeRegistry } from "@flowgram.ai/free-layout-editor"
|
||||||
Field,
|
|
||||||
type WorkflowNodeRegistry,
|
|
||||||
} from "@flowgram.ai/free-layout-editor"
|
|
||||||
|
|
||||||
import type { AIWorkflowNodeSpec } from "@/lib/api/admin"
|
import type { AIWorkflowNodeSpec } from "@/lib/api/admin"
|
||||||
|
|
||||||
import { WorkflowNodeIcon } from "./node-icon"
|
import { WorkflowNodeForm } from "./node-form-panel"
|
||||||
import { normalizeConditionBranches } from "./workflow-model"
|
|
||||||
|
|
||||||
export function buildNodeRegistries(
|
export function buildNodeRegistries(
|
||||||
nodeSpecs: AIWorkflowNodeSpec[]
|
nodeSpecs: AIWorkflowNodeSpec[]
|
||||||
@@ -16,6 +12,9 @@ export function buildNodeRegistries(
|
|||||||
return [
|
return [
|
||||||
...nodeSpecs.map((spec) => ({
|
...nodeSpecs.map((spec) => ({
|
||||||
type: spec.type,
|
type: spec.type,
|
||||||
|
info: {
|
||||||
|
description: spec.description,
|
||||||
|
},
|
||||||
meta: {
|
meta: {
|
||||||
defaultExpanded: true,
|
defaultExpanded: true,
|
||||||
isStart: spec.type === "start",
|
isStart: spec.type === "start",
|
||||||
@@ -26,7 +25,7 @@ export function buildNodeRegistries(
|
|||||||
defaultPorts: getDefaultPorts(spec.type),
|
defaultPorts: getDefaultPorts(spec.type),
|
||||||
},
|
},
|
||||||
formMeta: {
|
formMeta: {
|
||||||
render: () => <CanvasNodeContent spec={spec} />,
|
render: () => <WorkflowNodeForm spec={spec} />,
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
{
|
{
|
||||||
@@ -47,65 +46,6 @@ export function buildNodeRegistries(
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
function getDefaultPorts(type: string) {
|
||||||
if (type === "start") return [{ type: "output" as const }]
|
if (type === "start") return [{ type: "output" as const }]
|
||||||
if (type === "end") return [{ type: "input" as const }]
|
if (type === "end") return [{ type: "input" as const }]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
AIWorkflowNodeSpec,
|
AIWorkflowNodeSpec,
|
||||||
AIWorkflowValue,
|
AIWorkflowValue,
|
||||||
} from "@/lib/api/admin"
|
} from "@/lib/api/admin"
|
||||||
|
import type { WorkflowNodeJSON } from "@flowgram.ai/free-layout-editor"
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
WorkflowConditionBranch,
|
WorkflowConditionBranch,
|
||||||
@@ -104,14 +105,13 @@ export function serializeDefinition(
|
|||||||
export function createNodeJSON(
|
export function createNodeJSON(
|
||||||
spec: AIWorkflowNodeSpec,
|
spec: AIWorkflowNodeSpec,
|
||||||
existingNodeIDs: string[] = []
|
existingNodeIDs: string[] = []
|
||||||
): WorkflowNode {
|
): WorkflowNodeJSON {
|
||||||
const id = uniqueNodeID(spec.type, existingNodeIDs)
|
const id = uniqueNodeID(spec.type, existingNodeIDs)
|
||||||
const config =
|
const config =
|
||||||
spec.type === "condition" ? { branches: [defaultBranch] } : {}
|
spec.type === "condition" ? { branches: [defaultBranch] } : {}
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
type: spec.type,
|
type: spec.type,
|
||||||
meta: { position: { x: 0, y: 0 } },
|
|
||||||
data: {
|
data: {
|
||||||
title: spec.title || spec.type,
|
title: spec.title || spec.type,
|
||||||
config,
|
config,
|
||||||
|
|||||||
@@ -265,6 +265,7 @@ export function WorkflowWorkbench({
|
|||||||
>
|
>
|
||||||
{nodeSpecs.length ? (
|
{nodeSpecs.length ? (
|
||||||
<WorkflowEditor
|
<WorkflowEditor
|
||||||
|
key={active?.id ?? (workflowID ? `loading-${workflowID}` : "new")}
|
||||||
definition={definition}
|
definition={definition}
|
||||||
nodeSpecs={nodeSpecs}
|
nodeSpecs={nodeSpecs}
|
||||||
onDefinitionChange={(next) => {
|
onDefinitionChange={(next) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user