diff --git a/src/App.css b/src/App.css index 7de163d..f5e2d49 100644 --- a/src/App.css +++ b/src/App.css @@ -45,10 +45,14 @@ .app-header-right { display: flex; + align-items: center; gap: 8px; } .header-btn { + display: inline-flex; + align-items: center; + gap: 5px; padding: 8px 16px; border-radius: 8px; font-size: 13px; diff --git a/src/App.tsx b/src/App.tsx index 7e682df..c8a5d18 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,23 @@ import { ReactFlowProvider } from '@xyflow/react'; import WorkflowCanvas from './components/WorkflowCanvas'; +import { useWorkflowStore } from './store/workflowStore'; import './App.css'; function App() { + const { resetToExample, clearCanvas } = useWorkflowStore(); + + const handleReset = () => { + if (confirm('確定要重設為範例流程嗎?目前的畫布內容將會被覆蓋。')) { + resetToExample(); + } + }; + + const handleClear = () => { + if (confirm('確定要清空畫布嗎?將只保留開始節點。')) { + clearCanvas(); + } + }; + return (
@@ -11,7 +26,8 @@ function App() {

Workflow Builder

- + +
diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 758fd8b..3d12ad1 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -47,9 +47,20 @@ interface WorkflowState { redo: () => void; canUndo: () => boolean; canRedo: () => boolean; + resetToExample: () => void; + clearCanvas: () => void; } -const initialNodes: Node[] = [ +const STORAGE_KEY = 'workflow-canvas'; + +const defaultEdgeStyle = { + type: 'smoothstep' as const, + animated: true, + markerEnd: { type: MarkerType.ArrowClosed, color: '#94a3b8' }, + style: { stroke: '#94a3b8', strokeWidth: 2 }, +}; + +const startOnlyNodes: Node[] = [ { id: 'start-1', type: 'startNode', @@ -64,7 +75,138 @@ const initialNodes: Node[] = [ }, ]; -const initialEdges: Edge[] = []; +const exampleNodes: Node[] = [ + { + id: 'start-1', + type: 'startNode', + position: { x: 300, y: 50 }, + data: { + label: '開始', + type: 'start', + icon: 'Play', + color: '#2563eb', + config: { input_variables: ['user_query'] }, + }, + }, + { + id: 'knowledge-1', + type: 'knowledgeNode', + position: { x: 80, y: 200 }, + data: { + label: '知識庫', + type: 'knowledge', + icon: 'Database', + color: '#0891b2', + description: '從知識庫中檢索資料', + config: { knowledge_base: '產品文件庫', top_k: 3, score_threshold: 0.5 }, + }, + }, + { + id: 'template-1', + type: 'templateNode', + position: { x: 480, y: 200 }, + data: { + label: '模板轉換', + type: 'template', + icon: 'FileText', + color: '#ca8a04', + description: '使用模板轉換資料', + config: { template: '根據以下資料回答使用者問題:\n\n{{context}}\n\n使用者問題:{{user_query}}' }, + }, + }, + { + id: 'llm-1', + type: 'llmNode', + position: { x: 280, y: 380 }, + data: { + label: 'LLM', + type: 'llm', + icon: 'MessageSquare', + color: '#7c3aed', + description: '呼叫大型語言模型', + config: { model: 'gpt-4', temperature: 0.7, system_prompt: '你是一個專業的客服助手', max_tokens: 2048 }, + }, + }, + { + id: 'ifelse-1', + type: 'ifElseNode', + position: { x: 280, y: 550 }, + data: { + label: '條件判斷', + type: 'ifElse', + icon: 'GitBranch', + color: '#16a34a', + description: 'IF/ELSE 條件分支', + config: { conditions: [{ variable: 'confidence', operator: 'gte', value: '0.8' }] }, + }, + }, + { + id: 'end-1', + type: 'endNode', + position: { x: 120, y: 720 }, + data: { + label: '結束(直接回覆)', + type: 'end', + icon: 'Square', + color: '#dc2626', + description: '工作流程的結束節點', + config: { output_type: 'text' }, + }, + }, + { + id: 'http-1', + type: 'httpNode', + position: { x: 440, y: 720 }, + data: { + label: 'HTTP 請求', + type: 'http', + icon: 'Globe', + color: '#0d9488', + description: '發送 HTTP 請求到外部 API', + config: { method: 'POST', url: 'https://api.example.com/escalate', headers: {}, body: '' }, + }, + }, +]; + +const exampleEdges: Edge[] = [ + { id: 'e-start-knowledge', source: 'start-1', target: 'knowledge-1', ...defaultEdgeStyle }, + { id: 'e-start-template', source: 'start-1', target: 'template-1', ...defaultEdgeStyle }, + { id: 'e-knowledge-llm', source: 'knowledge-1', target: 'llm-1', ...defaultEdgeStyle }, + { id: 'e-template-llm', source: 'template-1', target: 'llm-1', ...defaultEdgeStyle }, + { id: 'e-llm-ifelse', source: 'llm-1', target: 'ifelse-1', ...defaultEdgeStyle }, + { id: 'e-ifelse-end', source: 'ifelse-1', target: 'end-1', sourceHandle: 'true', ...defaultEdgeStyle }, + { id: 'e-ifelse-http', source: 'ifelse-1', target: 'http-1', sourceHandle: 'false', ...defaultEdgeStyle }, +]; + +function saveToStorage(nodes: Node[], edges: Edge[]) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ nodes, edges })); + } catch { + // ignore quota errors + } +} + +function loadFromStorage(): { nodes: Node[]; edges: Edge[] } | null { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const data = JSON.parse(raw); + if (Array.isArray(data.nodes) && Array.isArray(data.edges)) { + return data; + } + } catch { + // ignore parse errors + } + return null; +} + +function getInitialState(): { nodes: Node[]; edges: Edge[] } { + const saved = loadFromStorage(); + if (saved) return saved; + return { nodes: exampleNodes, edges: exampleEdges }; +} + +const initialState = getInitialState(); export const useWorkflowStore = create((set, get) => { const pushHistory = () => { @@ -75,8 +217,8 @@ export const useWorkflowStore = create((set, get) => { }; return { - nodes: initialNodes, - edges: initialEdges, + nodes: initialState.nodes, + edges: initialState.edges, selectedNodeId: null, execution: null, mobileSidebarOpen: false, @@ -337,5 +479,36 @@ export const useWorkflowStore = create((set, get) => { }, setMobileSidebarOpen: (open) => set({ mobileSidebarOpen: open }), + + resetToExample: () => { + set({ + nodes: exampleNodes, + edges: exampleEdges, + selectedNodeId: null, + execution: null, + past: [], + future: [], + }); + saveToStorage(exampleNodes, exampleEdges); + }, + + clearCanvas: () => { + set({ + nodes: startOnlyNodes, + edges: [], + selectedNodeId: null, + execution: null, + past: [], + future: [], + }); + saveToStorage(startOnlyNodes, []); + }, }; }); + +// Auto-save to localStorage on nodes/edges changes +useWorkflowStore.subscribe((state, prevState) => { + if (state.nodes !== prevState.nodes || state.edges !== prevState.edges) { + saveToStorage(state.nodes, state.edges); + } +});