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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user