From e9c7c88b9aef7292be823d1e39b19ad09a774bf1 Mon Sep 17 00:00:00 2001 From: Stefan Krawczyk Date: Sun, 1 Mar 2026 08:23:29 -0800 Subject: [PATCH 1/2] feat: add visual Graph Builder tool to Burr UI This takes work from @jaeyow and https://github.com/apache/burr/pull/572. Adds a drag-and-drop graph editor for designing Burr application graphs visually and exporting as Python code or JSON. Key changes: - New /graph-builder route with full visual editor (ReactFlow v12) - Migrate existing GraphView from reactflow v11 to @xyflow/react v12 - Remove reactflow and @tisoap/react-flow-smart-edge dependencies - Per-node async/streaming toggles matching Burr's 4 action variants - Python code generation with correct decorators and signatures - 3 pre-built example graphs (MultiModal Chatbot, CRAG, Streaming) - localStorage auto-save/restore of graph state - Empty-state onboarding overlay and structured help sidebar - Fix appcontainer layout for full-height content --- telemetry/ui/package-lock.json | 363 +---- telemetry/ui/package.json | 3 +- telemetry/ui/src/App.tsx | 2 + .../ui/src/components/nav/appcontainer.tsx | 16 +- .../src/components/routes/app/GraphView.tsx | 45 +- .../components/ConfirmLoadExampleDialog.tsx | 81 + .../graph-builder/components/CustomEdge.tsx | 161 ++ .../graph-builder/components/CustomNode.tsx | 230 +++ .../components/ExampleGallery.tsx | 71 + .../graph-builder/components/GraphBuilder.tsx | 1307 +++++++++++++++++ .../routes/graph-builder/data/examples.ts | 564 +++++++ .../graph-builder/utils/BurrCodeGenerator.ts | 321 ++++ .../graph-builder/utils/ExampleLoader.ts | 93 ++ .../graph-builder/utils/GraphExporter.ts | 83 ++ 14 files changed, 2974 insertions(+), 366 deletions(-) create mode 100644 telemetry/ui/src/components/routes/graph-builder/components/ConfirmLoadExampleDialog.tsx create mode 100644 telemetry/ui/src/components/routes/graph-builder/components/CustomEdge.tsx create mode 100644 telemetry/ui/src/components/routes/graph-builder/components/CustomNode.tsx create mode 100644 telemetry/ui/src/components/routes/graph-builder/components/ExampleGallery.tsx create mode 100644 telemetry/ui/src/components/routes/graph-builder/components/GraphBuilder.tsx create mode 100644 telemetry/ui/src/components/routes/graph-builder/data/examples.ts create mode 100644 telemetry/ui/src/components/routes/graph-builder/utils/BurrCodeGenerator.ts create mode 100644 telemetry/ui/src/components/routes/graph-builder/utils/ExampleLoader.ts create mode 100644 telemetry/ui/src/components/routes/graph-builder/utils/GraphExporter.ts diff --git a/telemetry/ui/package-lock.json b/telemetry/ui/package-lock.json index 21461ed4e..dfd25da4c 100644 --- a/telemetry/ui/package-lock.json +++ b/telemetry/ui/package-lock.json @@ -14,7 +14,6 @@ "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", - "@tisoap/react-flow-smart-edge": "^3.0.0", "@types/fuse": "^2.6.0", "@types/jest": "^27.5.2", "@types/node": "^16.18.82", @@ -23,6 +22,7 @@ "@types/react-select": "^5.0.1", "@types/react-syntax-highlighter": "^15.5.11", "@uiw/react-json-view": "^2.0.0-alpha.12", + "@xyflow/react": "^12.0.0", "clsx": "^2.1.0", "dagre": "^0.8.5", "es-abstract": "^1.22.4", @@ -37,7 +37,6 @@ "react-scripts": "5.0.1", "react-select": "^5.8.1", "react-syntax-highlighter": "^15.5.0", - "reactflow": "^11.10.4", "remark-gfm": "^4.0.0", "string.prototype.matchall": "^4.0.10", "tailwindcss-question-mark": "^0.4.0", @@ -6105,102 +6104,6 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0" } }, - "node_modules/@reactflow/background": { - "version": "11.3.9", - "resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.9.tgz", - "integrity": "sha512-byj/G9pEC8tN0wT/ptcl/LkEP/BBfa33/SvBkqE4XwyofckqF87lKp573qGlisfnsijwAbpDlf81PuFL41So4Q==", - "dependencies": { - "@reactflow/core": "11.10.4", - "classcat": "^5.0.3", - "zustand": "^4.4.1" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, - "node_modules/@reactflow/controls": { - "version": "11.2.9", - "resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.9.tgz", - "integrity": "sha512-e8nWplbYfOn83KN1BrxTXS17+enLyFnjZPbyDgHSRLtI5ZGPKF/8iRXV+VXb2LFVzlu4Wh3la/pkxtfP/0aguA==", - "dependencies": { - "@reactflow/core": "11.10.4", - "classcat": "^5.0.3", - "zustand": "^4.4.1" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, - "node_modules/@reactflow/core": { - "version": "11.10.4", - "resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.10.4.tgz", - "integrity": "sha512-j3i9b2fsTX/sBbOm+RmNzYEFWbNx4jGWGuGooh2r1jQaE2eV+TLJgiG/VNOp0q5mBl9f6g1IXs3Gm86S9JfcGw==", - "dependencies": { - "@types/d3": "^7.4.0", - "@types/d3-drag": "^3.0.1", - "@types/d3-selection": "^3.0.3", - "@types/d3-zoom": "^3.0.1", - "classcat": "^5.0.3", - "d3-drag": "^3.0.0", - "d3-selection": "^3.0.0", - "d3-zoom": "^3.0.0", - "zustand": "^4.4.1" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, - "node_modules/@reactflow/minimap": { - "version": "11.7.9", - "resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.9.tgz", - "integrity": "sha512-le95jyTtt3TEtJ1qa7tZ5hyM4S7gaEQkW43cixcMOZLu33VAdc2aCpJg/fXcRrrf7moN2Mbl9WIMNXUKsp5ILA==", - "dependencies": { - "@reactflow/core": "11.10.4", - "@types/d3-selection": "^3.0.3", - "@types/d3-zoom": "^3.0.1", - "classcat": "^5.0.3", - "d3-selection": "^3.0.0", - "d3-zoom": "^3.0.0", - "zustand": "^4.4.1" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, - "node_modules/@reactflow/node-resizer": { - "version": "2.2.9", - "resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.9.tgz", - "integrity": "sha512-HfickMm0hPDIHt9qH997nLdgLt0kayQyslKE0RS/GZvZ4UMQJlx/NRRyj5y47Qyg0NnC66KYOQWDM9LLzRTnUg==", - "dependencies": { - "@reactflow/core": "11.10.4", - "classcat": "^5.0.4", - "d3-drag": "^3.0.0", - "d3-selection": "^3.0.0", - "zustand": "^4.4.1" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, - "node_modules/@reactflow/node-toolbar": { - "version": "1.3.9", - "resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.9.tgz", - "integrity": "sha512-VmgxKmToax4sX1biZ9LXA7cj/TBJ+E5cklLGwquCCVVxh+lxpZGTBF3a5FJGVHiUNBBtFsC8ldcSZIK4cAlQww==", - "dependencies": { - "@reactflow/core": "11.10.4", - "classcat": "^5.0.3", - "zustand": "^4.4.1" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, "node_modules/@remix-run/router": { "version": "1.15.1", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.15.1.tgz", @@ -7162,24 +7065,6 @@ "@testing-library/dom": ">=7.21.4" } }, - "node_modules/@tisoap/react-flow-smart-edge": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@tisoap/react-flow-smart-edge/-/react-flow-smart-edge-3.0.0.tgz", - "integrity": "sha512-XtEQT0IrOqPwJvCzgEoj3Y16/EK4SOcjZO7FmOPU+qJWmgYjeTyv7J35CGm6dFeJYdZ2gHDrvQ1zwaXuo23/8g==", - "dependencies": { - "pathfinding": "0.4.18" - }, - "engines": { - "node": ">=16", - "npm": "^8.0.0" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17", - "reactflow": ">=11", - "typescript": ">=4.6" - } - }, "node_modules/@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -7278,93 +7163,11 @@ "@types/node": "*" } }, - "node_modules/@types/d3": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", - "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", - "dependencies": { - "@types/d3-array": "*", - "@types/d3-axis": "*", - "@types/d3-brush": "*", - "@types/d3-chord": "*", - "@types/d3-color": "*", - "@types/d3-contour": "*", - "@types/d3-delaunay": "*", - "@types/d3-dispatch": "*", - "@types/d3-drag": "*", - "@types/d3-dsv": "*", - "@types/d3-ease": "*", - "@types/d3-fetch": "*", - "@types/d3-force": "*", - "@types/d3-format": "*", - "@types/d3-geo": "*", - "@types/d3-hierarchy": "*", - "@types/d3-interpolate": "*", - "@types/d3-path": "*", - "@types/d3-polygon": "*", - "@types/d3-quadtree": "*", - "@types/d3-random": "*", - "@types/d3-scale": "*", - "@types/d3-scale-chromatic": "*", - "@types/d3-selection": "*", - "@types/d3-shape": "*", - "@types/d3-time": "*", - "@types/d3-time-format": "*", - "@types/d3-timer": "*", - "@types/d3-transition": "*", - "@types/d3-zoom": "*" - } - }, - "node_modules/@types/d3-array": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", - "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==" - }, - "node_modules/@types/d3-axis": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", - "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", - "dependencies": { - "@types/d3-selection": "*" - } - }, - "node_modules/@types/d3-brush": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", - "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", - "dependencies": { - "@types/d3-selection": "*" - } - }, - "node_modules/@types/d3-chord": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", - "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==" - }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" }, - "node_modules/@types/d3-contour": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", - "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", - "dependencies": { - "@types/d3-array": "*", - "@types/geojson": "*" - } - }, - "node_modules/@types/d3-delaunay": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", - "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==" - }, - "node_modules/@types/d3-dispatch": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.6.tgz", - "integrity": "sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ==" - }, "node_modules/@types/d3-drag": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", @@ -7373,47 +7176,6 @@ "@types/d3-selection": "*" } }, - "node_modules/@types/d3-dsv": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", - "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==" - }, - "node_modules/@types/d3-ease": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", - "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==" - }, - "node_modules/@types/d3-fetch": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", - "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", - "dependencies": { - "@types/d3-dsv": "*" - } - }, - "node_modules/@types/d3-force": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.9.tgz", - "integrity": "sha512-IKtvyFdb4Q0LWna6ymywQsEYjK/94SGhPrMfEr1TIc5OBeziTi+1jcCvttts8e0UWZIxpasjnQk9MNk/3iS+kA==" - }, - "node_modules/@types/d3-format": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", - "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==" - }, - "node_modules/@types/d3-geo": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", - "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", - "dependencies": { - "@types/geojson": "*" - } - }, - "node_modules/@types/d3-hierarchy": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.6.tgz", - "integrity": "sha512-qlmD/8aMk5xGorUvTUWHCiumvgaUXYldYjNVOWtYoTYY/L+WwIEAmJxUmTgr9LoGNG0PPAOmqMDJVDPc7DOpPw==" - }, "node_modules/@types/d3-interpolate": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", @@ -7422,67 +7184,11 @@ "@types/d3-color": "*" } }, - "node_modules/@types/d3-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==" - }, - "node_modules/@types/d3-polygon": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", - "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==" - }, - "node_modules/@types/d3-quadtree": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", - "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==" - }, - "node_modules/@types/d3-random": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", - "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==" - }, - "node_modules/@types/d3-scale": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz", - "integrity": "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==", - "dependencies": { - "@types/d3-time": "*" - } - }, - "node_modules/@types/d3-scale-chromatic": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.0.3.tgz", - "integrity": "sha512-laXM4+1o5ImZv3RpFAsTRn3TEkzqkytiOY0Dz0sq5cnd1dtNlk6sHLon4OvqaiJb28T0S/TdsBI3Sjsy+keJrw==" - }, "node_modules/@types/d3-selection": { "version": "3.0.10", "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.10.tgz", "integrity": "sha512-cuHoUgS/V3hLdjJOLTT691+G2QoqAjCVLmr4kJXR4ha56w1Zdu8UUQ5TxLRqudgNjwXeQxKMq4j+lyf9sWuslg==" }, - "node_modules/@types/d3-shape": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.6.tgz", - "integrity": "sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==", - "dependencies": { - "@types/d3-path": "*" - } - }, - "node_modules/@types/d3-time": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.3.tgz", - "integrity": "sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==" - }, - "node_modules/@types/d3-time-format": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", - "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==" - }, - "node_modules/@types/d3-timer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", - "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" - }, "node_modules/@types/d3-transition": { "version": "3.0.8", "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.8.tgz", @@ -7577,11 +7283,6 @@ "fuse": "*" } }, - "node_modules/@types/geojson": { - "version": "7946.0.14", - "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.14.tgz", - "integrity": "sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==" - }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -8715,6 +8416,38 @@ "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==" }, + "node_modules/@xyflow/react": { + "version": "12.10.1", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.1.tgz", + "integrity": "sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.75", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.75", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.75.tgz", + "integrity": "sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -15058,11 +14791,6 @@ "tslib": "^2.0.3" } }, - "node_modules/heap": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/heap/-/heap-0.2.5.tgz", - "integrity": "sha512-G7HLD+WKcrOyJP5VQwYZNC3Z6FcQ7YYjEFiFoIj8PfEr73mu421o8B1N5DKUcc8K37EsJ2XXWA8DtrDz/2dReg==" - }, "node_modules/heroicons": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/heroicons/-/heroicons-2.1.1.tgz", @@ -21519,14 +21247,6 @@ "node": ">=8" } }, - "node_modules/pathfinding": { - "version": "0.4.18", - "resolved": "https://registry.npmjs.org/pathfinding/-/pathfinding-0.4.18.tgz", - "integrity": "sha512-R0TGEQ9GRcFCDvAWlJAWC+KGJ9SLbW4c0nuZRcioVlXVTlw+F5RvXQ8SQgSqI9KXWC1ew95vgmIiyaWTlCe9Ag==", - "dependencies": { - "heap": "0.2.5" - } - }, "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -23634,23 +23354,6 @@ "react-dom": ">=16.6.0" } }, - "node_modules/reactflow": { - "version": "11.10.4", - "resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.10.4.tgz", - "integrity": "sha512-0CApYhtYicXEDg/x2kvUHiUk26Qur8lAtTtiSlptNKuyEuGti6P1y5cS32YGaUoDMoCqkm/m+jcKkfMOvSCVRA==", - "dependencies": { - "@reactflow/background": "11.3.9", - "@reactflow/controls": "11.2.9", - "@reactflow/core": "11.10.4", - "@reactflow/minimap": "11.7.9", - "@reactflow/node-resizer": "2.2.9", - "@reactflow/node-toolbar": "1.3.9" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", diff --git a/telemetry/ui/package.json b/telemetry/ui/package.json index 1a391fcc0..2145236c5 100644 --- a/telemetry/ui/package.json +++ b/telemetry/ui/package.json @@ -9,7 +9,7 @@ "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", - "@tisoap/react-flow-smart-edge": "^3.0.0", + "@xyflow/react": "^12.0.0", "@types/fuse": "^2.6.0", "@types/jest": "^27.5.2", "@types/node": "^16.18.82", @@ -32,7 +32,6 @@ "react-scripts": "5.0.1", "react-select": "^5.8.1", "react-syntax-highlighter": "^15.5.0", - "reactflow": "^11.10.4", "remark-gfm": "^4.0.0", "string.prototype.matchall": "^4.0.10", "tailwindcss-question-mark": "^0.4.0", diff --git a/telemetry/ui/src/App.tsx b/telemetry/ui/src/App.tsx index 4b80c5bd8..fd0f1445e 100644 --- a/telemetry/ui/src/App.tsx +++ b/telemetry/ui/src/App.tsx @@ -31,6 +31,7 @@ import { StreamingChatbotWithTelemetry } from './examples/StreamingChatbot'; import { AdminView } from './components/routes/AdminView'; import { AnnotationsViewContainer } from './components/routes/app/AnnotationsView'; import { DeepResearcherWithTelemetry } from './examples/DeepResearcher'; +import GraphBuilder from './components/routes/graph-builder/components/GraphBuilder'; /** * Basic application. We have an AppContainer -- this has a breadcrumb and a sidebar. @@ -65,6 +66,7 @@ const App = () => { } /> } /> } /> + } /> } /> diff --git a/telemetry/ui/src/components/nav/appcontainer.tsx b/telemetry/ui/src/components/nav/appcontainer.tsx index edcce3f2c..7277ac556 100644 --- a/telemetry/ui/src/components/nav/appcontainer.tsx +++ b/telemetry/ui/src/components/nav/appcontainer.tsx @@ -22,6 +22,7 @@ import { Dialog, Disclosure, Transition } from '@headlessui/react'; import { ComputerDesktopIcon, Square2StackIcon, + SquaresPlusIcon, QuestionMarkCircleIcon, XMarkIcon, ChatBubbleLeftEllipsisIcon, @@ -100,6 +101,12 @@ export const AppContainer = (props: { children: React.ReactNode }) => { icon: Square2StackIcon, linkType: 'internal' }, + { + name: 'Graph Builder', + href: '/graph-builder', + icon: SquaresPlusIcon, + linkType: 'internal' + }, { name: 'Examples', href: 'https://github.com/DAGWorks-Inc/burr/tree/main/examples', @@ -384,13 +391,12 @@ export const AppContainer = (props: { children: React.ReactNode }) => { - {/* This is a bit hacky -- just quickly prototyping and these margins were the ones that worked! */} -
-
+
+
-
-
{props.children}
+
+
{props.children}
diff --git a/telemetry/ui/src/components/routes/app/GraphView.tsx b/telemetry/ui/src/components/routes/app/GraphView.tsx index 31a5f37e3..baa0b0610 100644 --- a/telemetry/ui/src/components/routes/app/GraphView.tsx +++ b/telemetry/ui/src/components/routes/app/GraphView.tsx @@ -20,8 +20,9 @@ import { ActionModel, ApplicationModel, Step } from '../../../api'; import dagre from 'dagre'; -import React, { createContext, useLayoutEffect, useRef, useState } from 'react'; -import ReactFlow, { +import React, { createContext, useCallback, useLayoutEffect, useRef, useState } from 'react'; +import { + ReactFlow, BaseEdge, Controls, EdgeProps, @@ -30,14 +31,12 @@ import ReactFlow, { Position, ReactFlowProvider, getBezierPath, - useNodes, useReactFlow -} from 'reactflow'; +} from '@xyflow/react'; -import 'reactflow/dist/style.css'; +import '@xyflow/react/dist/style.css'; import { backgroundColorsForIndex } from './AppView'; import { getActionStatus } from '../../../utils'; -import { getSmartEdge } from '@tisoap/react-flow-smart-edge'; const dagreGraph = new dagre.graphlib.Graph(); @@ -149,38 +148,26 @@ export const ActionActionEdge = ({ markerEnd, data }: EdgeProps) => { - const nodes = useNodes(); - data = data as EdgeData; + const edgeData = data as EdgeData | undefined; const { highlightedActions: previousActions, currentAction } = React.useContext(NodeStateProvider); const allActionsInPath = [...(previousActions || []), ...(currentAction ? [currentAction] : [])]; const containsFrom = allActionsInPath.some( - (action) => action.step_start_log.action === data.from + (action) => action.step_start_log.action === edgeData?.from + ); + const containsTo = allActionsInPath.some( + (action) => action.step_start_log.action === edgeData?.to ); - const containsTo = allActionsInPath.some((action) => action.step_start_log.action === data.to); const shouldHighlight = containsFrom && containsTo; - const getSmartEdgeResponse = getSmartEdge({ - sourcePosition, - targetPosition, + + const [edgePath] = getBezierPath({ sourceX, sourceY, + sourcePosition, targetX, targetY, - nodes + targetPosition }); - let edgePath = null; - if (getSmartEdgeResponse !== null) { - edgePath = getSmartEdgeResponse.svgPathString; - } else { - edgePath = getBezierPath({ - sourceX, - sourceY, - sourcePosition, - targetX, - targetY, - targetPosition - })[0]; - } const style = { markerColor: shouldHighlight ? 'black' : 'gray', @@ -188,7 +175,7 @@ export const ActionActionEdge = ({ }; return ( <> - + ); }; @@ -359,7 +346,7 @@ export const _Graph = (props: { void; + onConfirm: () => void; + exampleTitle: string; + hasExistingContent: boolean; +} + +const ConfirmLoadExampleDialog: React.FC = ({ + open, + onClose, + onConfirm, + exampleTitle, + hasExistingContent +}) => { + if (!open) return null; + + return ( +
+
+
+ +

Load Example Graph

+
+ +
+

+ Are you sure you want to load the "{exampleTitle}" example? +

+ + {hasExistingContent && ( +
+

+ This will replace your current graph. Any unsaved changes will be lost. +

+
+ )} + +
+ You can always export your current work as JSON or Python code before loading the + example. +
+
+ +
+ + +
+
+
+ ); +}; + +export default ConfirmLoadExampleDialog; diff --git a/telemetry/ui/src/components/routes/graph-builder/components/CustomEdge.tsx b/telemetry/ui/src/components/routes/graph-builder/components/CustomEdge.tsx new file mode 100644 index 000000000..f6e2447ba --- /dev/null +++ b/telemetry/ui/src/components/routes/graph-builder/components/CustomEdge.tsx @@ -0,0 +1,161 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useState, useCallback } from 'react'; +import { + BaseEdge, + EdgeLabelRenderer, + EdgeProps, + getBezierPath, + MarkerType, + Edge +} from '@xyflow/react'; + +export interface CustomEdgeData extends Record { + condition?: string; + isConditional?: boolean; + label?: string; + onLabelChange?: (edgeId: string, newLabel: string) => void; +} + +type CustomEdgeType = Edge; + +const CustomEdge: React.FC> = ({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + style = {}, + data, + markerEnd, + selected +}) => { + const [isEditing, setIsEditing] = useState(false); + + const isConditional = data?.isConditional || (data?.condition && data.condition !== 'default'); + const displayLabel = isConditional ? data?.label || 'condition' : ''; + const [labelValue, setLabelValue] = useState(displayLabel); + + React.useEffect(() => { + const newDisplayLabel = isConditional ? data?.label || 'condition' : ''; + setLabelValue(newDisplayLabel); + }, [data?.label, data?.condition, isConditional]); + + const [edgePath, labelX, labelY] = getBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition + }); + + const edgeStyle: React.CSSProperties = { + strokeWidth: selected ? 4 : 2, + stroke: + (style as React.CSSProperties)?.stroke || + (data?.condition === 'default' ? '#94a3b8' : '#429dbce6'), + ...(style as React.CSSProperties) + }; + + if (isConditional) { + edgeStyle.strokeDasharray = '8,4'; + edgeStyle.animation = 'dash 2s linear infinite'; + } + + const handleLabelClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + setIsEditing(true); + }, []); + + const handleLabelChange = useCallback((event: React.ChangeEvent) => { + setLabelValue(event.target.value); + }, []); + + const handleLabelBlur = useCallback(() => { + setIsEditing(false); + if (data?.onLabelChange) { + data.onLabelChange(id, labelValue); + } + }, [data, id, labelValue]); + + const handleLabelKeyDown = useCallback( + (event: React.KeyboardEvent) => { + event.stopPropagation(); + + if (event.key === 'Enter') { + handleLabelBlur(); + } else if (event.key === 'Escape') { + setLabelValue(data?.label || data?.condition || ''); + setIsEditing(false); + } + }, + [data?.label, data?.condition, handleLabelBlur] + ); + + return ( + <> + + + + + {isConditional && ( +
+ {isEditing ? ( + e.stopPropagation()} + autoFocus + className="border border-gray-300 rounded-xl px-2 py-1 text-xs bg-white min-w-16 text-center focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + ) : ( + + )} +
+ )} +
+ + ); +}; + +export default CustomEdge; diff --git a/telemetry/ui/src/components/routes/graph-builder/components/CustomNode.tsx b/telemetry/ui/src/components/routes/graph-builder/components/CustomNode.tsx new file mode 100644 index 000000000..c78661108 --- /dev/null +++ b/telemetry/ui/src/components/routes/graph-builder/components/CustomNode.tsx @@ -0,0 +1,230 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { memo, useState, useCallback, useRef, useEffect } from 'react'; +import { Handle, Position, NodeProps, Node } from '@xyflow/react'; + +const pastelColors = [ + { border: '#FF6B6B', background: '#FFE5E5' }, + { border: '#4ECDC4', background: '#E5F9F6' }, + { border: '#45B7D1', background: '#E5F4FD' }, + { border: '#96CEB4', background: '#F0F9F4' }, + { border: '#FFEAA7', background: '#FFFCF0' }, + { border: '#DDA0DD', background: '#F5F0F5' }, + { border: '#98D8C8', background: '#F0FAF7' }, + { border: '#F7DC6F', background: '#FEFBF0' }, + { border: '#BB8FCE', background: '#F4F1F7' }, + { border: '#85C1E9', background: '#F0F8FF' } +]; + +export interface CustomNodeData extends Record { + label: string; + description?: string; + nodeType: string; + isAsync?: boolean; + isStreaming?: boolean; + icon: string; + colorIndex?: number; + onDelete?: (nodeId: string) => void; + onLabelChange?: (nodeId: string, newLabel: string) => void; + onToggleProperty?: (nodeId: string, property: 'isAsync' | 'isStreaming') => void; +} + +type CustomNodeType = Node; + +const CustomNode: React.FC> = ({ id, data, selected }) => { + const [isEditing, setIsEditing] = useState(false); + const [labelValue, setLabelValue] = useState(data.label); + const [fixedWidth, setFixedWidth] = useState(null); + const [fixedHeight, setFixedHeight] = useState(null); + const paperRef = useRef(null); + + useEffect(() => { + setLabelValue(data.label); + }, [data.label]); + + const colorIndex = data.colorIndex ?? parseInt(id.replace(/\D/g, '')) % pastelColors.length; + const colors = pastelColors[colorIndex]; + + const handleLabelClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + if (paperRef.current) { + setFixedWidth(paperRef.current.offsetWidth); + setFixedHeight(paperRef.current.offsetHeight); + } + setIsEditing(true); + }, []); + + const handleLabelChange = useCallback((event: React.ChangeEvent) => { + setLabelValue(event.target.value); + }, []); + + const handleLabelBlur = useCallback(() => { + setIsEditing(false); + setFixedWidth(null); + setFixedHeight(null); + if (data.onLabelChange && labelValue.trim() !== data.label) { + data.onLabelChange(id, labelValue.trim() || data.label); + } + }, [data, id, labelValue]); + + const handleLabelKeyDown = useCallback( + (event: React.KeyboardEvent) => { + event.stopPropagation(); + + if (event.key === 'Enter') { + handleLabelBlur(); + } else if (event.key === 'Escape') { + setLabelValue(data.label); + setIsEditing(false); + setFixedWidth(null); + setFixedHeight(null); + } + }, + [data.label, handleLabelBlur] + ); + + const isInputNode = data.nodeType === 'input'; + + return ( +
+ + +
+ {selected && !isInputNode && ( +
+ + +
+ )} + + {isEditing ? ( + e.stopPropagation()} + autoFocus + className={` + border-none outline-none bg-transparent text-sm font-bold font-inherit + w-full box-border p-0 m-0 leading-normal block + ${isInputNode ? 'text-center text-gray-600' : 'text-left'} + `} + style={{ + color: isInputNode ? '#666' : colors.border, + marginBottom: data.description ? '8px' : 0, + paddingRight: selected ? '24px' : 0 + }} + /> + ) : ( +
+ {labelValue || click to name} +
+ )} + + {data.description && ( +
+ {data.description} +
+ )} +
+ + +
+ ); +}; + +export default memo(CustomNode); diff --git a/telemetry/ui/src/components/routes/graph-builder/components/ExampleGallery.tsx b/telemetry/ui/src/components/routes/graph-builder/components/ExampleGallery.tsx new file mode 100644 index 000000000..89d49cd71 --- /dev/null +++ b/telemetry/ui/src/components/routes/graph-builder/components/ExampleGallery.tsx @@ -0,0 +1,71 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { PlayIcon } from '@heroicons/react/24/outline'; +import { Button } from '../../../common/button'; +import { ExampleGraph } from '../data/examples'; + +interface ExampleGalleryProps { + examples: ExampleGraph[]; + onLoadExample: (example: ExampleGraph) => void; +} + +const ExampleGallery: React.FC = ({ examples, onLoadExample }) => { + return ( +
+

Example Graphs

+

+ Load pre-built examples to explore the graph builder +

+ + {examples.map((example) => ( +
+
+

{example.title}

+

{example.description}

+
+ + {example.nodes.length} nodes + + + {example.edges.length} edges + +
+
+
+ +
+
+ ))} + + {examples.length === 0 && ( +

No examples available yet.

+ )} +
+ ); +}; + +export default ExampleGallery; diff --git a/telemetry/ui/src/components/routes/graph-builder/components/GraphBuilder.tsx b/telemetry/ui/src/components/routes/graph-builder/components/GraphBuilder.tsx new file mode 100644 index 000000000..9d94afecc --- /dev/null +++ b/telemetry/ui/src/components/routes/graph-builder/components/GraphBuilder.tsx @@ -0,0 +1,1307 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react'; +import { + ReactFlow, + Node, + Edge, + addEdge, + Connection, + useNodesState, + useEdgesState, + Controls, + MiniMap, + Background, + BackgroundVariant, + NodeTypes, + EdgeTypes, + ReactFlowInstance, + MarkerType +} from '@xyflow/react'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import { Button } from '../../../common/button'; +import { + PlusIcon, + ChevronLeftIcon, + ChevronRightIcon, + ClipboardDocumentIcon, + QuestionMarkCircleIcon, + TrashIcon +} from '@heroicons/react/24/outline'; + +import '@xyflow/react/dist/style.css'; +import CustomNode from './CustomNode'; +import CustomEdge from './CustomEdge'; +import ExampleGallery from './ExampleGallery'; +import ConfirmLoadExampleDialog from './ConfirmLoadExampleDialog'; +import { GraphExporter, BurrGraphJSON } from '../utils/GraphExporter'; +import { BurrGraphCodeGenerator } from '../utils/BurrCodeGenerator'; +import { ExampleLoader } from '../utils/ExampleLoader'; +import { examples } from '../data/examples'; +import type { ExampleGraph } from '../data/examples'; + +const nodeTypes: NodeTypes = { + custom: CustomNode as NodeTypes['custom'] +}; + +const edgeTypes: EdgeTypes = { + custom: CustomEdge as EdgeTypes['custom'] +}; + +const defaultEdgeOptions = { + type: 'custom', + markerEnd: { + type: MarkerType.ArrowClosed, + width: 15, + height: 15, + color: '#429dbce6' + } +}; + +const STORAGE_KEY = 'burr-graph-builder-state'; + +const nodeTemplates = [ + { type: 'action', label: 'Action' }, + { type: 'input', label: 'Input' } +]; + +interface NodeDialogData { + label: string; + description: string; + nodeType: string; + icon: string; +} + +/** + * Visual graph builder for Burr applications. + * + * Accepts an optional initialGraph to pre-populate the canvas - this enables + * future flows like loading from the tracking API or from Pyodide-based + * Python AST parsing. + */ +interface GraphBuilderProps { + initialGraph?: BurrGraphJSON; +} + +const GraphBuilder: React.FC = ({ initialGraph }) => { + const nodeIdCounter = useRef(0); + const [leftOpen, setLeftOpen] = useState(true); + const [rightOpen, setRightOpen] = useState(true); + const [nodes, setNodes, onNodesChange] = useNodesState([] as Node[]); + const [edges, setEdges, onEdgesChange] = useEdgesState([] as Edge[]); + const [reactFlowInstance, setReactFlowInstance] = useState(null); + const [nodeDialog, setNodeDialog] = useState(false); + const [selectedEdge, setSelectedEdge] = useState(null); + const [selectedNode, setSelectedNode] = useState(null); + const [colorPickerOpen, setColorPickerOpen] = useState(false); + const [colorPickerAnchor, setColorPickerAnchor] = useState(null); + const [confirmDialogOpen, setConfirmDialogOpen] = useState(false); + const [selectedExample, setSelectedExample] = useState(null); + const [nodeDialogData, setNodeDialogData] = useState({ + label: '', + description: '', + nodeType: 'action', + icon: 'settings' + }); + const [tabIndex, setTabIndex] = useState(0); + const [copied, setCopied] = useState<'python' | 'json' | null>(null); + const [showExamplePicker, setShowExamplePicker] = useState(false); + + const edgeColors = [ + '#429dbce6', + '#ef4444', + '#10b981', + '#f59e0b', + '#8b5cf6', + '#ec4899', + '#6b7280' + ]; + + const handleDeleteNode = useCallback( + (nodeId: string) => { + setNodes((nds) => nds.filter((node) => node.id !== nodeId)); + setEdges((eds) => eds.filter((edge) => edge.source !== nodeId && edge.target !== nodeId)); + setSelectedNode(null); + }, + [setNodes, setEdges] + ); + + const handleLabelChange = useCallback( + (nodeId: string, newLabel: string) => { + setNodes((nds) => + nds.map((node) => + node.id === nodeId ? { ...node, data: { ...node.data, label: newLabel } } : node + ) + ); + }, + [setNodes] + ); + + const handleToggleProperty = useCallback( + (nodeId: string, property: 'isAsync' | 'isStreaming') => { + setNodes((nds) => + nds.map((node) => + node.id === nodeId + ? { ...node, data: { ...node.data, [property]: !node.data[property] } } + : node + ) + ); + }, + [setNodes] + ); + + const handleEdgeLabelChange = useCallback( + (edgeId: string, newLabel: string) => { + setEdges((eds) => + eds.map((edge) => + edge.id === edgeId + ? { ...edge, data: { ...edge.data, label: newLabel, condition: newLabel } } + : edge + ) + ); + }, + [setEdges] + ); + + // Shared helper: convert BurrGraphJSON into ReactFlow nodes/edges and load them + const loadGraphIntoCanvas = useCallback( + (graphJson: BurrGraphJSON) => { + const newNodes: Node[] = graphJson.nodes.map((n, i) => ({ + id: n.id, + type: 'custom', + position: n.position, + data: { + label: n.label, + description: n.description || '', + nodeType: n.nodeType, + isAsync: n.isAsync || false, + isStreaming: n.isStreaming || false, + icon: 'settings', + colorIndex: i % 10, + onDelete: handleDeleteNode, + onLabelChange: handleLabelChange, + onToggleProperty: handleToggleProperty + } + })); + + const newEdges: Edge[] = graphJson.edges.map((e) => ({ + id: e.id, + source: e.source, + target: e.target, + type: 'custom', + markerEnd: { + type: MarkerType.ArrowClosed, + width: 15, + height: 15, + color: '#429dbce6' + }, + data: { + condition: e.condition, + isConditional: e.isConditional, + label: e.condition, + onLabelChange: handleEdgeLabelChange + } + })); + + // Update nodeIdCounter to be higher than any existing node's numeric suffix + const maxId = graphJson.nodes.reduce((max, n) => { + const match = n.id.match(/(\d+)$/); + return match ? Math.max(max, parseInt(match[1], 10)) : max; + }, 0); + nodeIdCounter.current = Math.max(nodeIdCounter.current, maxId); + + setNodes(newNodes); + setEdges(newEdges); + }, + [ + handleDeleteNode, + handleLabelChange, + handleToggleProperty, + handleEdgeLabelChange, + setNodes, + setEdges + ] + ); + + // Load initialGraph prop if provided, otherwise restore from localStorage + useEffect(() => { + if (initialGraph) { + loadGraphIntoCanvas(initialGraph); + return; + } + + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + const parsed = JSON.parse(saved) as BurrGraphJSON; + if (parsed.nodes && parsed.nodes.length > 0) { + loadGraphIntoCanvas(parsed); + } + } + } catch { + // Corrupted data — ignore and start fresh + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const onPaneClick = useCallback( + (event: React.MouseEvent) => { + if (event.metaKey || event.ctrlKey) { + const isRightClick = event.button === 2 || event.type === 'contextmenu'; + const nodeType = isRightClick ? 'input' : 'action'; + const nodeLabel = isRightClick ? `Input ${nodes.length + 1}` : `Node ${nodes.length + 1}`; + + let position; + if (reactFlowInstance) { + position = reactFlowInstance.screenToFlowPosition({ + x: event.clientX, + y: event.clientY + }); + } else { + const rect = (event.currentTarget as HTMLElement).getBoundingClientRect(); + position = { + x: event.clientX - rect.left, + y: event.clientY - rect.top + }; + } + + const newNode: Node = { + id: `node_${++nodeIdCounter.current}`, + type: 'custom', + position, + data: { + label: nodeLabel, + description: '', + nodeType: nodeType, + isAsync: false, + isStreaming: false, + icon: 'settings', + colorIndex: nodes.length % 10, + onDelete: handleDeleteNode, + onLabelChange: handleLabelChange, + onToggleProperty: handleToggleProperty + } + }; + + setNodes((nds) => [...nds, newNode]); + + if (isRightClick) { + event.preventDefault(); + } + } + }, + [ + nodes.length, + setNodes, + handleDeleteNode, + handleLabelChange, + handleToggleProperty, + reactFlowInstance + ] + ); + + const onPaneContextMenu = useCallback( + (event: React.MouseEvent | MouseEvent) => { + const reactEvent = event as React.MouseEvent; + if (reactEvent.metaKey || reactEvent.ctrlKey) { + onPaneClick(reactEvent); + } + }, + [onPaneClick] + ); + + const onKeyDown = useCallback( + (event: KeyboardEvent) => { + const target = event.target as HTMLElement; + const isInputFocused = + target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable; + + if ((event.key === 'Backspace' || event.key === 'Delete') && !isInputFocused) { + if (selectedNode) { + setNodes((nds) => nds.filter((node) => node.id !== selectedNode)); + setEdges((eds) => + eds.filter((edge) => edge.source !== selectedNode && edge.target !== selectedNode) + ); + setSelectedNode(null); + } else if (selectedEdge) { + setEdges((eds) => { + const filteredEdges = eds.filter((edge) => edge.id !== selectedEdge); + + const deletedEdge = eds.find((edge) => edge.id === selectedEdge); + if (deletedEdge) { + const sourceEdges = filteredEdges.filter( + (edge) => edge.source === deletedEdge.source + ); + const shouldBeConditional = sourceEdges.length > 1; + + return filteredEdges.map((edge) => { + if (edge.source === deletedEdge.source) { + const preservedLabel = shouldBeConditional + ? deletedEdge.data?.label || edge.data?.label || 'condition' + : undefined; + return { + ...edge, + data: { + ...edge.data, + isConditional: shouldBeConditional, + label: preservedLabel, + onLabelChange: handleEdgeLabelChange + } + }; + } + return edge; + }); + } + + return filteredEdges; + }); + setSelectedEdge(null); + } + } + }, + [selectedNode, selectedEdge, setNodes, setEdges, handleEdgeLabelChange] + ); + + useEffect(() => { + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('keydown', onKeyDown); + }; + }, [onKeyDown]); + + const onConnect = useCallback( + (params: Connection) => { + const sourceEdges = edges.filter((edge) => edge.source === params.source); + const willBeConditional = sourceEdges.length > 0; + + const targetNode = nodes.find((node) => node.id === params.target); + const targetLabel = targetNode?.data?.label || params.target; + const conditionString = `condition="${targetLabel}"`; + + const newEdge = { + ...params, + type: 'custom', + markerEnd: { + type: MarkerType.ArrowClosed, + width: 15, + height: 15, + color: '#429dbce6' + }, + data: { + condition: willBeConditional ? conditionString : undefined, + isConditional: willBeConditional, + label: willBeConditional ? conditionString : undefined, + onLabelChange: handleEdgeLabelChange + } + }; + + setEdges((eds) => addEdge(newEdge, eds)); + }, + [setEdges, edges, nodes, handleEdgeLabelChange] + ); + + const onNodeClick = useCallback((_event: React.MouseEvent, node: Node) => { + setSelectedNode(node.id); + setSelectedEdge(null); + setColorPickerOpen(false); + setColorPickerAnchor(null); + }, []); + + const onEdgeClick = useCallback((_event: React.MouseEvent, edge: Edge) => { + setSelectedEdge(edge.id); + setSelectedNode(null); + setColorPickerAnchor(_event.currentTarget as HTMLElement); + setColorPickerOpen(true); + }, []); + + const handleEdgeColorChange = useCallback( + (color: string) => { + if (selectedEdge) { + setEdges((eds) => + eds.map((edge) => + edge.id === selectedEdge ? { ...edge, style: { ...edge.style, stroke: color } } : edge + ) + ); + } + setColorPickerOpen(false); + setColorPickerAnchor(null); + }, + [selectedEdge, setEdges] + ); + + const handleToggleConditional = useCallback(() => { + if (!selectedEdge) return; + setEdges((eds) => { + const targetEdge = eds.find((e) => e.id === selectedEdge); + if (!targetEdge) return eds; + const source = targetEdge.source; + const target = targetEdge.target; + const groupEdges = eds.filter((e) => e.source === source); + const toggledIsConditional = !targetEdge.data?.isConditional; + + const targetNode = nodes.find((node) => node.id === target); + const targetLabel = targetNode?.data?.label || target; + const conditionString = `condition="${targetLabel}"`; + + return eds.map((edge) => { + if (edge.id === selectedEdge) { + return { + ...edge, + data: { + ...edge.data, + isConditional: toggledIsConditional, + condition: toggledIsConditional ? conditionString : undefined, + label: toggledIsConditional ? conditionString : undefined + } + }; + } + if (edge.source === source && edge.id !== selectedEdge) { + if (!toggledIsConditional) { + const stillConditional = + groupEdges.filter((e) => e.id !== selectedEdge && e.data?.isConditional).length > 1; + return { + ...edge, + data: { + ...edge.data, + isConditional: stillConditional + } + }; + } + } + return edge; + }); + }); + setColorPickerOpen(false); + setColorPickerAnchor(null); + }, [selectedEdge, setEdges, nodes]); + + const handleAddNode = useCallback(() => { + setNodeDialog(true); + }, []); + + const [confirmClearOpen, setConfirmClearOpen] = useState(false); + const handleClearCanvas = useCallback(() => { + setNodes([]); + setEdges([]); + setSelectedNode(null); + setSelectedEdge(null); + nodeIdCounter.current = 0; + setConfirmClearOpen(false); + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + // ignore + } + }, [setNodes, setEdges]); + + const handleCreateNode = useCallback(() => { + const newNode: Node = { + id: `node_${++nodeIdCounter.current}`, + type: 'custom', + position: { x: Math.random() * 500 + 100, y: Math.random() * 500 + 100 }, + data: { + label: nodeDialogData.label, + description: nodeDialogData.description, + nodeType: nodeDialogData.nodeType, + isAsync: false, + isStreaming: false, + icon: nodeDialogData.icon, + colorIndex: nodes.length % 10, + onDelete: handleDeleteNode, + onLabelChange: handleLabelChange, + onToggleProperty: handleToggleProperty + } + }; + + setNodes((nds) => [...nds, newNode]); + setNodeDialog(false); + setNodeDialogData({ + label: '', + description: '', + nodeType: 'action', + icon: 'settings' + }); + }, [ + nodeDialogData, + setNodes, + nodes.length, + handleDeleteNode, + handleLabelChange, + handleToggleProperty + ]); + + const graphData = useMemo(() => GraphExporter.exportToJSON(nodes, edges), [nodes, edges]); + const pythonCode = useMemo( + () => BurrGraphCodeGenerator.generatePythonCode(graphData), + [graphData] + ); + const jsonCode = useMemo(() => JSON.stringify(graphData, null, 2), [graphData]); + + // Auto-save graph to localStorage (debounced to avoid writing on every drag frame) + const saveTimerRef = useRef | null>(null); + useEffect(() => { + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current); + } + saveTimerRef.current = setTimeout(() => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(graphData)); + } catch { + // Storage full or unavailable — silently skip + } + }, 500); + return () => { + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current); + } + }; + }, [graphData]); + + const hasExistingContent = nodes.length > 0 || edges.length > 0; + + const handleLoadExample = useCallback((example: ExampleGraph) => { + setSelectedExample(example); + setConfirmDialogOpen(true); + }, []); + + const handleConfirmLoadExample = useCallback(() => { + if (!selectedExample) return; + + const errors = ExampleLoader.validateExample(selectedExample); + if (errors.length > 0) { + console.error('Example validation failed:', errors); + return; + } + + const { nodes: newNodes, edges: newEdges } = ExampleLoader.convertToReactFlow(selectedExample); + + const nodesWithHandlers = newNodes.map((node) => ({ + ...node, + data: { + ...node.data, + onDelete: handleDeleteNode, + onLabelChange: handleLabelChange, + onToggleProperty: handleToggleProperty + } + })); + + const edgesWithHandlers = newEdges.map((edge) => ({ + ...edge, + data: { + ...edge.data, + onLabelChange: handleEdgeLabelChange + } + })); + + setNodes(nodesWithHandlers); + setEdges(edgesWithHandlers); + setConfirmDialogOpen(false); + setSelectedExample(null); + + setTimeout(() => { + if (reactFlowInstance) { + reactFlowInstance.fitView({ padding: 0.1 }); + } + }, 100); + }, [ + selectedExample, + handleDeleteNode, + handleLabelChange, + handleToggleProperty, + handleEdgeLabelChange, + setNodes, + setEdges, + reactFlowInstance + ]); + + const handleCancelLoadExample = useCallback(() => { + setConfirmDialogOpen(false); + setSelectedExample(null); + }, []); + + return ( +
+ {/* Left sidebar with help & instructions */} +
+
+ {leftOpen ? ( +
+ {/* Intro */} +
+

Graph Builder

+

+ Visually design Burr application graphs, then export as Python code or JSON. +

+
+ + {/* Quick Start */} +
+

+ Quick Start +

+
    +
  1. Add action nodes to the canvas
  2. +
  3. Connect them by dragging between handles
  4. +
  5. + Switch to the Python tab to see generated + code +
  6. +
+
+ + {/* Creating */} +
+

+ Creating +

+
+
+
+ + {navigator.platform?.includes('Mac') ? '\u2318' : 'Ctrl'}+Click + +
+ + Add an action node at that position + +
+
+
+ + {navigator.platform?.includes('Mac') ? '\u2318' : 'Ctrl'}+Right-click + +
+ Add an input node +
+
+
+ + Drag + +
+ + From bottom handle to top handle to create an edge (transition) + +
+
+
+ + + + +
+ + Use the button at the bottom-right to add a node via dialog + +
+
+
+ + {/* Editing */} +
+

+ Editing +

+
+
+
+ Click label +
+ Edit a node's name inline +
+
+
+ Click edge label +
+ + Edit a conditional edge's condition text + +
+
+
+ Select node +
+ + Toggle action/streaming type via the badge in the top-right corner + +
+
+
+ Click edge +
+ + Pick a color or toggle conditional/default + +
+
+
+ + {/* Deleting */} +
+

+ Deleting +

+
+
+ + {navigator.platform?.includes('Mac') ? '\u232b' : 'Backspace'} + +
+ Delete the selected node or edge +
+
+ + {/* Concepts */} +
+

+ Node Types +

+
+
+ +
+ Action +

+ A step decorated with{' '} + @action +

+
+
+
+ +
+ Input +

+ External input passed into an action at runtime +

+
+
+
+
+ + {/* Node Flags */} +
+

+ Action Flags +

+

+ Select a node to toggle these independently: +

+
+
+ + async + +

+ Makes the function async def +

+
+
+ + stream + +

+ Uses @streaming_action and + yields results +

+
+
+
+ + {/* Edge Types */} +
+

+ Edge Types +

+
+
+ +
+ Default +

+ Unconditional transition (uses{' '} + default) +

+
+
+
+ +
+ Conditional +

+ Guarded transition (uses{' '} + when()). Created + automatically when a node has multiple outgoing edges. +

+
+
+
+
+
+ ) : ( +
+ +
+ )} +
+ +
+
+
+ + {/* Main content area */} +
+ {/* Tab navigation */} +
+ +
+ + {/* Tab content */} +
+ {tabIndex === 0 && ( +
+ + + + + + + {/* Empty state overlay */} + {nodes.length === 0 && ( +
+
+

+ Design your Burr graph +

+

+ Build application graphs visually and export as Python code. +

+ +
+
+ + {navigator.platform?.includes('Mac') ? '\u2318' : 'Ctrl'}+Click + + Add an action node +
+
+ + Drag handle + + Connect nodes with edges +
+
+ +
+ + +
+
+
+ )} + +
+ {hasExistingContent && ( + + )} + +
+
+ )} + {tabIndex === 1 && ( +
+
+ +
+
+ + {pythonCode} + +
+
+ )} + {tabIndex === 2 && ( +
+
+ +
+
+ + {jsonCode} + +
+
+ )} +
+
+ + {/* Right panel: ExampleGallery */} +
+
+ {rightOpen ? ( +
+ +
+ ) : ( +
+ )} +
+ +
+
+
+ + {/* Add Node Dialog */} + {nodeDialog && ( +
setNodeDialog(false)} + > +
e.stopPropagation()} + > +

Add New Node

+
+
+ + setNodeDialogData({ ...nodeDialogData, label: e.target.value })} + className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ +