diff --git a/src/app/globals.css b/src/app/globals.css index 8e6077f..ac88ff1 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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); + } } diff --git a/src/components/pages/AssistantWorkflowPage.tsx b/src/components/pages/AssistantWorkflowPage.tsx index dc8e9e5..00c3b1b 100644 --- a/src/components/pages/AssistantWorkflowPage.tsx +++ b/src/components/pages/AssistantWorkflowPage.tsx @@ -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; + +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 ( -
-
-
-

{workflowName}

-

- 拖动节点,并从节点连接点拖出连线。 -

+
+
+
+
+ + 工作流助手 + / + {workflowName} +
+

+ {workflowName} +

-
+
+
+ + {saved ? "已保存到本地" : "有未保存更改"} +
+ {onBack ? ( - ) : null} - -
-
- - - - - +
+ + +
+
+
+ + { + onNodesChange(changes); + setSaved(false); + }} + onEdgesChange={(changes) => { + onEdgesChange(changes); + setSaved(false); + }} + onConnect={handleConnect} + defaultEdgeOptions={defaultEdgeOptions} + fitView + minZoom={0.45} + maxZoom={1.6} + proOptions={{ hideAttribution: true }} + > + + + + +
+ 选中节点后按 Backspace 删除 · 滚轮缩放 · 拖动画布移动 +
+
); } + +function WorkflowNodeCard({ data, selected }: NodeProps) { + const meta = nodeMeta[data.kind]; + const Icon = meta.icon; + const hasTarget = data.kind !== "start"; + const hasSource = data.kind !== "end"; + + return ( +
+ {hasTarget ? ( + + ) : null} + +
+ +
+
+ +
+ +
+
{meta.caption}
+
+ {data.label} +
+
+ + +
+ +

+ {data.description} +

+ + {hasSource ? ( + + ) : null} +
+ ); +}