From f809b5e52662217aa361547775ec63d991a03578 Mon Sep 17 00:00:00 2001 From: mlogclub Date: Sun, 28 Jun 2026 11:52:32 +0800 Subject: [PATCH] feat: enhance workflow node components with new condition handling and UI improvements --- .../_components/flowgram-node-registries.tsx | 208 ++++-------------- .../_components/flowgram-node-renderer.tsx | 7 +- .../workflow-condition-node-content.tsx | 153 +++++++++++++ .../_components/workflow-node-card.tsx | 66 ++++++ .../_components/workflow-node-icon.tsx | 35 +++ .../_components/workflow-node-meta.ts | 83 +++++++ .../_components/workflow-node-palette.tsx | 7 +- .../workflow-simple-node-content.tsx | 42 ++++ 8 files changed, 432 insertions(+), 169 deletions(-) create mode 100644 web/app/dashboard/ai-workflows/_components/workflow-condition-node-content.tsx create mode 100644 web/app/dashboard/ai-workflows/_components/workflow-node-card.tsx create mode 100644 web/app/dashboard/ai-workflows/_components/workflow-node-icon.tsx create mode 100644 web/app/dashboard/ai-workflows/_components/workflow-node-meta.ts create mode 100644 web/app/dashboard/ai-workflows/_components/workflow-simple-node-content.tsx diff --git a/web/app/dashboard/ai-workflows/_components/flowgram-node-registries.tsx b/web/app/dashboard/ai-workflows/_components/flowgram-node-registries.tsx index 3d704bf..5b13bef 100644 --- a/web/app/dashboard/ai-workflows/_components/flowgram-node-registries.tsx +++ b/web/app/dashboard/ai-workflows/_components/flowgram-node-registries.tsx @@ -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() 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 ), }, @@ -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 ( - - ) - } - return ( -
- name="title"> - {({ field }) => ( -
- {field.value || fallbackTitle} -
- )} - -
{nodeType}
-
+ name="title"> + {({ field }) => ( + + {nodeType === "condition" ? ( + > name="config"> + {({ field: configField }) => ( + + )} + + ) : ( + + )} + + )} + ) } @@ -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 ( -
- name="title"> - {({ field }) => ( -
- {field.value || fallbackTitle} -
- )} - - > 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 ( -
-
- {branches.map((branch, index) => ( -
{ - event.stopPropagation() - onSelectBranch?.({ nodeId, branchId: branch.id }) - }} - onMouseDownCapture={(event) => { - event.stopPropagation() - }} - onClick={(event) => { - event.stopPropagation() - }} - > - - {branch.name || (branch.default ? "默认分支" : branch.id)} - - - {branch.default ? "else" : index === 0 ? "if" : "elseif"} - - {branch.default ? null : ( - - )} - -
- ))} -
- -
- ) - }} - -
- ) -} - -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), - ] -} diff --git a/web/app/dashboard/ai-workflows/_components/flowgram-node-renderer.tsx b/web/app/dashboard/ai-workflows/_components/flowgram-node-renderer.tsx index ea67ff7..a084cab 100644 --- a/web/app/dashboard/ai-workflows/_components/flowgram-node-renderer.tsx +++ b/web/app/dashboard/ai-workflows/_components/flowgram-node-renderer.tsx @@ -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 ( | undefined + nodeId: string + onChange: (value: Record) => 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 ( +
+
+ {branches.map((branch, index) => ( + onSelectBranch?.({ nodeId, branchId: branch.id })} + onDelete={() => deleteBranch(branch.id)} + /> + ))} +
+ +
+ ) +} + +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 ( +
{ + event.stopPropagation() + onSelect() + }} + onMouseDownCapture={(event) => { + event.stopPropagation() + }} + onClick={(event) => { + event.stopPropagation() + }} + > + + {branchType} + + + {branch.name || (branch.default ? "默认分支" : branch.id)} + + {branch.default ? null : ( + + )} + +
+ ) +} + +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), + ] +} diff --git a/web/app/dashboard/ai-workflows/_components/workflow-node-card.tsx b/web/app/dashboard/ai-workflows/_components/workflow-node-card.tsx new file mode 100644 index 0000000..01f1316 --- /dev/null +++ b/web/app/dashboard/ai-workflows/_components/workflow-node-card.tsx @@ -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 ( +
+
+
+ +
+
+ {title} +
+
+ + {meta.label} + + + {nodeType} + +
+
+
+ {spec?.description ? ( +
+ {spec.description} +
+ ) : null} +
{children}
+
+
+ ) +} diff --git a/web/app/dashboard/ai-workflows/_components/workflow-node-icon.tsx b/web/app/dashboard/ai-workflows/_components/workflow-node-icon.tsx new file mode 100644 index 0000000..16aeb6b --- /dev/null +++ b/web/app/dashboard/ai-workflows/_components/workflow-node-icon.tsx @@ -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 ( + + + + ) +} diff --git a/web/app/dashboard/ai-workflows/_components/workflow-node-meta.ts b/web/app/dashboard/ai-workflows/_components/workflow-node-meta.ts new file mode 100644 index 0000000..efe3ab4 --- /dev/null +++ b/web/app/dashboard/ai-workflows/_components/workflow-node-meta.ts @@ -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 = { + 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 = { + 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 = { + 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] +} diff --git a/web/app/dashboard/ai-workflows/_components/workflow-node-palette.tsx b/web/app/dashboard/ai-workflows/_components/workflow-node-palette.tsx index 8c15a4b..575d0e9 100644 --- a/web/app/dashboard/ai-workflows/_components/workflow-node-palette.tsx +++ b/web/app/dashboard/ai-workflows/_components/workflow-node-palette.tsx @@ -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({