refactor: enhance node form panel and editor tools for improved validation and rendering

This commit is contained in:
mlogclub
2026-07-27 15:28:14 +08:00
parent bd8011dab8
commit d33960961a
6 changed files with 586 additions and 345 deletions
@@ -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) => {