feat: enhance workflow node components with new condition handling and UI improvements
This commit is contained in:
@@ -1,22 +1,31 @@
|
|||||||
import { Field, type WorkflowNodeRegistry, useNodeRender } from "@flowgram.ai/free-layout-editor"
|
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 type { AIWorkflowNodeSpec } from "@/lib/api/admin"
|
||||||
import {
|
import { WorkflowConditionNodeContent } from "./workflow-condition-node-content"
|
||||||
createConditionBranchID,
|
import { WorkflowNodeCard } from "./workflow-node-card"
|
||||||
normalizeNodeConfig,
|
import { WorkflowSimpleNodeContent } from "./workflow-simple-node-content"
|
||||||
type WorkflowConditionBranch,
|
|
||||||
} from "./workflow-utils"
|
|
||||||
import { useWorkflowBranchSelection } from "./workflow-branch-selection"
|
|
||||||
|
|
||||||
export function buildFlowgramNodeRegistries(nodeSpecs: AIWorkflowNodeSpec[]): WorkflowNodeRegistry[] {
|
export function buildFlowgramNodeRegistries(nodeSpecs: AIWorkflowNodeSpec[]): WorkflowNodeRegistry[] {
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const specs = nodeSpecs.length > 0
|
const specs = nodeSpecs.length > 0
|
||||||
? nodeSpecs
|
? 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
|
return specs
|
||||||
@@ -41,6 +50,7 @@ export function buildFlowgramNodeRegistries(nodeSpecs: AIWorkflowNodeSpec[]): Wo
|
|||||||
<FlowgramNodeForm
|
<FlowgramNodeForm
|
||||||
nodeType={spec.type}
|
nodeType={spec.type}
|
||||||
fallbackTitle={spec.title || spec.type}
|
fallbackTitle={spec.title || spec.type}
|
||||||
|
spec={spec}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -50,33 +60,41 @@ export function buildFlowgramNodeRegistries(nodeSpecs: AIWorkflowNodeSpec[]): Wo
|
|||||||
function FlowgramNodeForm({
|
function FlowgramNodeForm({
|
||||||
nodeType,
|
nodeType,
|
||||||
fallbackTitle,
|
fallbackTitle,
|
||||||
|
spec,
|
||||||
}: {
|
}: {
|
||||||
nodeType: string
|
nodeType: string
|
||||||
fallbackTitle: string
|
fallbackTitle: string
|
||||||
|
spec?: AIWorkflowNodeSpec
|
||||||
}) {
|
}) {
|
||||||
const { node } = useNodeRender()
|
const { node, selected } = useNodeRender()
|
||||||
const nodeId = String(node.id ?? "")
|
const nodeId = String(node.id ?? "")
|
||||||
|
|
||||||
if (nodeType === "condition") {
|
|
||||||
return (
|
return (
|
||||||
<ConditionNodeForm
|
|
||||||
fallbackTitle={fallbackTitle}
|
|
||||||
nodeId={nodeId}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex w-full flex-col">
|
|
||||||
<Field<string> name="title">
|
<Field<string> name="title">
|
||||||
{({ field }) => (
|
{({ field }) => (
|
||||||
<div className="border-b px-3 py-2.5 text-sm font-medium leading-5">
|
<WorkflowNodeCard
|
||||||
{field.value || fallbackTitle}
|
nodeType={nodeType}
|
||||||
</div>
|
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>
|
</Field>
|
||||||
<div className="px-3 py-2 text-xs text-muted-foreground">{nodeType}</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,133 +110,3 @@ function defaultPortsForNodeType(type: string) {
|
|||||||
}
|
}
|
||||||
return [{ type: "input" as const }, { type: "output" as const }]
|
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"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
export function FlowgramNodeRenderer(props: WorkflowNodeProps) {
|
export function FlowgramNodeRenderer(props: WorkflowNodeProps) {
|
||||||
const { selected, node, form } = useNodeRender()
|
const { node, form } = useNodeRender()
|
||||||
const nodeType = String(node.flowNodeType ?? "")
|
const nodeType = String(node.flowNodeType ?? "")
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -24,10 +24,7 @@ export function FlowgramNodeRenderer(props: WorkflowNodeProps) {
|
|||||||
return (
|
return (
|
||||||
<WorkflowNodeRenderer
|
<WorkflowNodeRenderer
|
||||||
node={props.node}
|
node={props.node}
|
||||||
className={cn(
|
className={cn("overflow-visible")}
|
||||||
"w-[320px] overflow-visible rounded-md border bg-background shadow-sm transition-colors",
|
|
||||||
selected ? "border-[var(--g-selection-background)]" : "border-border"
|
|
||||||
)}
|
|
||||||
style={{ padding: 0 }}
|
style={{ padding: 0 }}
|
||||||
portPrimaryColor="var(--g-selection-background)"
|
portPrimaryColor="var(--g-selection-background)"
|
||||||
portSecondaryColor="#c9cdd4"
|
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"
|
"use client"
|
||||||
|
|
||||||
import { PlusIcon } from "lucide-react"
|
|
||||||
|
|
||||||
import type { AIWorkflowNodeSpec } from "@/lib/api/admin"
|
import type { AIWorkflowNodeSpec } from "@/lib/api/admin"
|
||||||
|
import { WorkflowNodeIcon } from "./workflow-node-icon"
|
||||||
|
|
||||||
export function WorkflowNodePalette({
|
export function WorkflowNodePalette({
|
||||||
nodeSpecs,
|
nodeSpecs,
|
||||||
@@ -22,10 +21,10 @@ export function WorkflowNodePalette({
|
|||||||
<button
|
<button
|
||||||
key={spec.type}
|
key={spec.type}
|
||||||
type="button"
|
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)}
|
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="min-w-0">
|
||||||
<span className="block truncate font-medium">{spec.title || spec.type}</span>
|
<span className="block truncate font-medium">{spec.title || spec.type}</span>
|
||||||
<span className="line-clamp-2 text-xs text-muted-foreground">{spec.description}</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 "低风险"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user