This is an automated email from the ASF dual-hosted git repository. skrawcz pushed a commit to branch feature/graph-builder in repository https://gitbox.apache.org/repos/asf/burr.git
commit ea60cbe0bcfe852ab5bee434838995beda6551b1 Author: Stefan Krawczyk <[email protected]> AuthorDate: Sun Mar 1 08:23:29 2026 -0800 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 + telemetry/ui/src/components/nav/appcontainer.tsx | 16 +- .../ui/src/components/routes/app/GraphView.tsx | 43 +- .../components/ConfirmLoadExampleDialog.tsx | 81 ++ .../routes/graph-builder/components/CustomEdge.tsx | 161 +++ .../routes/graph-builder/components/CustomNode.tsx | 230 ++++ .../graph-builder/components/ExampleGallery.tsx | 71 ++ .../graph-builder/components/GraphBuilder.tsx | 1307 ++++++++++++++++++++ .../routes/graph-builder/data/examples.ts | 564 +++++++++ .../graph-builder/utils/BurrCodeGenerator.ts | 321 +++++ .../routes/graph-builder/utils/ExampleLoader.ts | 93 ++ .../routes/graph-builder/utils/GraphExporter.ts | 83 ++ 14 files changed, 2973 insertions(+), 365 deletions(-) diff --git a/telemetry/ui/package-lock.json b/telemetry/ui/package-lock.json index 21461ed4..dfd25da4 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 1a391fcc..2145236c 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 4b80c5bd..fd0f1445 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 = () => { <Route path="/demos/email-assistant" element={<EmailAssistantWithTelemetry />} /> <Route path="/demos/deep-researcher" element={<DeepResearcherWithTelemetry />} /> <Route path="/admin" element={<AdminView />} /> + <Route path="/graph-builder" element={<GraphBuilder />} /> <Route path="*" element={<Navigate to="/projects" />} /> </Routes> </AppContainer> diff --git a/telemetry/ui/src/components/nav/appcontainer.tsx b/telemetry/ui/src/components/nav/appcontainer.tsx index edcce3f2..7277ac55 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 }) => { <ToggleOpenButton open={sidebarOpen} toggleSidebar={toggleSidebar} /> </div> - {/* This is a bit hacky -- just quickly prototyping and these margins were the ones that worked! */} - <main className={`py-14 -my-1 ${sidebarOpen ? 'lg:pl-72' : 'lg:pl-5'} h-full`}> - <div className="flex items-center px-5 sm:px-7 lg:px-9 pb-8 -my-4"> + <main className={`${sidebarOpen ? 'lg:pl-72' : 'lg:pl-5'} h-full flex flex-col`}> + <div className="flex items-center px-5 sm:px-7 lg:px-9 h-14 flex-shrink-0"> <BreadCrumb /> </div> - <div className="flex h-full flex-col"> - <div className="px-4 sm:px-6 lg:px-2 max-h-full h-full flex-1"> {props.children}</div> + <div className="flex-1 flex flex-col min-h-0"> + <div className="px-4 sm:px-6 lg:px-2 h-full flex-1 min-h-0">{props.children}</div> </div> </main> </div> diff --git a/telemetry/ui/src/components/routes/app/GraphView.tsx b/telemetry/ui/src/components/routes/app/GraphView.tsx index ce85b823..b12d9977 100644 --- a/telemetry/ui/src/components/routes/app/GraphView.tsx +++ b/telemetry/ui/src/components/routes/app/GraphView.tsx @@ -21,7 +21,8 @@ import { ActionModel, ApplicationModel, Step } from '../../../api'; import dagre from 'dagre'; import React, { createContext, useCallback, useLayoutEffect, useRef, useState } from 'react'; -import ReactFlow, { +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 ( <> - <BaseEdge path={edgePath} markerEnd={markerEnd} style={style} label={'test'} /> + <BaseEdge path={edgePath} markerEnd={markerEnd} style={style} /> </> ); }; @@ -358,7 +345,7 @@ export const _Graph = (props: { <ReactFlow nodes={nodes} edges={edges} - edgesUpdatable={false} + edgesReconnectable={false} nodesDraggable={false} nodeTypes={nodeTypes} edgeTypes={edgeTypes} diff --git a/telemetry/ui/src/components/routes/graph-builder/components/ConfirmLoadExampleDialog.tsx b/telemetry/ui/src/components/routes/graph-builder/components/ConfirmLoadExampleDialog.tsx new file mode 100644 index 00000000..e1a0cf56 --- /dev/null +++ b/telemetry/ui/src/components/routes/graph-builder/components/ConfirmLoadExampleDialog.tsx @@ -0,0 +1,81 @@ +/* + * 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 { Button } from '../../../common/button'; +import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'; + +interface ConfirmLoadExampleDialogProps { + open: boolean; + onClose: () => void; + onConfirm: () => void; + exampleTitle: string; + hasExistingContent: boolean; +} + +const ConfirmLoadExampleDialog: React.FC<ConfirmLoadExampleDialogProps> = ({ + open, + onClose, + onConfirm, + exampleTitle, + hasExistingContent +}) => { + if (!open) return null; + + return ( + <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> + <div className="bg-white rounded-lg p-6 w-full max-w-md mx-4"> + <div className="flex items-center gap-2 mb-4"> + <ExclamationTriangleIcon className="w-6 h-6 text-amber-500" /> + <h2 className="text-lg font-semibold">Load Example Graph</h2> + </div> + + <div className="space-y-4"> + <p className="text-gray-700"> + Are you sure you want to load the "{exampleTitle}" example? + </p> + + {hasExistingContent && ( + <div className="bg-amber-50 border border-amber-200 rounded-md p-3"> + <p className="text-sm text-amber-800"> + This will replace your current graph. Any unsaved changes will be lost. + </p> + </div> + )} + + <div className="text-sm text-gray-500"> + You can always export your current work as JSON or Python code before loading the + example. + </div> + </div> + + <div className="flex justify-end space-x-2 mt-6"> + <Button onClick={onClose} outline> + Cancel + </Button> + <Button onClick={onConfirm} color={hasExistingContent ? 'amber' : 'blue'}> + {hasExistingContent ? 'Replace Graph' : 'Load Example'} + </Button> + </div> + </div> + </div> + ); +}; + +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 00000000..f6e2447b --- /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<string, unknown> { + condition?: string; + isConditional?: boolean; + label?: string; + onLabelChange?: (edgeId: string, newLabel: string) => void; +} + +type CustomEdgeType = Edge<CustomEdgeData>; + +const CustomEdge: React.FC<EdgeProps<CustomEdgeType>> = ({ + 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<HTMLInputElement>) => { + 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 ( + <> + <style> + {` + @keyframes dash { + to { + stroke-dashoffset: -12; + } + } + `} + </style> + + <BaseEdge path={edgePath} markerEnd={markerEnd || MarkerType.ArrowClosed} style={edgeStyle} /> + <EdgeLabelRenderer> + {isConditional && ( + <div + className="absolute text-xs pointer-events-auto" + style={{ + transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)` + }} + > + {isEditing ? ( + <input + value={labelValue} + onChange={handleLabelChange} + onBlur={handleLabelBlur} + onKeyDown={handleLabelKeyDown} + onClick={(e) => 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" + /> + ) : ( + <button + onClick={handleLabelClick} + className="bg-white border border-gray-300 rounded-xl px-2 py-1 text-xs cursor-pointer hover:bg-gray-50 transition-colors min-h-5 flex items-center" + > + {data?.label ?? data?.condition ?? ''} + </button> + )} + </div> + )} + </EdgeLabelRenderer> + </> + ); +}; + +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 00000000..c7866110 --- /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<string, unknown> { + 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<CustomNodeData>; + +const CustomNode: React.FC<NodeProps<CustomNodeType>> = ({ id, data, selected }) => { + const [isEditing, setIsEditing] = useState(false); + const [labelValue, setLabelValue] = useState(data.label); + const [fixedWidth, setFixedWidth] = useState<number | null>(null); + const [fixedHeight, setFixedHeight] = useState<number | null>(null); + const paperRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => { + 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 ( + <div + ref={paperRef} + className={` + min-w-40 max-w-60 relative overflow-visible transition-all duration-200 ease-in-out + ${selected ? 'shadow-lg scale-105' : 'shadow-sm'} + ${isInputNode ? 'border-2 border-dashed border-gray-600 bg-white' : 'border-2 border-solid bg-opacity-90'} + rounded-lg + `} + style={{ + width: fixedWidth ? `${fixedWidth}px` : 'fit-content', + height: fixedHeight ? `${fixedHeight}px` : 'auto', + borderColor: isInputNode ? '#666' : colors.border, + backgroundColor: isInputNode ? '#fff' : colors.background + }} + > + <Handle + type="target" + position={Position.Top} + style={{ + background: 'white', + border: `2px solid ${isInputNode ? '#666' : colors.border}`, + width: 12, + height: 12 + }} + /> + + <div + className={` + p-3 relative + ${isInputNode ? 'flex flex-col items-center justify-center h-full text-center' : ''} + `} + > + {selected && !isInputNode && ( + <div className="absolute top-0 right-0 flex gap-px"> + <button + onClick={() => data.onToggleProperty?.(id, 'isAsync')} + className="px-1.5 py-0.5 text-[10px] font-semibold transition-opacity rounded-bl" + style={{ + color: data.isAsync ? 'white' : colors.border, + backgroundColor: data.isAsync ? colors.border : `${colors.border}22`, + opacity: data.isAsync ? 1 : 0.6 + }} + title={data.isAsync ? 'Click to make synchronous' : 'Click to make async'} + > + async + </button> + <button + onClick={() => data.onToggleProperty?.(id, 'isStreaming')} + className="px-1.5 py-0.5 text-[10px] font-semibold transition-opacity rounded-tr-lg" + style={{ + color: data.isStreaming ? 'white' : colors.border, + backgroundColor: data.isStreaming ? colors.border : `${colors.border}22`, + opacity: data.isStreaming ? 1 : 0.6 + }} + title={data.isStreaming ? 'Click to make regular action' : 'Click to make streaming'} + > + stream + </button> + </div> + )} + + {isEditing ? ( + <input + value={labelValue} + onChange={handleLabelChange} + onBlur={handleLabelBlur} + onKeyDown={handleLabelKeyDown} + onClick={(e) => 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 + }} + /> + ) : ( + <div + onClick={handleLabelClick} + className={` + font-bold cursor-pointer hover:opacity-80 break-words min-h-5 min-w-16 + ${isInputNode ? 'text-center text-gray-600' : 'text-left'} + ${data.description ? 'mb-2' : ''} + `} + style={{ + color: isInputNode ? '#666' : colors.border, + paddingRight: selected ? '12px' : 0 + }} + > + {labelValue || <span className="opacity-40 italic font-normal">click to name</span>} + </div> + )} + + {data.description && ( + <div + className={` + text-xs opacity-80 block break-words + ${isInputNode ? 'text-center text-gray-600' : 'text-left'} + `} + style={{ color: isInputNode ? '#666' : colors.border }} + > + {data.description} + </div> + )} + </div> + + <Handle + type="source" + position={Position.Bottom} + style={{ + background: 'white', + border: `2px solid ${isInputNode ? '#666' : colors.border}`, + width: 12, + height: 12 + }} + /> + </div> + ); +}; + +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 00000000..89d49cd7 --- /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<ExampleGalleryProps> = ({ examples, onLoadExample }) => { + return ( + <div className="mt-6 pt-4 border-t border-gray-200"> + <h3 className="text-lg font-semibold text-gray-900 mb-1">Example Graphs</h3> + <p className="text-sm text-gray-600 mb-4"> + Load pre-built examples to explore the graph builder + </p> + + {examples.map((example) => ( + <div + key={example.id} + className="mb-4 border border-gray-200 rounded-lg hover:shadow-md hover:-translate-y-0.5 transition-all duration-200 ease-in-out" + > + <div className="p-4 pb-2"> + <h4 className="text-base font-medium text-gray-900 mb-2">{example.title}</h4> + <p className="text-sm text-gray-600 mb-3">{example.description}</p> + <div className="flex gap-2 mb-2"> + <span className="inline-flex items-center px-2 py-1 rounded text-xs border border-gray-300 bg-gray-50 text-gray-700"> + {example.nodes.length} nodes + </span> + <span className="inline-flex items-center px-2 py-1 rounded text-xs border border-gray-300 bg-gray-50 text-gray-700"> + {example.edges.length} edges + </span> + </div> + </div> + <div className="px-4 pb-4 pt-0 flex gap-2"> + <Button onClick={() => onLoadExample(example)} className="text-xs cursor-pointer"> + <PlayIcon className="w-3 h-3 mr-1" /> + Load Example + </Button> + </div> + </div> + ))} + + {examples.length === 0 && ( + <p className="text-sm text-gray-500 italic">No examples available yet.</p> + )} + </div> + ); +}; + +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 00000000..9d94afec --- /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<GraphBuilderProps> = ({ 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<ReactFlowInstance | null>(null); + const [nodeDialog, setNodeDialog] = useState(false); + const [selectedEdge, setSelectedEdge] = useState<string | null>(null); + const [selectedNode, setSelectedNode] = useState<string | null>(null); + const [colorPickerOpen, setColorPickerOpen] = useState(false); + const [colorPickerAnchor, setColorPickerAnchor] = useState<HTMLElement | null>(null); + const [confirmDialogOpen, setConfirmDialogOpen] = useState(false); + const [selectedExample, setSelectedExample] = useState<ExampleGraph | null>(null); + const [nodeDialogData, setNodeDialogData] = useState<NodeDialogData>({ + 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<ReturnType<typeof setTimeout> | 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 ( + <div className="flex h-full overflow-hidden border-t"> + {/* Left sidebar with help & instructions */} + <div + className={`${leftOpen ? 'w-72' : 'w-12'} flex-shrink-0 bg-white border-r border-gray-200 transition-all duration-200 overflow-hidden`} + > + <div className="flex flex-col h-full"> + {leftOpen ? ( + <div className="flex-1 overflow-auto p-4"> + {/* Intro */} + <div className="mb-5"> + <h3 className="text-lg font-semibold mb-1">Graph Builder</h3> + <p className="text-sm text-gray-500"> + Visually design Burr application graphs, then export as Python code or JSON. + </p> + </div> + + {/* Quick Start */} + <div className="mb-5"> + <h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3"> + Quick Start + </h4> + <ol className="text-sm text-gray-600 space-y-2 list-decimal list-inside"> + <li>Add action nodes to the canvas</li> + <li>Connect them by dragging between handles</li> + <li> + Switch to the <span className="font-medium">Python</span> tab to see generated + code + </li> + </ol> + </div> + + {/* Creating */} + <div className="mb-5"> + <h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3"> + Creating + </h4> + <div className="space-y-3"> + <div className="flex items-start gap-2"> + <div className="flex-shrink-0 mt-0.5"> + <kbd className="inline-flex items-center px-1.5 py-0.5 rounded bg-gray-100 border border-gray-300 text-xs font-mono text-gray-700"> + {navigator.platform?.includes('Mac') ? '\u2318' : 'Ctrl'}+Click + </kbd> + </div> + <span className="text-sm text-gray-600"> + Add an action node at that position + </span> + </div> + <div className="flex items-start gap-2"> + <div className="flex-shrink-0 mt-0.5"> + <kbd className="inline-flex items-center px-1.5 py-0.5 rounded bg-gray-100 border border-gray-300 text-xs font-mono text-gray-700"> + {navigator.platform?.includes('Mac') ? '\u2318' : 'Ctrl'}+Right-click + </kbd> + </div> + <span className="text-sm text-gray-600">Add an input node</span> + </div> + <div className="flex items-start gap-2"> + <div className="flex-shrink-0 mt-0.5"> + <kbd className="inline-flex items-center px-1.5 py-0.5 rounded bg-gray-100 border border-gray-300 text-xs font-mono text-gray-700"> + Drag + </kbd> + </div> + <span className="text-sm text-gray-600"> + From bottom handle to top handle to create an edge (transition) + </span> + </div> + <div className="flex items-start gap-2"> + <div className="flex-shrink-0 mt-0.5"> + <kbd className="inline-flex items-center px-1.5 py-0.5 rounded bg-gray-100 border border-gray-300 text-xs font-mono text-gray-700"> + + + </kbd> + </div> + <span className="text-sm text-gray-600"> + Use the button at the bottom-right to add a node via dialog + </span> + </div> + </div> + </div> + + {/* Editing */} + <div className="mb-5"> + <h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3"> + Editing + </h4> + <div className="space-y-3"> + <div className="flex items-start gap-2"> + <div className="flex-shrink-0 mt-0.5"> + <span className="text-sm text-gray-500">Click label</span> + </div> + <span className="text-sm text-gray-600">Edit a node's name inline</span> + </div> + <div className="flex items-start gap-2"> + <div className="flex-shrink-0 mt-0.5"> + <span className="text-sm text-gray-500">Click edge label</span> + </div> + <span className="text-sm text-gray-600"> + Edit a conditional edge's condition text + </span> + </div> + <div className="flex items-start gap-2"> + <div className="flex-shrink-0 mt-0.5"> + <span className="text-sm text-gray-500">Select node</span> + </div> + <span className="text-sm text-gray-600"> + Toggle action/streaming type via the badge in the top-right corner + </span> + </div> + <div className="flex items-start gap-2"> + <div className="flex-shrink-0 mt-0.5"> + <span className="text-sm text-gray-500">Click edge</span> + </div> + <span className="text-sm text-gray-600"> + Pick a color or toggle conditional/default + </span> + </div> + </div> + </div> + + {/* Deleting */} + <div className="mb-5"> + <h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3"> + Deleting + </h4> + <div className="flex items-start gap-2"> + <div className="flex-shrink-0 mt-0.5"> + <kbd className="inline-flex items-center px-1.5 py-0.5 rounded bg-gray-100 border border-gray-300 text-xs font-mono text-gray-700"> + {navigator.platform?.includes('Mac') ? '\u232b' : 'Backspace'} + </kbd> + </div> + <span className="text-sm text-gray-600">Delete the selected node or edge</span> + </div> + </div> + + {/* Concepts */} + <div className="mb-5"> + <h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3"> + Node Types + </h4> + <div className="space-y-2"> + <div className="flex items-start gap-2"> + <span className="inline-block w-3 h-3 rounded bg-blue-200 border border-blue-400 flex-shrink-0 mt-1" /> + <div> + <span className="text-sm font-medium text-gray-700">Action</span> + <p className="text-xs text-gray-500"> + A step decorated with{' '} + <code className="bg-gray-100 px-1 rounded">@action</code> + </p> + </div> + </div> + <div className="flex items-start gap-2"> + <span className="inline-block w-3 h-3 rounded border-2 border-dashed border-gray-400 flex-shrink-0 mt-1 w-3 h-3" /> + <div> + <span className="text-sm font-medium text-gray-700">Input</span> + <p className="text-xs text-gray-500"> + External input passed into an action at runtime + </p> + </div> + </div> + </div> + </div> + + {/* Node Flags */} + <div className="mb-5"> + <h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3"> + Action Flags + </h4> + <p className="text-xs text-gray-500 mb-2"> + Select a node to toggle these independently: + </p> + <div className="space-y-2"> + <div className="flex items-start gap-2"> + <span className="inline-flex px-1.5 py-0.5 rounded bg-blue-500 text-white text-[10px] font-semibold flex-shrink-0 mt-0.5"> + async + </span> + <p className="text-xs text-gray-500"> + Makes the function <code className="bg-gray-100 px-1 rounded">async def</code> + </p> + </div> + <div className="flex items-start gap-2"> + <span className="inline-flex px-1.5 py-0.5 rounded bg-blue-500 text-white text-[10px] font-semibold flex-shrink-0 mt-0.5"> + stream + </span> + <p className="text-xs text-gray-500"> + Uses <code className="bg-gray-100 px-1 rounded">@streaming_action</code> and + yields results + </p> + </div> + </div> + </div> + + {/* Edge Types */} + <div className="mb-5"> + <h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3"> + Edge Types + </h4> + <div className="space-y-2"> + <div className="flex items-start gap-2"> + <span className="inline-block w-6 border-t-2 border-blue-400 flex-shrink-0 mt-2" /> + <div> + <span className="text-sm font-medium text-gray-700">Default</span> + <p className="text-xs text-gray-500"> + Unconditional transition (uses{' '} + <code className="bg-gray-100 px-1 rounded">default</code>) + </p> + </div> + </div> + <div className="flex items-start gap-2"> + <span className="inline-block w-6 border-t-2 border-dashed border-blue-400 flex-shrink-0 mt-2" /> + <div> + <span className="text-sm font-medium text-gray-700">Conditional</span> + <p className="text-xs text-gray-500"> + Guarded transition (uses{' '} + <code className="bg-gray-100 px-1 rounded">when()</code>). Created + automatically when a node has multiple outgoing edges. + </p> + </div> + </div> + </div> + </div> + </div> + ) : ( + <div className="flex-1 flex items-start justify-center pt-4"> + <QuestionMarkCircleIcon className="w-5 h-5 text-gray-400" /> + </div> + )} + <div className={`flex items-center ${leftOpen ? 'justify-start' : 'justify-center'} p-2`}> + <button + onClick={() => setLeftOpen(!leftOpen)} + className="p-1 rounded hover:bg-gray-100" + title={leftOpen ? 'Collapse help panel' : 'Expand help panel'} + > + {leftOpen ? ( + <ChevronLeftIcon className="w-5 h-5 text-gray-400" /> + ) : ( + <ChevronRightIcon className="w-5 h-5 text-gray-400" /> + )} + </button> + </div> + </div> + </div> + + {/* Main content area */} + <div className="flex flex-col flex-1 min-w-0"> + {/* Tab navigation */} + <div className="border-b border-gray-200 flex-shrink-0"> + <nav className="flex space-x-8 px-4"> + {[ + { label: 'Canvas', title: 'Visual graph editor' }, + { label: 'Python', title: 'Generated Burr Python code' }, + { label: 'JSON', title: 'Graph data as JSON (importable)' } + ].map((tab, idx) => ( + <button + key={tab.label} + onClick={() => setTabIndex(idx)} + title={tab.title} + className={`py-2 px-1 border-b-2 font-medium text-sm ${ + tabIndex === idx + ? 'border-blue-500 text-blue-600' + : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' + }`} + > + {tab.label} + </button> + ))} + </nav> + </div> + + {/* Tab content */} + <div className="flex-1 min-h-0 relative"> + {tabIndex === 0 && ( + <div className="h-full w-full relative"> + <ReactFlow + nodes={nodes} + edges={edges} + onNodesChange={onNodesChange} + onEdgesChange={onEdgesChange} + onConnect={onConnect} + onNodeClick={onNodeClick} + onEdgeClick={onEdgeClick} + onPaneClick={onPaneClick} + onPaneContextMenu={onPaneContextMenu} + onInit={setReactFlowInstance} + nodeTypes={nodeTypes} + edgeTypes={edgeTypes} + defaultEdgeOptions={defaultEdgeOptions} + defaultViewport={{ x: 0, y: 0, zoom: 1.0 }} + attributionPosition="bottom-left" + deleteKeyCode={null} + style={{ width: '100%', height: '100%' }} + > + <Controls /> + <MiniMap /> + <Background variant={BackgroundVariant.Dots} gap={20} size={1} /> + </ReactFlow> + + {/* Empty state overlay */} + {nodes.length === 0 && ( + <div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10"> + <div className="bg-white/90 backdrop-blur-sm rounded-xl shadow-lg border border-gray-200 p-8 max-w-md text-center pointer-events-auto"> + <h3 className="text-xl font-semibold text-gray-800 mb-2"> + Design your Burr graph + </h3> + <p className="text-sm text-gray-500 mb-6"> + Build application graphs visually and export as Python code. + </p> + + <div className="text-left space-y-3 mb-6"> + <div className="flex items-center gap-3 bg-gray-50 rounded-lg px-4 py-2.5"> + <kbd className="inline-flex items-center px-2 py-1 rounded bg-white border border-gray-300 text-xs font-mono text-gray-700 shadow-sm flex-shrink-0"> + {navigator.platform?.includes('Mac') ? '\u2318' : 'Ctrl'}+Click + </kbd> + <span className="text-sm text-gray-600">Add an action node</span> + </div> + <div className="flex items-center gap-3 bg-gray-50 rounded-lg px-4 py-2.5"> + <kbd className="inline-flex items-center px-2 py-1 rounded bg-white border border-gray-300 text-xs font-mono text-gray-700 shadow-sm flex-shrink-0"> + Drag handle + </kbd> + <span className="text-sm text-gray-600">Connect nodes with edges</span> + </div> + </div> + + <div className="flex gap-3 justify-center"> + <button + className="inline-flex items-center px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white text-sm font-medium rounded-lg transition-colors" + onClick={handleAddNode} + > + <PlusIcon className="w-4 h-4 mr-1.5" /> + Add First Node + </button> + <button + className="inline-flex items-center px-4 py-2 border border-gray-300 text-gray-700 text-sm font-medium rounded-lg hover:bg-gray-50 transition-colors" + onClick={() => setShowExamplePicker(true)} + > + Load Example + </button> + </div> + </div> + </div> + )} + + <div className="absolute bottom-4 right-4 flex gap-2"> + {hasExistingContent && ( + <button + className="bg-white hover:bg-red-50 text-red-500 border border-red-200 rounded-full w-14 h-14 flex items-center justify-center shadow-lg transition-colors" + onClick={() => setConfirmClearOpen(true)} + title="Clear canvas" + aria-label="Clear canvas" + > + <TrashIcon className="w-6 h-6" /> + </button> + )} + <button + className="bg-blue-500 hover:bg-blue-600 text-white rounded-full w-14 h-14 flex items-center justify-center shadow-lg transition-colors" + onClick={handleAddNode} + title="Add a new node via dialog" + aria-label="Add a new node" + > + <PlusIcon className="w-6 h-6" /> + </button> + </div> + </div> + )} + {tabIndex === 1 && ( + <div className="h-full flex flex-col bg-gray-900 relative"> + <div className="absolute top-2 right-5 z-10 bg-white bg-opacity-85 rounded"> + <button + className={`p-2 rounded ${ + copied === 'python' ? 'text-green-600' : 'text-blue-600' + } hover:bg-gray-100`} + onClick={() => { + navigator.clipboard + .writeText(pythonCode) + .then(() => { + setCopied('python'); + setTimeout(() => setCopied(null), 1200); + }) + .catch(() => { + /* clipboard not available */ + }); + }} + title={copied === 'python' ? 'Copied!' : 'Copy Python code to clipboard'} + aria-label="Copy Python code" + > + <ClipboardDocumentIcon className="w-5 h-5" /> + </button> + </div> + <div className="flex-1 overflow-auto"> + <SyntaxHighlighter + language="python" + style={vscDarkPlus} + customStyle={{ + margin: 0, + padding: 16, + fontSize: 14, + borderRadius: 0, + minHeight: '100%' + }} + > + {pythonCode} + </SyntaxHighlighter> + </div> + </div> + )} + {tabIndex === 2 && ( + <div className="h-full flex flex-col bg-gray-900 relative"> + <div className="absolute top-2 right-5 z-10 bg-white bg-opacity-85 rounded"> + <button + className={`p-2 rounded ${ + copied === 'json' ? 'text-green-600' : 'text-blue-600' + } hover:bg-gray-100`} + onClick={() => { + navigator.clipboard + .writeText(jsonCode) + .then(() => { + setCopied('json'); + setTimeout(() => setCopied(null), 1200); + }) + .catch(() => { + /* clipboard not available */ + }); + }} + title={copied === 'json' ? 'Copied!' : 'Copy JSON to clipboard'} + aria-label="Copy JSON code" + > + <ClipboardDocumentIcon className="w-5 h-5" /> + </button> + </div> + <div className="flex-1 overflow-auto"> + <SyntaxHighlighter + language="json" + style={vscDarkPlus} + customStyle={{ + margin: 0, + padding: 16, + fontSize: 14, + borderRadius: 0, + minHeight: '100%' + }} + > + {jsonCode} + </SyntaxHighlighter> + </div> + </div> + )} + </div> + </div> + + {/* Right panel: ExampleGallery */} + <div + className={`${rightOpen ? 'w-72' : 'w-12'} flex-shrink-0 bg-white shadow-lg z-10 transition-all duration-200`} + > + <div className="flex flex-col h-full"> + {rightOpen ? ( + <div className="flex-1 overflow-y-auto p-4"> + <ExampleGallery examples={examples} onLoadExample={handleLoadExample} /> + </div> + ) : ( + <div className="flex-1" /> + )} + <div + className={`flex items-center ${rightOpen ? 'justify-start' : 'justify-center'} p-2 border-t border-gray-200 bg-white flex-shrink-0`} + > + <button + onClick={() => setRightOpen(!rightOpen)} + className="p-1 rounded hover:bg-gray-100" + title={rightOpen ? 'Collapse examples panel' : 'Expand examples panel'} + > + {rightOpen ? ( + <ChevronRightIcon className="w-5 h-5 text-gray-400" /> + ) : ( + <ChevronLeftIcon className="w-5 h-5 text-gray-400" /> + )} + </button> + </div> + </div> + </div> + + {/* Add Node Dialog */} + {nodeDialog && ( + <div + className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" + onClick={() => setNodeDialog(false)} + > + <div + className="bg-white rounded-lg p-6 w-full max-w-md" + onClick={(e) => e.stopPropagation()} + > + <h2 className="text-lg font-semibold mb-4">Add New Node</h2> + <div className="space-y-4"> + <div> + <label className="block text-sm font-medium text-gray-700 mb-1">Node Label</label> + <input + type="text" + value={nodeDialogData.label} + onChange={(e) => 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" + /> + </div> + <div> + <label className="block text-sm font-medium text-gray-700 mb-1">Description</label> + <textarea + value={nodeDialogData.description} + onChange={(e) => + setNodeDialogData({ ...nodeDialogData, description: e.target.value }) + } + rows={3} + className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + </div> + <div> + <label className="block text-sm font-medium text-gray-700 mb-1">Node Type</label> + <select + value={nodeDialogData.nodeType} + onChange={(e) => + setNodeDialogData({ ...nodeDialogData, nodeType: 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" + > + {nodeTemplates.map((template) => ( + <option key={template.type} value={template.type}> + {template.label} + </option> + ))} + </select> + </div> + </div> + <div className="flex justify-end space-x-2 mt-6"> + <Button onClick={() => setNodeDialog(false)} outline> + Cancel + </Button> + <Button onClick={handleCreateNode}>Create Node</Button> + </div> + </div> + </div> + )} + + {/* Color Picker Popover for Edges */} + {colorPickerOpen && colorPickerAnchor && ( + <div + className="fixed z-50 bg-white rounded-lg shadow-lg border border-gray-200 p-4" + style={{ + top: colorPickerAnchor.getBoundingClientRect().bottom + 8, + left: colorPickerAnchor.getBoundingClientRect().left + }} + > + <h3 className="text-sm font-medium mb-3">Select Edge Color</h3> + <div className="grid grid-cols-4 gap-2"> + {edgeColors.map((color) => ( + <button + key={color} + className="w-8 h-8 rounded border-2 border-transparent hover:border-black transition-colors" + style={{ backgroundColor: color }} + onClick={() => handleEdgeColorChange(color)} + /> + ))} + </div> + {(() => { + if (!selectedEdge) return null; + const selected = edges.find((e) => e.id === selectedEdge); + if (!selected) return null; + const groupEdges = edges.filter((e) => e.source === selected.source); + if (groupEdges.length > 1) { + return ( + <div className="mt-4"> + {selected.data?.isConditional ? ( + <Button className="w-full" color="blue" onClick={handleToggleConditional}> + Make Default + </Button> + ) : ( + <Button className="w-full" outline onClick={handleToggleConditional}> + Make Conditional + </Button> + )} + </div> + ); + } + return null; + })()} + </div> + )} + + {/* Example Picker Modal (from empty state) */} + {showExamplePicker && ( + <div + className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" + onClick={() => setShowExamplePicker(false)} + > + <div + className="bg-white rounded-lg p-6 w-full max-w-lg max-h-[80vh] overflow-y-auto" + onClick={(e) => e.stopPropagation()} + > + <h2 className="text-lg font-semibold mb-1">Load an Example</h2> + <p className="text-sm text-gray-500 mb-4"> + Pick a pre-built graph to explore the builder. + </p> + <div className="space-y-3"> + {examples.map((example) => ( + <button + key={example.id} + className="w-full text-left border border-gray-200 rounded-lg p-4 hover:shadow-md hover:border-blue-300 transition-all" + onClick={() => { + setShowExamplePicker(false); + handleLoadExample(example); + }} + > + <h4 className="font-medium text-gray-900">{example.title}</h4> + <p className="text-sm text-gray-600 mt-1">{example.description}</p> + <div className="flex gap-2 mt-2"> + <span className="inline-flex items-center px-2 py-0.5 rounded text-xs border border-gray-300 bg-gray-50 text-gray-600"> + {example.nodes.length} nodes + </span> + <span className="inline-flex items-center px-2 py-0.5 rounded text-xs border border-gray-300 bg-gray-50 text-gray-600"> + {example.edges.length} edges + </span> + </div> + </button> + ))} + </div> + <div className="mt-4 flex justify-end"> + <button + className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800" + onClick={() => setShowExamplePicker(false)} + > + Cancel + </button> + </div> + </div> + </div> + )} + + {/* Confirm Clear Dialog */} + {confirmClearOpen && ( + <div + className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" + onClick={() => setConfirmClearOpen(false)} + > + <div + className="bg-white rounded-lg p-6 w-full max-w-sm" + onClick={(e) => e.stopPropagation()} + > + <h2 className="text-lg font-semibold mb-2">Clear canvas?</h2> + <p className="text-sm text-gray-600 mb-6"> + This will remove all nodes and edges. This cannot be undone. + </p> + <div className="flex justify-end gap-2"> + <Button outline onClick={() => setConfirmClearOpen(false)}> + Cancel + </Button> + <Button color="red" onClick={handleClearCanvas}> + Clear + </Button> + </div> + </div> + </div> + )} + + {/* Confirm Load Example Dialog */} + <ConfirmLoadExampleDialog + open={confirmDialogOpen} + onClose={handleCancelLoadExample} + onConfirm={handleConfirmLoadExample} + exampleTitle={selectedExample?.title || ''} + hasExistingContent={hasExistingContent} + /> + </div> + ); +}; + +export default GraphBuilder; diff --git a/telemetry/ui/src/components/routes/graph-builder/data/examples.ts b/telemetry/ui/src/components/routes/graph-builder/data/examples.ts new file mode 100644 index 00000000..528600dc --- /dev/null +++ b/telemetry/ui/src/components/routes/graph-builder/data/examples.ts @@ -0,0 +1,564 @@ +/* + * 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. + */ + +export interface ExampleGraph { + id: string; + title: string; + description: string; + nodes: Array<{ + id: string; + label: string; + nodeType: 'input' | 'action'; + isAsync?: boolean; + isStreaming?: boolean; + position: { x: number; y: number }; + description?: string; + }>; + edges: Array<{ + id: string; + source: string; + target: string; + condition?: string; + isConditional: boolean; + }>; +} + +export const multiModalChatbotWorkflow: ExampleGraph = { + id: 'multi-modal-chatbot', + title: 'MultiModal Chatbot', + description: 'A ChatGPT-like bot which supports multiple response modes, with regular actions.', + nodes: [ + { + id: 'node_prompt', + label: 'prompt', + nodeType: 'action', + position: { x: 761, y: 91 } + }, + { + id: 'node_check_openai_key', + label: 'check_openai_key', + nodeType: 'action', + position: { x: 591, y: 193 } + }, + { + id: 'node_check_safety', + label: 'check_safety', + nodeType: 'action', + position: { x: 384, y: 342 } + }, + { + id: 'node_decide_mode', + label: 'decide_mode', + nodeType: 'action', + position: { x: 245, y: 441 } + }, + { + id: 'node_prompt_for_more', + label: 'prompt_for_more', + nodeType: 'action', + position: { x: 25, y: 640 } + }, + { + id: 'node_generate_image', + label: 'generate_image', + nodeType: 'action', + position: { x: 237, y: 673 } + }, + { + id: 'node_generate_code', + label: 'generate_code', + nodeType: 'action', + position: { x: 470, y: 747 } + }, + { + id: 'node_answer_question', + label: 'answer_question', + nodeType: 'action', + position: { x: 1019, y: 724 } + }, + { + id: 'node_response', + label: 'response', + nodeType: 'action', + position: { x: 883, y: 909 } + }, + { + id: 'input_prompt', + label: 'input: prompt', + nodeType: 'input', + position: { x: 790, y: -29 } + }, + { + id: 'input_model', + label: 'input: model', + nodeType: 'input', + position: { x: 884, y: 549 } + }, + { + id: 'input_display_type', + label: 'input: display_type', + nodeType: 'input', + position: { x: 1086, y: 551 } + } + ], + edges: [ + { + id: 'e-input_prompt-node_prompt', + source: 'input_prompt', + target: 'node_prompt', + isConditional: false + }, + { + id: 'e-node_prompt-node_check_openai_key', + source: 'node_prompt', + target: 'node_check_openai_key', + isConditional: false + }, + { + id: 'e-node_check_openai_key-node_response', + source: 'node_check_openai_key', + target: 'node_response', + isConditional: false + }, + { + id: 'e-node_check_openai_key-node_check_safety', + source: 'node_check_openai_key', + target: 'node_check_safety', + condition: 'has_openai_key=True', + isConditional: true + }, + { + id: 'e-node_check_safety-node_decide_mode', + source: 'node_check_safety', + target: 'node_decide_mode', + condition: 'safe=True', + isConditional: true + }, + { + id: 'e-node_check_safety-node_response', + source: 'node_check_safety', + target: 'node_response', + isConditional: false + }, + { + id: 'e-node_decide_mode-node_prompt_for_more', + source: 'node_decide_mode', + target: 'node_prompt_for_more', + isConditional: false + }, + { + id: 'e-node_decide_mode-node_generate_image', + source: 'node_decide_mode', + target: 'node_generate_image', + condition: 'mode="generate_image"', + isConditional: true + }, + { + id: 'e-node_decide_mode-node_generate_code', + source: 'node_decide_mode', + target: 'node_generate_code', + condition: 'mode="generate_code"', + isConditional: true + }, + { + id: 'e-node_decide_mode-node_answer_question', + source: 'node_decide_mode', + target: 'node_answer_question', + condition: 'mode="answer_question"', + isConditional: true + }, + { + id: 'e-node_answer_question-node_response', + source: 'node_answer_question', + target: 'node_response', + isConditional: false + }, + { + id: 'e-node_generate_code-node_response', + source: 'node_generate_code', + target: 'node_response', + isConditional: false + }, + { + id: 'e-node_generate_image-node_response', + source: 'node_generate_image', + target: 'node_response', + isConditional: false + }, + { + id: 'e-node_prompt_for_more-node_response', + source: 'node_prompt_for_more', + target: 'node_response', + isConditional: false + }, + { + id: 'e-node_response-node_prompt', + source: 'node_response', + target: 'node_prompt', + isConditional: false + }, + { + id: 'e-input_model-node_generate_code', + source: 'input_model', + target: 'node_generate_code', + isConditional: false + }, + { + id: 'e-input_model-node_answer_question', + source: 'input_model', + target: 'node_answer_question', + isConditional: false + }, + { + id: 'e-input_model-node_generate_image', + source: 'input_model', + target: 'node_generate_image', + isConditional: false + }, + { + id: 'e-input_display_type-node_answer_question', + source: 'input_display_type', + target: 'node_answer_question', + isConditional: false + }, + { + id: 'e-input_display_type-node_generate_code', + source: 'input_display_type', + target: 'node_generate_code', + isConditional: false + } + ] +}; + +export const streamingChatbotWorkflow: ExampleGraph = { + id: 'streaming-chatbot', + title: 'Streaming Chatbot', + description: 'A ChatGPT-like bot which supports multiple response modes, with streaming actions.', + nodes: [ + { + id: 'input_prompt', + label: 'input: prompt', + nodeType: 'input', + position: { x: 500, y: 50 } + }, + { + id: 'input_model', + label: 'input: model', + nodeType: 'input', + position: { x: 84, y: 436 } + }, + { + id: 'prompt', + label: 'prompt', + nodeType: 'action', + position: { x: 500, y: 200 } + }, + { + id: 'check_safety', + label: 'check_safety', + nodeType: 'action', + position: { x: 500, y: 350 } + }, + { + id: 'decide_mode', + label: 'decide_mode', + nodeType: 'action', + position: { x: 400, y: 500 } + }, + { + id: 'unsafe_response', + label: 'unsafe_response', + nodeType: 'action', + isAsync: true, + isStreaming: true, + position: { x: 761, y: 496 } + }, + { + id: 'generate_code', + label: 'generate_code', + nodeType: 'action', + isAsync: true, + isStreaming: true, + position: { x: 90, y: 635 } + }, + { + id: 'answer_question', + label: 'answer_question', + nodeType: 'action', + isAsync: true, + isStreaming: true, + position: { x: 299, y: 713 } + }, + { + id: 'generate_poem', + label: 'generate_poem', + nodeType: 'action', + isAsync: true, + isStreaming: true, + position: { x: 565, y: 780 } + }, + { + id: 'prompt_for_more', + label: 'prompt_for_more', + nodeType: 'action', + isAsync: true, + isStreaming: true, + position: { x: 730, y: 677 } + } + ], + edges: [ + { + id: 'e-input_prompt-prompt', + source: 'input_prompt', + target: 'prompt', + isConditional: false + }, + { + id: 'e-input_model-generate_code', + source: 'input_model', + target: 'generate_code', + isConditional: false + }, + { + id: 'e-input_model-answer_question', + source: 'input_model', + target: 'answer_question', + isConditional: false + }, + { + id: 'e-input_model-generate_poem', + source: 'input_model', + target: 'generate_poem', + isConditional: false + }, + { + id: 'e-prompt-check_safety', + source: 'prompt', + target: 'check_safety', + isConditional: false + }, + { + id: 'e-check_safety-decide_mode', + source: 'check_safety', + target: 'decide_mode', + condition: 'safe=True', + isConditional: true + }, + { + id: 'e-check_safety-unsafe_response', + source: 'check_safety', + target: 'unsafe_response', + isConditional: false + }, + { + id: 'e-decide_mode-generate_code', + source: 'decide_mode', + target: 'generate_code', + condition: 'mode="generate_code"', + isConditional: true + }, + { + id: 'e-decide_mode-answer_question', + source: 'decide_mode', + target: 'answer_question', + condition: 'mode="answer_question"', + isConditional: true + }, + { + id: 'e-decide_mode-generate_poem', + source: 'decide_mode', + target: 'generate_poem', + condition: 'mode="generate_poem"', + isConditional: true + }, + { + id: 'e-decide_mode-prompt_for_more', + source: 'decide_mode', + target: 'prompt_for_more', + isConditional: false + }, + { + id: 'e-generate_code-prompt', + source: 'generate_code', + target: 'prompt', + isConditional: false + }, + { + id: 'e-answer_question-prompt', + source: 'answer_question', + target: 'prompt', + isConditional: false + }, + { + id: 'e-generate_poem-prompt', + source: 'generate_poem', + target: 'prompt', + isConditional: false + }, + { + id: 'e-unsafe_response-prompt', + source: 'unsafe_response', + target: 'prompt', + isConditional: false + }, + { + id: 'e-prompt_for_more-prompt', + source: 'prompt_for_more', + target: 'prompt', + isConditional: false + } + ] +}; + +export const adaptiveCRAGWorkflow: ExampleGraph = { + id: 'adaptive-crag', + title: 'Adaptive CRAG', + description: + 'A system that dynamically selects the most suitable route for a given user query and self-reflects on retrieved documents to improve response quality.', + nodes: [ + { + id: 'node_router', + label: 'router', + nodeType: 'action', + position: { x: 821, y: 177 } + }, + { + id: 'node_terminate', + label: 'terminate', + nodeType: 'action', + position: { x: 1115, y: 339 } + }, + { + id: 'node_rewrite_query', + label: 'rewrite_query_for_lancedb', + nodeType: 'action', + position: { x: 570, y: 343 } + }, + { + id: 'node_search_lancedb', + label: 'search_lancedb', + nodeType: 'action', + position: { x: 395, y: 474 } + }, + { + id: 'node_remove_irrelevant', + label: 'remove_irrelevant_lancedb_results', + nodeType: 'action', + position: { x: 222, y: 598 } + }, + { + id: 'node_extract_keywords', + label: 'extract_keywords_for_exa_search', + nodeType: 'action', + position: { x: 862, y: 755 } + }, + { + id: 'node_search_exa', + label: 'search_exa', + nodeType: 'action', + position: { x: 1014, y: 900 } + }, + { + id: 'node_ask_assistant', + label: 'ask_assistant', + nodeType: 'action', + position: { x: 664, y: 1050 } + }, + { + id: 'input_query', + label: 'input: query', + nodeType: 'input', + position: { x: 822, y: 26 } + } + ], + edges: [ + { + id: 'e-input_query-node_router', + source: 'input_query', + target: 'node_router', + isConditional: false + }, + { + id: 'e-node_router-node_rewrite_query', + source: 'node_router', + target: 'node_rewrite_query', + isConditional: false + }, + { + id: 'e-node_rewrite_query-node_search_lancedb', + source: 'node_rewrite_query', + target: 'node_search_lancedb', + isConditional: false + }, + { + id: 'e-node_search_lancedb-node_remove_irrelevant', + source: 'node_search_lancedb', + target: 'node_remove_irrelevant', + isConditional: false + }, + { + id: 'e-node_remove_irrelevant-node_ask_assistant', + source: 'node_remove_irrelevant', + target: 'node_ask_assistant', + isConditional: false + }, + { + id: 'e-node_remove_irrelevant-node_extract_keywords', + source: 'node_remove_irrelevant', + target: 'node_extract_keywords', + condition: 'len(lancedb_results) < docs_limit', + isConditional: true + }, + { + id: 'e-node_extract_keywords-node_search_exa', + source: 'node_extract_keywords', + target: 'node_search_exa', + isConditional: false + }, + { + id: 'e-node_search_exa-node_ask_assistant', + source: 'node_search_exa', + target: 'node_ask_assistant', + isConditional: false + }, + { + id: 'e-node_router-node_ask_assistant', + source: 'node_router', + target: 'node_ask_assistant', + condition: 'route="assistant"', + isConditional: true + }, + { + id: 'e-node_router-node_extract_keywords', + source: 'node_router', + target: 'node_extract_keywords', + condition: 'route="web_search"', + isConditional: true + }, + { + id: 'e-node_router-node_terminate', + source: 'node_router', + target: 'node_terminate', + condition: 'route="terminate"', + isConditional: true + } + ] +}; + +export const examples = [multiModalChatbotWorkflow, adaptiveCRAGWorkflow, streamingChatbotWorkflow]; diff --git a/telemetry/ui/src/components/routes/graph-builder/utils/BurrCodeGenerator.ts b/telemetry/ui/src/components/routes/graph-builder/utils/BurrCodeGenerator.ts new file mode 100644 index 00000000..16004ce4 --- /dev/null +++ b/telemetry/ui/src/components/routes/graph-builder/utils/BurrCodeGenerator.ts @@ -0,0 +1,321 @@ +/* + * 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 { BurrGraphJSON } from './GraphExporter'; + +type NodeRef = BurrGraphJSON['nodes'][0]; + +export class BurrGraphCodeGenerator { + static generatePythonCode(graphData: BurrGraphJSON): string { + const actionNodes = graphData.nodes.filter((n) => n.nodeType !== 'input'); + const hasAsync = actionNodes.some((n) => n.isAsync); + const hasStreaming = actionNodes.some((n) => n.isStreaming); + + const imports = this.generateImports(actionNodes, hasAsync, hasStreaming); + const actions = this.generateActions(graphData.nodes, graphData.edges); + const graphFunction = this.generateGraphFunction(graphData); + const main = this.generateMain(hasAsync, hasStreaming); + + return [imports, actions, graphFunction, main].join('\n\n'); + } + + private static generateImports( + actionNodes: NodeRef[], + hasAsync: boolean, + hasStreaming: boolean + ): string { + // Decorator imports + const decorators: string[] = ['action']; + if (hasStreaming) decorators.push('streaming_action'); + const actionImports = `from burr.core.action import ${decorators.join(', ')}`; + + // Typing imports + const typingParts: string[] = ['Tuple']; + if (hasStreaming) { + typingParts.push('Optional'); + if (hasAsync) typingParts.push('AsyncGenerator'); + else typingParts.push('Generator'); + } + const typingImports = `from typing import ${typingParts.join(', ')}`; + + const asyncioImport = hasAsync ? '\nimport asyncio' : ''; + + return `${typingImports} +from burr.core import State, default, when +${actionImports} +from burr.core.graph import GraphBuilder${asyncioImport}`; + } + + private static generateActions( + nodes: BurrGraphJSON['nodes'], + edges: BurrGraphJSON['edges'] + ): string { + const processNodes = nodes.filter((node) => node.nodeType !== 'input'); + + const actionFunctions = processNodes.map((node) => { + return this.generateAction(node, nodes, edges); + }); + + return actionFunctions.join('\n\n'); + } + + private static generateAction( + node: NodeRef, + nodes: BurrGraphJSON['nodes'], + edges: BurrGraphJSON['edges'] + ): string { + const functionName = this.sanitizeNodeName(node.label); + const inputParams = this.getInputParameters(node.id, nodes, edges); + const paramString = + inputParams.length > 0 ? `state: State, ${inputParams.join(', ')}` : 'state: State'; + + const isAsync = node.isAsync || false; + const isStreaming = node.isStreaming || false; + + const decorator = isStreaming + ? '@streaming_action(reads=[], writes=[])' + : '@action(reads=[], writes=[])'; + const asyncKeyword = isAsync ? 'async ' : ''; + + let returnType: string; + let body: string; + let docKind: string; + + if (isStreaming && isAsync) { + returnType = 'AsyncGenerator[Tuple[dict, Optional[State]], None]'; + body = ' yield {}, state'; + docKind = 'async streaming action'; + } else if (isStreaming) { + returnType = 'Generator[Tuple[dict, Optional[State]], None, None]'; + body = ' yield {}, state'; + docKind = 'streaming action'; + } else if (isAsync) { + returnType = 'Tuple[dict, State]'; + body = ' return {}, state'; + docKind = 'async action'; + } else { + returnType = 'Tuple[dict, State]'; + body = ' return {}, state'; + docKind = 'action'; + } + + const docstring = node.description + ? `\n """${node.description}\n\n This is a stub ${docKind}. Please complete with your business logic.\n """` + : `\n """Stub ${docKind}. Please complete with your business logic."""`; + + return `${decorator} +${asyncKeyword}def ${functionName}(${paramString}) -> ${returnType}:${docstring} +${body}`; + } + + private static getInputParameters( + nodeId: string, + nodes: BurrGraphJSON['nodes'], + edges: BurrGraphJSON['edges'] + ): string[] { + const inputParams: string[] = []; + + edges.forEach((edge) => { + if (edge.target === nodeId) { + const sourceNode = nodes.find((n) => n.id === edge.source); + if (sourceNode && sourceNode.nodeType === 'input') { + const paramName = sourceNode.label.replace(/^input:\s*/, '').trim(); + const sanitizedParam = this.sanitizeParameterName(paramName); + inputParams.push(`${sanitizedParam}: str`); + } + } + }); + + return inputParams; + } + + private static sanitizeParameterName(name: string): string { + return ( + name + .toLowerCase() + .replace(/[^a-z0-9]/g, '_') + .replace(/_+/g, '_') + .replace(/^_|_$/g, '') || 'param' + ); + } + + private static deduplicateNames(names: string[]): string[] { + const seen = new Map<string, number>(); + return names.map((name) => { + const count = seen.get(name) || 0; + seen.set(name, count + 1); + return count > 0 ? `${name}_${count}` : name; + }); + } + + private static generateGraphFunction(graphData: BurrGraphJSON): string { + const processNodes = graphData.nodes.filter((node) => node.nodeType !== 'input'); + const actionNames = this.deduplicateNames( + processNodes.map((node) => this.sanitizeNodeName(node.label)) + ); + const transitions = this.generateTransitions(graphData); + + const actionsString = actionNames.map((name) => ` ${name},`).join('\n'); + + return `def create_burr_graph(): + """Create the Burr graph for the project.""" + return ( + GraphBuilder() + .with_actions( +${actionsString} + ) + .with_transitions( +${transitions} + ) + .build() + )`; + } + + private static generateTransitions(graphData: BurrGraphJSON): string { + const transitions: string[] = []; + + const processEdges = graphData.edges.filter((edge) => { + const sourceNode = graphData.nodes.find((n) => n.id === edge.source); + const targetNode = graphData.nodes.find((n) => n.id === edge.target); + return sourceNode?.nodeType !== 'input' && targetNode?.nodeType !== 'input'; + }); + + const edgesBySource = new Map<string, typeof processEdges>(); + processEdges.forEach((edge) => { + if (!edgesBySource.has(edge.source)) { + edgesBySource.set(edge.source, []); + } + edgesBySource.get(edge.source)!.push(edge); + }); + + const allTransitions: Array<{ source: string; target: string; condition: string }> = []; + + edgesBySource.forEach((edges, sourceId) => { + const sourceNode = graphData.nodes.find((n) => n.id === sourceId); + if (!sourceNode || sourceNode.nodeType === 'input') return; + + const sourceName = this.sanitizeNodeName(sourceNode.label); + + if (edges.length === 1) { + const edge = edges[0]; + const targetNode = graphData.nodes.find((n) => n.id === edge.target); + if (targetNode && targetNode.nodeType !== 'input') { + const targetName = this.sanitizeNodeName(targetNode.label); + allTransitions.push({ source: sourceName, target: targetName, condition: 'default' }); + } + } else { + const conditionalEdges = edges.filter((e) => e.isConditional && e.condition); + const defaultEdges = edges.filter((e) => !e.isConditional || !e.condition); + + conditionalEdges.forEach((edge) => { + const targetNode = graphData.nodes.find((n) => n.id === edge.target); + if (targetNode && targetNode.nodeType !== 'input' && edge.condition) { + const targetName = this.sanitizeNodeName(targetNode.label); + allTransitions.push({ + source: sourceName, + target: targetName, + condition: `when(${edge.condition})` + }); + } + }); + + if (defaultEdges.length > 0) { + const defaultEdge = defaultEdges[0]; + const targetNode = graphData.nodes.find((n) => n.id === defaultEdge.target); + if (targetNode && targetNode.nodeType !== 'input') { + const targetName = this.sanitizeNodeName(targetNode.label); + allTransitions.push({ source: sourceName, target: targetName, condition: 'default' }); + } + } + } + }); + + // Group default transitions by target so multiple sources going to the same + // target can be collapsed into a single list-form transition. + const defaultsByTarget = new Map<string, string[]>(); + const conditionalTransitions: Array<{ source: string; target: string; condition: string }> = []; + + allTransitions.forEach(({ source, target, condition }) => { + if (condition === 'default') { + if (!defaultsByTarget.has(target)) { + defaultsByTarget.set(target, []); + } + defaultsByTarget.get(target)!.push(source); + } else { + conditionalTransitions.push({ source, target, condition }); + } + }); + + // Emit conditional transitions first + conditionalTransitions.forEach(({ source, target, condition }) => { + transitions.push(` ("${source}", "${target}", ${condition}),`); + }); + + // Emit default transitions (possibly grouped) + defaultsByTarget.forEach((sources, target) => { + if (sources.length === 1) { + transitions.push(` ("${sources[0]}", "${target}", default),`); + } else { + const sourceList = sources.map((s) => ` "${s}"`).join(',\n'); + transitions.push( + ` (\n [\n${sourceList}\n ],\n "${target}",\n ),` + ); + } + }); + + return transitions.join('\n'); + } + + private static generateMain(hasAsync: boolean, hasStreaming: boolean): string { + const needsAsyncMain = hasAsync || hasStreaming; + if (needsAsyncMain) { + return `graph = create_burr_graph() + + +async def main(): + """Run the Burr application. + + Uses async execution which handles both sync and async actions seamlessly. + """ + # action_, result, state = await app.arun(halt_after=[...]) + print("Burr graph created successfully.") + print(graph) + + +if __name__ == "__main__": + asyncio.run(main())`; + } + return `graph = create_burr_graph() + +if __name__ == "__main__": + print("Burr graph created successfully.") + print(graph) + # You can now use \`graph\` in your Burr application.`; + } + + private static sanitizeNodeName(label: string): string { + return ( + label + .toLowerCase() + .replace(/[^a-z0-9]/g, '_') + .replace(/_+/g, '_') + .replace(/^_|_$/g, '') || 'unnamed_node' + ); + } +} diff --git a/telemetry/ui/src/components/routes/graph-builder/utils/ExampleLoader.ts b/telemetry/ui/src/components/routes/graph-builder/utils/ExampleLoader.ts new file mode 100644 index 00000000..0492c47b --- /dev/null +++ b/telemetry/ui/src/components/routes/graph-builder/utils/ExampleLoader.ts @@ -0,0 +1,93 @@ +/* + * 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 { Node, Edge, MarkerType } from '@xyflow/react'; +import { ExampleGraph } from '../data/examples'; + +export class ExampleLoader { + static convertToReactFlow(example: ExampleGraph): { nodes: Node[]; edges: Edge[] } { + const nodes: Node[] = example.nodes.map((node, index) => ({ + id: node.id, + type: 'custom', + position: node.position, + data: { + label: node.label, + description: node.description || '', + nodeType: node.nodeType, + isAsync: node.isAsync || false, + isStreaming: node.isStreaming || false, + icon: 'settings', + colorIndex: index % 10 + } + })); + + const edges: Edge[] = example.edges.map((edge) => ({ + id: edge.id, + source: edge.source, + target: edge.target, + type: 'custom', + markerEnd: { + type: MarkerType.ArrowClosed, + width: 15, + height: 15, + color: '#429dbce6' + }, + data: { + condition: edge.condition, + isConditional: edge.isConditional, + label: edge.condition + } + })); + + return { nodes, edges }; + } + + static validateExample(example: ExampleGraph): string[] { + const errors: string[] = []; + + const nodeIds = new Set(example.nodes.map((n) => n.id)); + + example.edges.forEach((edge) => { + if (!nodeIds.has(edge.source)) { + errors.push(`Edge ${edge.id} references non-existent source node: ${edge.source}`); + } + if (!nodeIds.has(edge.target)) { + errors.push(`Edge ${edge.id} references non-existent target node: ${edge.target}`); + } + }); + + const uniqueNodeIds = new Set<string>(); + example.nodes.forEach((node) => { + if (uniqueNodeIds.has(node.id)) { + errors.push(`Duplicate node ID: ${node.id}`); + } + uniqueNodeIds.add(node.id); + }); + + const uniqueEdgeIds = new Set<string>(); + example.edges.forEach((edge) => { + if (uniqueEdgeIds.has(edge.id)) { + errors.push(`Duplicate edge ID: ${edge.id}`); + } + uniqueEdgeIds.add(edge.id); + }); + + return errors; + } +} diff --git a/telemetry/ui/src/components/routes/graph-builder/utils/GraphExporter.ts b/telemetry/ui/src/components/routes/graph-builder/utils/GraphExporter.ts new file mode 100644 index 00000000..0959b3d6 --- /dev/null +++ b/telemetry/ui/src/components/routes/graph-builder/utils/GraphExporter.ts @@ -0,0 +1,83 @@ +/* + * 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 { Node, Edge } from '@xyflow/react'; + +/** + * Interchange format for Burr graphs. This is the central data model that connects: + * - Visual graph editing (Graph Builder canvas) + * - Python code generation (BurrCodeGenerator) + * - Example loading (ExampleLoader) + * - Future: Pyodide-based Python AST parsing (code -> visual) + * - Future: Tracking API import (ApplicationModel -> BurrGraphJSON) + */ +export interface BurrGraphJSON { + version: string; + metadata: { + created: string; + title?: string; + description?: string; + }; + nodes: Array<{ + id: string; + label: string; + description?: string; + nodeType: 'input' | 'action'; + isAsync?: boolean; + isStreaming?: boolean; + position: { x: number; y: number }; + }>; + edges: Array<{ + id: string; + source: string; + target: string; + condition?: string; + isConditional: boolean; + }>; +} + +export class GraphExporter { + static exportToJSON(nodes: Node[], edges: Edge[]): BurrGraphJSON { + return { + version: '1.0.0', + metadata: { + created: new Date().toISOString(), + title: 'Burr Graph', + description: 'Generated from Burr Graph Builder' + }, + nodes: nodes.map((node) => ({ + id: node.id, + label: (node.data.label as string) || 'Unnamed Node', + description: (node.data.description as string) || undefined, + nodeType: + (node.data.nodeType as string) === 'input' ? ('input' as const) : ('action' as const), + isAsync: Boolean(node.data.isAsync) || undefined, + isStreaming: Boolean(node.data.isStreaming) || undefined, + position: node.position + })), + edges: edges.map((edge) => ({ + id: edge.id, + source: edge.source, + target: edge.target, + condition: (edge.data?.label as string) || (edge.data?.condition as string) || undefined, + isConditional: Boolean(edge.data?.isConditional) || false + })) + }; + } +}
