Enhance AssistantWorkflowPage with workflow node management and styling

Updated the AssistantWorkflowPage to include a comprehensive node catalog for workflow management, allowing users to add and connect various node types. Introduced new styles for the workflow editor in globals.css to improve UI consistency and visual appeal. Enhanced state management for node connections and saving workflows, providing a more intuitive user experience.
This commit is contained in:
Xin Wang
2026-06-09 10:50:15 +08:00
parent f7fd2bb53e
commit 1772c41f18
2 changed files with 410 additions and 70 deletions

View File

@@ -232,4 +232,57 @@
letter-spacing: 0.08em;
text-transform: uppercase;
}
.workflow-editor {
--xy-controls-button-background-color: var(--card);
--xy-controls-button-background-color-hover: var(--surface-strong);
--xy-controls-button-color: var(--foreground);
--xy-controls-button-color-hover: var(--foreground);
--xy-controls-button-border-color: var(--hairline);
--xy-controls-box-shadow: none;
}
.workflow-editor .react-flow__controls {
overflow: hidden;
border: 1px solid var(--hairline);
border-radius: 14px;
background-color: var(--card);
box-shadow: 0 8px 24px color-mix(in srgb, var(--foreground) 8%, transparent);
backdrop-filter: blur(12px);
}
.workflow-editor .react-flow__controls-button {
border-bottom-color: var(--hairline);
background-color: var(--card);
color: var(--foreground);
}
.workflow-editor .react-flow__controls-button:hover {
background-color: var(--surface-strong);
}
.workflow-editor .react-flow__controls-button svg {
fill: currentColor;
color: currentColor;
}
.workflow-editor .react-flow__edge-path {
stroke: var(--muted-soft);
}
.workflow-editor .react-flow__edge.selected .react-flow__edge-path {
stroke: var(--foreground);
}
.workflow-editor .workflow-handle {
width: 11px;
height: 11px;
border: 2px solid var(--card);
background: var(--muted-soft);
box-shadow: 0 0 0 1px var(--hairline-strong);
}
.workflow-editor .workflow-handle:hover {
background: var(--foreground);
}
}

View File

@@ -1,55 +1,160 @@
"use client";
import { useCallback } from "react";
import { useCallback, useMemo, useState, type ComponentType } from "react";
import {
addEdge,
Background,
BackgroundVariant,
Connection,
Controls,
Edge,
MiniMap,
Handle,
Node,
NodeProps,
Position,
ReactFlow,
useEdgesState,
useNodesState,
} from "@xyflow/react";
import { ChevronLeft, Plus, Save } from "lucide-react";
import {
Bot,
Brain,
ChevronLeft,
CircleStop,
Database,
GitBranch,
MessageSquareText,
Save,
Sparkles,
Wrench,
} from "lucide-react";
import { Button } from "@/components/ui/button";
const initialNodes: Node[] = [
type WorkflowNodeKind = "start" | "intent" | "knowledge" | "answer" | "tool" | "end";
type WorkflowNodeData = {
label: string;
description: string;
kind: WorkflowNodeKind;
};
type WorkflowNode = Node<WorkflowNodeData>;
type NodeCatalogItem = {
kind: WorkflowNodeKind;
label: string;
description: string;
icon: ComponentType<{ size?: number; className?: string }>;
};
const nodeCatalog: NodeCatalogItem[] = [
{
kind: "intent",
label: "意图识别",
description: "识别用户目标并进入对应分支",
icon: GitBranch,
},
{
kind: "knowledge",
label: "知识库检索",
description: "从已配置的知识库获取上下文",
icon: Database,
},
{
kind: "answer",
label: "模型回答",
description: "调用大模型生成回复内容",
icon: Brain,
},
{
kind: "tool",
label: "工具调用",
description: "调用外部工具完成具体任务",
icon: Wrench,
},
];
const nodeMeta: Record<
WorkflowNodeKind,
{
icon: ComponentType<{ size?: number; className?: string }>;
caption: string;
accent: string;
}
> = {
start: {
icon: Sparkles,
caption: "入口节点",
accent: "var(--gradient-mint)",
},
intent: {
icon: GitBranch,
caption: "判断节点",
accent: "var(--gradient-lavender)",
},
knowledge: {
icon: Database,
caption: "资源节点",
accent: "var(--gradient-sky)",
},
answer: {
icon: Brain,
caption: "生成节点",
accent: "var(--gradient-peach)",
},
tool: {
icon: Wrench,
caption: "执行节点",
accent: "var(--gradient-rose)",
},
end: {
icon: CircleStop,
caption: "结束节点",
accent: "var(--muted-soft)",
},
};
const initialNodes: WorkflowNode[] = [
{
id: "start",
type: "input",
position: { x: 80, y: 180 },
data: { label: "开始" },
type: "workflow",
position: { x: 50, y: 190 },
data: {
label: "开始",
description: "接收用户消息并启动工作流",
kind: "start",
},
},
{
id: "answer",
position: { x: 340, y: 180 },
data: { label: "模型回答" },
type: "workflow",
position: { x: 390, y: 190 },
data: {
label: "模型回答",
description: "根据上下文生成自然语言回复",
kind: "answer",
},
},
{
id: "end",
type: "output",
position: { x: 600, y: 180 },
data: { label: "结束" },
type: "workflow",
position: { x: 730, y: 190 },
data: {
label: "结束",
description: "完成当前对话流程",
kind: "end",
},
},
];
const initialEdges: Edge[] = [
{
id: "start-answer",
source: "start",
target: "answer",
},
{
id: "answer-end",
source: "answer",
target: "end",
},
{ id: "start-answer", source: "start", target: "answer" },
{ id: "answer-end", source: "answer", target: "end" },
];
const nodeTypes = { workflow: WorkflowNodeCard };
type AssistantWorkflowPageProps = {
workflowName?: string;
onBack?: () => void;
@@ -61,63 +166,101 @@ export function AssistantWorkflowPage({
}: AssistantWorkflowPageProps = {}) {
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const [saved, setSaved] = useState(false);
const handleConnect = useCallback(
(connection: Connection) => {
setEdges((currentEdges) => addEdge(connection, currentEdges));
setEdges((currentEdges) =>
addEdge(
{
...connection,
type: "smoothstep",
animated: true,
},
currentEdges,
),
);
setSaved(false);
},
[setEdges],
);
const handleAddNode = () => {
const id = crypto.randomUUID();
const handleAddNode = useCallback(
(item: NodeCatalogItem = nodeCatalog[2]) => {
const id = crypto.randomUUID();
setNodes((currentNodes) => [
...currentNodes,
{
id,
position: {
x: 200 + currentNodes.length * 40,
y: 100 + currentNodes.length * 40,
setNodes((currentNodes) => [
...currentNodes,
{
id,
type: "workflow",
position: {
x: 250 + (currentNodes.length % 3) * 300,
y: 90 + Math.floor(currentNodes.length / 3) * 210,
},
data: {
label: item.label,
description: item.description,
kind: item.kind,
},
},
data: {
label: `新节点 ${currentNodes.length + 1}`,
},
},
]);
};
]);
setSaved(false);
},
[setNodes],
);
const handleSave = () => {
const workflow = { nodes, edges };
localStorage.setItem("assistant-workflow", JSON.stringify(workflow));
console.log("Saved workflow:", workflow);
localStorage.setItem(
"assistant-workflow",
JSON.stringify({ nodes, edges }),
);
setSaved(true);
};
const defaultEdgeOptions = useMemo(
() => ({
type: "smoothstep",
animated: true,
style: { stroke: "var(--muted-soft)", strokeWidth: 1.5 },
}),
[],
);
return (
<div className="flex h-[calc(100vh-160px)] min-h-[600px] flex-col gap-4">
<header className="flex items-center justify-between">
<div>
<h1 className="font-display display-lg text-ink">{workflowName}</h1>
<p className="mt-1 text-sm text-muted-foreground">
线
</p>
<div className="-mt-6 flex h-[calc(100vh-112px)] min-h-[640px] flex-col gap-4">
<header className="flex shrink-0 flex-col gap-4 border-b border-hairline pb-4 pt-1 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Bot size={14} />
<span></span>
<span className="text-muted-soft">/</span>
<span className="truncate text-foreground">{workflowName}</span>
</div>
<h1 className="font-display display-md mt-1 truncate text-ink">
{workflowName}
</h1>
</div>
<div className="flex gap-2">
<div className="flex flex-wrap items-center gap-2">
<div className="mr-1 flex items-center gap-2 text-xs text-muted-foreground">
<span
className={`h-2 w-2 rounded-full ${saved ? "bg-success" : "bg-muted-soft"}`}
/>
{saved ? "已保存到本地" : "有未保存更改"}
</div>
{onBack ? (
<Button variant="outline" onClick={onBack}>
<Button
variant="outline"
className="border-hairline-strong text-muted-foreground hover:text-foreground"
onClick={onBack}
>
<ChevronLeft size={16} />
</Button>
) : null}
<Button variant="outline" onClick={handleAddNode}>
<Plus size={16} />
</Button>
<Button onClick={handleSave}>
<Save size={16} />
@@ -125,20 +268,164 @@ export function AssistantWorkflowPage({
</div>
</header>
<div className="min-h-0 flex-1 overflow-hidden rounded-2xl border border-hairline bg-card">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={handleConnect}
fitView
>
<Background />
<Controls />
<MiniMap />
</ReactFlow>
<div className="flex min-h-0 flex-1 gap-4">
<aside className="hidden w-[250px] shrink-0 flex-col overflow-hidden rounded-2xl border border-hairline bg-card shadow-sm lg:flex">
<div className="border-b border-hairline px-5 py-4">
<div className="font-medium text-foreground"></div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
</p>
</div>
<div className="flex-1 space-y-2 overflow-y-auto p-3">
{nodeCatalog.map((item) => {
const Icon = item.icon;
return (
<button
key={item.kind}
type="button"
onClick={() => handleAddNode(item)}
className="group flex w-full items-start gap-3 rounded-xl border border-transparent px-3 py-3 text-left transition-colors hover:border-hairline hover:bg-canvas-soft"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground transition-transform group-hover:scale-105">
<Icon size={16} />
</span>
<span className="min-w-0">
<span className="block text-sm font-medium text-foreground">
{item.label}
</span>
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
{item.description}
</span>
</span>
</button>
);
})}
</div>
<div className="border-t border-hairline bg-canvas-soft px-4 py-3 text-xs text-muted-foreground">
{nodes.length} · {edges.length} 线
</div>
</aside>
<section className="workflow-editor relative min-w-0 flex-1 overflow-hidden rounded-2xl border border-hairline bg-canvas-soft shadow-sm">
<div
aria-hidden
className="pointer-events-none absolute -right-24 -top-24 z-0 h-80 w-80 rounded-full opacity-30 blur-3xl"
style={{
background:
"radial-gradient(circle, var(--gradient-sky), transparent 68%)",
}}
/>
<div
aria-hidden
className="pointer-events-none absolute -bottom-28 left-1/4 z-0 h-72 w-72 rounded-full opacity-25 blur-3xl"
style={{
background:
"radial-gradient(circle, var(--gradient-lavender), transparent 68%)",
}}
/>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={(changes) => {
onNodesChange(changes);
setSaved(false);
}}
onEdgesChange={(changes) => {
onEdgesChange(changes);
setSaved(false);
}}
onConnect={handleConnect}
defaultEdgeOptions={defaultEdgeOptions}
fitView
minZoom={0.45}
maxZoom={1.6}
proOptions={{ hideAttribution: true }}
>
<Background
variant={BackgroundVariant.Dots}
gap={22}
size={1}
color="var(--hairline-strong)"
/>
<Controls showInteractive={false} />
</ReactFlow>
<div className="pointer-events-none absolute bottom-4 left-1/2 z-10 -translate-x-1/2 rounded-full border border-hairline bg-card/90 px-4 py-2 text-xs text-muted-foreground shadow-sm backdrop-blur">
Backspace · ·
</div>
</section>
</div>
</div>
);
}
function WorkflowNodeCard({ data, selected }: NodeProps<WorkflowNode>) {
const meta = nodeMeta[data.kind];
const Icon = meta.icon;
const hasTarget = data.kind !== "start";
const hasSource = data.kind !== "end";
return (
<div
className={[
"relative w-[250px] rounded-2xl border bg-card p-4 text-card-foreground shadow-sm transition-[border-color,box-shadow,transform]",
selected
? "border-primary shadow-[0_12px_34px_color-mix(in_srgb,var(--primary)_16%,transparent)]"
: "border-hairline hover:border-hairline-strong hover:shadow-md",
].join(" ")}
>
{hasTarget ? (
<Handle
type="target"
position={Position.Left}
className="workflow-handle"
/>
) : null}
<div
aria-hidden
className="absolute left-5 right-5 top-0 h-px"
style={{
background: `linear-gradient(90deg, transparent, ${meta.accent}, transparent)`,
}}
/>
<div className="flex items-start gap-3">
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-foreground"
style={{
background: `color-mix(in srgb, ${meta.accent} 28%, var(--surface-strong))`,
}}
>
<Icon size={17} />
</div>
<div className="min-w-0 flex-1">
<div className="caption-label text-muted-soft">{meta.caption}</div>
<div className="mt-1 truncate text-sm font-medium text-foreground">
{data.label}
</div>
</div>
<MessageSquareText size={15} className="mt-1 text-muted-soft" />
</div>
<p className="mt-4 border-t border-hairline-soft pt-3 text-xs leading-5 text-muted-foreground">
{data.description}
</p>
{hasSource ? (
<Handle
type="source"
position={Position.Right}
className="workflow-handle"
/>
) : null}
</div>
);
}