feat: enhance workflow node components with new condition handling and UI improvements

This commit is contained in:
mlogclub
2026-06-28 11:52:32 +08:00
parent 5160499fbf
commit f809b5e526
8 changed files with 432 additions and 169 deletions
@@ -1,22 +1,31 @@
import { Field, type WorkflowNodeRegistry, useNodeRender } from "@flowgram.ai/free-layout-editor"
import { PlusIcon, XIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import type { AIWorkflowNodeSpec } from "@/lib/api/admin"
import {
createConditionBranchID,
normalizeNodeConfig,
type WorkflowConditionBranch,
} from "./workflow-utils"
import { useWorkflowBranchSelection } from "./workflow-branch-selection"
import { WorkflowConditionNodeContent } from "./workflow-condition-node-content"
import { WorkflowNodeCard } from "./workflow-node-card"
import { WorkflowSimpleNodeContent } from "./workflow-simple-node-content"
export function buildFlowgramNodeRegistries(nodeSpecs: AIWorkflowNodeSpec[]): WorkflowNodeRegistry[] {
const seen = new Set<string>()
const specs = nodeSpecs.length > 0
? nodeSpecs
: [
{ type: "start", title: "开始" },
{ type: "end", title: "结束" },
{
type: "start",
title: "开始",
description: "流程入口",
riskLevel: "low" as const,
interruptible: false,
requiresConfirmationPredecessor: false,
},
{
type: "end",
title: "结束",
description: "流程结束",
riskLevel: "low" as const,
interruptible: false,
requiresConfirmationPredecessor: false,
},
]
return specs
@@ -41,6 +50,7 @@ export function buildFlowgramNodeRegistries(nodeSpecs: AIWorkflowNodeSpec[]): Wo
<FlowgramNodeForm
nodeType={spec.type}
fallbackTitle={spec.title || spec.type}
spec={spec}
/>
),
},
@@ -50,33 +60,41 @@ export function buildFlowgramNodeRegistries(nodeSpecs: AIWorkflowNodeSpec[]): Wo
function FlowgramNodeForm({
nodeType,
fallbackTitle,
spec,
}: {
nodeType: string
fallbackTitle: string
spec?: AIWorkflowNodeSpec
}) {
const { node } = useNodeRender()
const { node, selected } = useNodeRender()
const nodeId = String(node.id ?? "")
if (nodeType === "condition") {
return (
<ConditionNodeForm
fallbackTitle={fallbackTitle}
nodeId={nodeId}
/>
)
}
return (
<div className="flex w-full flex-col">
<Field<string> name="title">
{({ field }) => (
<div className="border-b px-3 py-2.5 text-sm font-medium leading-5">
{field.value || fallbackTitle}
</div>
)}
</Field>
<div className="px-3 py-2 text-xs text-muted-foreground">{nodeType}</div>
</div>
<Field<string> name="title">
{({ field }) => (
<WorkflowNodeCard
nodeType={nodeType}
title={field.value || fallbackTitle}
spec={spec}
selected={selected}
width={nodeType === "condition" ? "wide" : "normal"}
>
{nodeType === "condition" ? (
<Field<Record<string, unknown>> name="config">
{({ field: configField }) => (
<WorkflowConditionNodeContent
configValue={configField.value}
nodeId={nodeId}
onChange={configField.onChange}
/>
)}
</Field>
) : (
<WorkflowSimpleNodeContent spec={spec} />
)}
</WorkflowNodeCard>
)}
</Field>
)
}
@@ -92,133 +110,3 @@ function defaultPortsForNodeType(type: string) {
}
return [{ type: "input" as const }, { type: "output" as const }]
}
function ConditionNodeForm({
fallbackTitle,
nodeId,
}: {
fallbackTitle: string
nodeId: string
}) {
const { selectedBranch, onSelectBranch } = useWorkflowBranchSelection()
return (
<div className="flex w-full flex-col">
<Field<string> name="title">
{({ field }) => (
<div className="border-b px-3 py-2.5 text-sm font-medium leading-5">
{field.value || fallbackTitle}
</div>
)}
</Field>
<Field<Record<string, unknown>> name="config">
{({ field }) => {
const config = normalizeNodeConfig(field.value)
const branches = ensureConditionBranches(config.branches ?? [])
const updateBranches = (nextBranches: WorkflowConditionBranch[]) => {
field.onChange({
...config,
branches: ensureConditionBranches(nextBranches),
})
}
const deleteBranch = (branchId: string) => {
updateBranches(branches.filter((branch) => branch.id !== branchId))
if (selectedBranch?.nodeId === nodeId && selectedBranch.branchId === branchId) {
onSelectBranch?.(null)
}
}
return (
<div className="px-3 py-2.5">
<div className="space-y-2">
{branches.map((branch, index) => (
<div
key={branch.id}
className={[
"relative flex min-h-9 cursor-pointer items-center gap-2 rounded-sm border px-2.5 text-xs transition-colors",
selectedBranch?.nodeId === nodeId && selectedBranch.branchId === branch.id
? "border-[var(--g-selection-background)] bg-muted"
: "border-transparent bg-muted/60 hover:bg-muted",
].join(" ")}
onPointerDownCapture={(event) => {
event.stopPropagation()
onSelectBranch?.({ nodeId, branchId: branch.id })
}}
onMouseDownCapture={(event) => {
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
}}
>
<span className="min-w-0 flex-1 truncate font-medium text-foreground/90">
{branch.name || (branch.default ? "默认分支" : branch.id)}
</span>
<span className="shrink-0 rounded-sm border bg-background px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{branch.default ? "else" : index === 0 ? "if" : "elseif"}
</span>
{branch.default ? null : (
<button
type="button"
className="flex size-5 shrink-0 items-center justify-center rounded-sm 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()
deleteBranch(branch.id)
}}
>
<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>
))}
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="mt-2 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>
)
}}
</Field>
</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),
]
}
@@ -10,7 +10,7 @@ import { useLayoutEffect } from "react"
import { cn } from "@/lib/utils"
export function FlowgramNodeRenderer(props: WorkflowNodeProps) {
const { selected, node, form } = useNodeRender()
const { node, form } = useNodeRender()
const nodeType = String(node.flowNodeType ?? "")
useLayoutEffect(() => {
@@ -24,10 +24,7 @@ export function FlowgramNodeRenderer(props: WorkflowNodeProps) {
return (
<WorkflowNodeRenderer
node={props.node}
className={cn(
"w-[320px] overflow-visible rounded-md border bg-background shadow-sm transition-colors",
selected ? "border-[var(--g-selection-background)]" : "border-border"
)}
className={cn("overflow-visible")}
style={{ padding: 0 }}
portPrimaryColor="var(--g-selection-background)"
portSecondaryColor="#c9cdd4"
@@ -0,0 +1,153 @@
import { PlusIcon, XIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { useWorkflowBranchSelection } from "./workflow-branch-selection"
import {
createConditionBranchID,
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 { 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) => {
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-[var(--g-selection-background)] bg-background shadow-sm"
: "border-border/50 bg-background/70 hover:border-border hover:bg-background"
)}
onPointerDownCapture={(event) => {
event.stopPropagation()
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),
]
}
@@ -0,0 +1,66 @@
import type { ReactNode } from "react"
import { cn } from "@/lib/utils"
import type { AIWorkflowNodeSpec } from "@/lib/api/admin"
import { getWorkflowNodeAccentClass, getWorkflowNodeMeta } from "./workflow-node-meta"
import { WorkflowNodeIcon } from "./workflow-node-icon"
export function WorkflowNodeCard({
nodeType,
title,
spec,
selected,
width = "normal",
children,
}: {
nodeType: string
title: string
spec?: AIWorkflowNodeSpec
selected: boolean
width?: "normal" | "wide"
children: ReactNode
}) {
const meta = getWorkflowNodeMeta(nodeType)
return (
<div
className={cn(
"group relative rounded-2xl border bg-background p-0.5 transition-all",
width === "wide" ? "w-[320px]" : "w-[280px]",
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-hidden rounded-[15px] border border-transparent bg-background">
<div className="flex items-center gap-2 px-3 pb-2 pt-3">
<WorkflowNodeIcon type={nodeType} tone={meta.tone} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold leading-5 text-foreground">
{title}
</div>
<div className="mt-0.5 flex items-center gap-1.5">
<span
className={cn(
"inline-flex h-4 shrink-0 items-center rounded-md border px-1.5 text-[10px] font-medium leading-none",
getWorkflowNodeAccentClass(meta.tone)
)}
>
{meta.label}
</span>
<span className="min-w-0 truncate text-[11px] leading-4 text-muted-foreground">
{nodeType}
</span>
</div>
</div>
</div>
{spec?.description ? (
<div className="mx-3 mb-2 line-clamp-2 rounded-lg bg-muted/45 px-2 py-1.5 text-xs leading-4 text-muted-foreground">
{spec.description}
</div>
) : null}
<div className="border-t bg-muted/20 px-3 py-2.5">{children}</div>
</div>
</div>
)
}
@@ -0,0 +1,35 @@
import { cn } from "@/lib/utils"
import {
getWorkflowNodeIconClass,
getWorkflowNodeMeta,
type WorkflowNodeTone,
} from "./workflow-node-meta"
export function WorkflowNodeIcon({
type,
tone,
size = "md",
className,
}: {
type: string
tone?: WorkflowNodeTone
size?: "sm" | "md"
className?: string
}) {
const meta = getWorkflowNodeMeta(type)
const Icon = meta.icon
const resolvedTone = tone ?? meta.tone
return (
<span
className={cn(
"flex shrink-0 items-center justify-center rounded-lg shadow-sm",
size === "md" ? "size-7" : "size-6",
getWorkflowNodeIconClass(resolvedTone),
className
)}
>
<Icon className={size === "md" ? "size-4" : "size-3.5"} />
</span>
)
}
@@ -0,0 +1,83 @@
import {
BookOpenIcon,
BotIcon,
ClipboardListIcon,
FileTextIcon,
FlagIcon,
GitBranchIcon,
HeadphonesIcon,
HelpCircleIcon,
MessageCircleIcon,
PlayCircleIcon,
SearchIcon,
SendIcon,
ShieldCheckIcon,
TicketIcon,
UserCheckIcon,
type LucideIcon,
} from "lucide-react"
export type WorkflowNodeTone =
| "blue"
| "cyan"
| "emerald"
| "indigo"
| "amber"
| "violet"
| "rose"
| "slate"
export type WorkflowNodeMeta = {
icon: LucideIcon
tone: WorkflowNodeTone
label: string
}
const workflowNodeMetaByType: Record<string, WorkflowNodeMeta> = {
start: { icon: PlayCircleIcon, tone: "blue", label: "入口" },
conversation_understanding: { icon: MessageCircleIcon, tone: "indigo", label: "理解" },
reply_policy: { icon: ShieldCheckIcon, tone: "violet", label: "策略" },
knowledge_retrieve: { icon: BookOpenIcon, tone: "emerald", label: "知识" },
answerability_gate: { icon: HelpCircleIcon, tone: "cyan", label: "判断" },
llm_reply: { icon: BotIcon, tone: "indigo", label: "生成" },
condition: { icon: GitBranchIcon, tone: "cyan", label: "分支" },
analyze_conversation: { icon: SearchIcon, tone: "blue", label: "分析" },
prepare_ticket_draft: { icon: ClipboardListIcon, tone: "amber", label: "工单" },
human_confirm: { icon: UserCheckIcon, tone: "amber", label: "确认" },
create_ticket: { icon: TicketIcon, tone: "rose", label: "工单" },
handoff_to_human: { icon: HeadphonesIcon, tone: "amber", label: "人工" },
send_reply: { icon: SendIcon, tone: "emerald", label: "发送" },
end: { icon: FlagIcon, tone: "slate", label: "结束" },
}
export function getWorkflowNodeMeta(type: string): WorkflowNodeMeta {
return workflowNodeMetaByType[type] ?? { icon: FileTextIcon, tone: "slate", label: "节点" }
}
export function getWorkflowNodeAccentClass(tone: WorkflowNodeTone) {
const classes: Record<WorkflowNodeTone, string> = {
blue: "border-blue-200/80 bg-blue-50 text-blue-700 dark:border-blue-900/60 dark:bg-blue-950/50 dark:text-blue-300",
cyan: "border-cyan-200/80 bg-cyan-50 text-cyan-700 dark:border-cyan-900/60 dark:bg-cyan-950/50 dark:text-cyan-300",
emerald: "border-emerald-200/80 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/50 dark:text-emerald-300",
indigo: "border-indigo-200/80 bg-indigo-50 text-indigo-700 dark:border-indigo-900/60 dark:bg-indigo-950/50 dark:text-indigo-300",
amber: "border-amber-200/80 bg-amber-50 text-amber-700 dark:border-amber-900/60 dark:bg-amber-950/50 dark:text-amber-300",
violet: "border-violet-200/80 bg-violet-50 text-violet-700 dark:border-violet-900/60 dark:bg-violet-950/50 dark:text-violet-300",
rose: "border-rose-200/80 bg-rose-50 text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/50 dark:text-rose-300",
slate: "border-slate-200/80 bg-slate-50 text-slate-700 dark:border-slate-800 dark:bg-slate-900/60 dark:text-slate-300",
}
return classes[tone]
}
export function getWorkflowNodeIconClass(tone: WorkflowNodeTone) {
const classes: Record<WorkflowNodeTone, string> = {
blue: "bg-blue-500 text-white shadow-blue-500/20",
cyan: "bg-cyan-500 text-white shadow-cyan-500/20",
emerald: "bg-emerald-500 text-white shadow-emerald-500/20",
indigo: "bg-indigo-500 text-white shadow-indigo-500/20",
amber: "bg-amber-500 text-white shadow-amber-500/20",
violet: "bg-violet-500 text-white shadow-violet-500/20",
rose: "bg-rose-500 text-white shadow-rose-500/20",
slate: "bg-slate-600 text-white shadow-slate-600/20",
}
return classes[tone]
}
@@ -1,8 +1,7 @@
"use client"
import { PlusIcon } from "lucide-react"
import type { AIWorkflowNodeSpec } from "@/lib/api/admin"
import { WorkflowNodeIcon } from "./workflow-node-icon"
export function WorkflowNodePalette({
nodeSpecs,
@@ -22,10 +21,10 @@ export function WorkflowNodePalette({
<button
key={spec.type}
type="button"
className="flex w-full items-start gap-2 rounded-sm px-2 py-2 text-left text-sm hover:bg-muted"
className="flex w-full items-start gap-2 rounded-md px-2 py-2 text-left text-sm transition-colors hover:bg-muted"
onClick={() => onAddNode(spec)}
>
<PlusIcon className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
<WorkflowNodeIcon type={spec.type} size="sm" className="mt-0.5" />
<span className="min-w-0">
<span className="block truncate font-medium">{spec.title || spec.type}</span>
<span className="line-clamp-2 text-xs text-muted-foreground">{spec.description}</span>
@@ -0,0 +1,42 @@
import type { AIWorkflowNodeSpec } from "@/lib/api/admin"
export function WorkflowSimpleNodeContent({
spec,
}: {
spec?: AIWorkflowNodeSpec
}) {
const inputCount = spec?.inputSchema?.length ?? 0
const outputCount = spec?.outputSchema?.length ?? 0
const hasSchema = inputCount > 0 || outputCount > 0
if (!hasSchema && !spec?.riskLevel && !spec?.interruptible) {
return (
<div className="text-xs leading-5 text-muted-foreground">
</div>
)
}
return (
<div className="flex flex-wrap items-center gap-1.5">
{inputCount > 0 ? <NodeInfoChip label={`入参 ${inputCount}`} /> : null}
{outputCount > 0 ? <NodeInfoChip label={`出参 ${outputCount}`} /> : null}
{spec?.riskLevel ? <NodeInfoChip label={riskLevelLabel(spec.riskLevel)} /> : null}
{spec?.interruptible ? <NodeInfoChip label="可中断" /> : null}
</div>
)
}
function NodeInfoChip({ label }: { label: string }) {
return (
<span className="inline-flex h-5 items-center rounded-md border bg-background px-1.5 text-[11px] leading-none text-muted-foreground">
{label}
</span>
)
}
function riskLevelLabel(riskLevel: AIWorkflowNodeSpec["riskLevel"]) {
if (riskLevel === "high") return "高风险"
if (riskLevel === "medium") return "中风险"
return "低风险"
}