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

Load Example Graph

+
+ +
+

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

+ + {hasExistingContent && ( +
+

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

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

Example Graphs

+

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

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

{example.title}

+

{example.description}

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

No examples available yet.

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

Graph Builder

+

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

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

+ Quick Start +

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

+ Creating +

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

+ Editing +

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

+ Deleting +

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

+ Node Types +

+
+
+ +
+ Action +

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

+
+
+
+ +
+ Input +

+ External input passed into an action at runtime +

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

+ Action Flags +

+

+ Select a node to toggle these independently: +

+
+
+ + async + +

+ Makes the function async def +

+
+
+ + stream + +

+ Uses @streaming_action and + yields results +

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

+ Edge Types +

+
+
+ +
+ Default +

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

+
+
+
+ +
+ Conditional +

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

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

+ Design your Burr graph +

+

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

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

Add New Node

+
+
+ + setNodeDialogData({ ...nodeDialogData, label: e.target.value })} + className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ + + }} + >
)}
@@ -1178,7 +1187,8 @@ const AnnotationEditCreateForm = (props: {
@@ -1214,16 +1225,19 @@ const AnnotationEditCreateForm = (props: { */ export const AnnotationsViewContainer = () => { const { projectId } = useLocationParams(); - const { data: backendSpec } = useQuery(['backendSpec'], () => - DefaultService.getAppSpecApiV0MetadataAppSpecGet().then((response) => { - return response; - }) - ); + const { data: backendSpec } = useQuery({ + queryKey: ['backendSpec'], + queryFn: () => + DefaultService.getAppSpecApiV0MetadataAppSpecGet().then((response) => { + return response; + }) + }); // TODO -- use a skiptoken to bypass annotation loading if we don't need them - const { data, refetch } = useQuery(['annotations', projectId], () => - DefaultService.getAnnotationsApiV0ProjectIdAnnotationsGet(projectId as string) - ); + const { data, refetch } = useQuery({ + queryKey: ['annotations', projectId], + queryFn: () => DefaultService.getAnnotationsApiV0ProjectIdAnnotationsGet(projectId as string) + }); // dummy value as this will not be linked to if annotations are not supported if (data === undefined || backendSpec === undefined) return ; diff --git a/telemetry/ui/src/components/routes/app/AppView.tsx b/telemetry/ui/src/components/routes/app/AppView.tsx index 5a30d4da0..3608b3e15 100644 --- a/telemetry/ui/src/components/routes/app/AppView.tsx +++ b/telemetry/ui/src/components/routes/app/AppView.tsx @@ -25,7 +25,7 @@ import { AttributeModel, DefaultService } from '../../../api'; -import { useMutation, useQuery } from 'react-query'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { Loading } from '../../common/loading'; import { ApplicationTable } from './StepList'; import { TwoColumnLayout, TwoRowLayout } from '../../common/layout'; @@ -250,73 +250,67 @@ export const AppView = (props: { const [currentEditingAnnotationContext, setCurrentEditingAnnotationContext] = useState< AnnotationEditingContext | undefined >(undefined); - const { data: backendSpec } = useQuery(['backendSpec'], () => - DefaultService.getAppSpecApiV0MetadataAppSpecGet().then((response) => { - return response; - }) - ); - const { data, error } = useQuery( - ['steps', appID, partitionKey], - () => + const { data: backendSpec } = useQuery({ + queryKey: ['backendSpec'], + queryFn: () => + DefaultService.getAppSpecApiV0MetadataAppSpecGet().then((response) => { + return response; + }) + }); + const { data, error } = useQuery({ + queryKey: ['steps', appID, partitionKey], + queryFn: () => DefaultService.getApplicationLogsApiV0ProjectIdAppIdPartitionKeyAppsGet( projectId as string, appID as string, props.partitionKey !== null ? props.partitionKey : '__none__' ), - { - refetchInterval: autoRefresh ? REFRESH_INTERVAL : false, - enabled: shouldQuery - } - ); + refetchInterval: autoRefresh ? REFRESH_INTERVAL : false, + enabled: shouldQuery + }); - const { data: currentFocusStepsData } = useQuery( - ['steps', currentFocusAppID, currentFocusPartitionKey], - () => + const { data: currentFocusStepsData } = useQuery({ + queryKey: ['steps', currentFocusAppID, currentFocusPartitionKey], + queryFn: () => DefaultService.getApplicationLogsApiV0ProjectIdAppIdPartitionKeyAppsGet( projectId as string, currentFocusAppID as string, currentFocusPartitionKey !== null ? currentFocusPartitionKey : '__none__' ), - { - refetchInterval: autoRefresh ? REFRESH_INTERVAL : false, - enabled: currentFocusAppID !== appID && currentFocusAppID !== undefined - } - ); + refetchInterval: autoRefresh ? REFRESH_INTERVAL : false, + enabled: currentFocusAppID !== appID && currentFocusAppID !== undefined + }); // TODO -- use a skiptoken to bypass annotation loading if we don't need them - const { data: annotationsData, refetch: refetchAnnotationsData } = useQuery( - ['annotations', appID, partitionKey], - () => + const { data: annotationsData, refetch: refetchAnnotationsData } = useQuery({ + queryKey: ['annotations', appID, partitionKey], + queryFn: () => DefaultService.getAnnotationsApiV0ProjectIdAnnotationsGet( projectId as string, appID as string, partitionKey !== null ? partitionKey : '__none__' ), - { - refetchInterval: autoRefresh ? REFRESH_INTERVAL : false, - enabled: shouldQuery && props.allowAnnotations && backendSpec?.supports_annotations - } - ); + refetchInterval: autoRefresh ? REFRESH_INTERVAL : false, + enabled: shouldQuery && props.allowAnnotations && backendSpec?.supports_annotations + }); - const { data: currentFocusAnnotationsData } = useQuery( - ['annotations', currentFocusAppID, currentFocusPartitionKey], - () => + const { data: currentFocusAnnotationsData } = useQuery({ + queryKey: ['annotations', currentFocusAppID, currentFocusPartitionKey], + queryFn: () => DefaultService.getAnnotationsApiV0ProjectIdAnnotationsGet( projectId as string, currentFocusAppID as string, currentFocusPartitionKey !== null ? partitionKey : '__none__' ), - { - enabled: - shouldQuery && - props.allowAnnotations && - backendSpec?.supports_annotations && - currentFocusAppID !== appID && - currentFocusAppID !== undefined - } - ); + enabled: + shouldQuery && + props.allowAnnotations && + backendSpec?.supports_annotations && + currentFocusAppID !== appID && + currentFocusAppID !== undefined + }); - const createAnnotationMutation = useMutation( - (data: { + const createAnnotationMutation = useMutation({ + mutationFn: (data: { projectId: string; annotationData: AnnotationCreate; appID: string; @@ -330,16 +324,16 @@ export const AppView = (props: { data.sequenceID, data.annotationData ) - ); + }); - const updateAnnotationMutation = useMutation( - (data: { annotationID: number; annotationData: AnnotationUpdate }) => + const updateAnnotationMutation = useMutation({ + mutationFn: (data: { annotationID: number; annotationData: AnnotationUpdate }) => DefaultService.updateAnnotationApiV0ProjectIdAnnotationIdUpdateAnnotationsPut( projectId, data.annotationID, data.annotationData ) - ); + }); useEffect(() => { const steps = data?.steps || []; @@ -503,13 +497,15 @@ export const AppView = (props: { return response; }, refreshAnnotationData: refetchAnnotationsData - }}> + }} + >
+ className={`w-full ${fullScreen ? 'h-full' : props.orientation === 'stacked_vertical' ? 'h-full' : 'h-1/2'}`} + > { return ( @@ -177,7 +177,8 @@ const CommonTableRow = (props: { }); } e.stopPropagation(); - }}> + }} + > {props.children} ); @@ -252,7 +253,8 @@ const ActionTableRow = (props: { setCurrentHoverIndex={setCurrentHoverIndex} setCurrentSelectedIndex={setCurrentSelectedIndex} appID={props.appID} - partitionKey={props.partitionKey}> + partitionKey={props.partitionKey} + >
@@ -270,7 +272,8 @@ const ActionTableRow = (props: { />
+ className={`${props.minimized ? 'w-32' : 'w-72 max-w-72'} flex flex-row justify-start gap-1 items-center`} + > { - const { data } = useQuery( - ['steps', props.projectID, props.appID, props.partitionKey], - () => + const { data } = useQuery({ + queryKey: ['steps', props.projectID, props.appID, props.partitionKey], + queryFn: () => DefaultService.getApplicationLogsApiV0ProjectIdAppIdPartitionKeyAppsGet( props.projectId as string, props.appID as string, props.partitionKey !== null ? props.partitionKey : '__none__' ), - { - // TODO -- decide how we want to auto-refresh with lots of nested stuff? - // Really, we'll want a bulk API but this is OK for now... - refetchInterval: props.autoRefresh ? REFRESH_INTERVAL : false, - enabled: true - } - ); + // TODO -- decide how we want to auto-refresh with lots of nested stuff? + // Really, we'll want a bulk API but this is OK for now... + refetchInterval: props.autoRefresh ? REFRESH_INTERVAL : false, + enabled: true + }); // TODO -- use a skiptoken to bypass annotation loading if we don't need them - const { data: annotationsData } = useQuery( - ['annotations', props.projectID, props.appID, props.partitionKey], - () => + const { data: annotationsData } = useQuery({ + queryKey: ['annotations', props.projectID, props.appID, props.partitionKey], + queryFn: () => DefaultService.getAnnotationsApiV0ProjectIdAnnotationsGet( props.projectId as string, props.appID as string, props.partitionKey !== null ? props.partitionKey : '__none__' ) - ); + }); const [traceExpandedActions, setTraceExpandedActions] = useState([]); const [linksExpandedActions, setLinksExpandedActions] = useState([]); @@ -520,7 +521,8 @@ const LinkSubTable = (props: { currentSelectedIndex={currentSelectedIndex} step={props.step} setCurrentHoverIndex={setCurrentHoverIndex} - setCurrentSelectedIndex={setCurrentSelectedIndex}> + setCurrentSelectedIndex={setCurrentSelectedIndex} + > @@ -528,7 +530,8 @@ const LinkSubTable = (props: { + className={`${normalText} w-48 min-w-48 max-w-48 truncate pl-9`} + >
{ @@ -536,7 +539,8 @@ const LinkSubTable = (props: { `/project/${props.projectId}/${subApp.child.partition_key || 'null'}/${subApp.child.app_id}` ); e.stopPropagation(); - }}> + }} + > {subApp.child.app_id}
@@ -663,9 +667,11 @@ const StepSubTableRow = (props: { currentSelectedIndex={currentSelectedIndex} step={props.step} setCurrentHoverIndex={setCurrentHoverIndex} - setCurrentSelectedIndex={setCurrentSelectedIndex}> + setCurrentSelectedIndex={setCurrentSelectedIndex} + > + className={` ${lightText} w-10 min-w-10 ${props.displaySpanID ? '' : 'text-opacity-0'}`} + > {spanIDUniqueToAction} @@ -674,12 +680,14 @@ const StepSubTableRow = (props: { <> + className={`${normalText} ${props.minimized ? 'w-32 min-w-32' : 'w-72 max-w-72'} flex flex-col`} + >
{[...Array(depth).keys()].map((i) => ( + className={`${i === depth - 1 ? 'opacity-0' : 'opacity-0'} text-lg text-gray-600 w-4 flex-shrink-0`} + > ))} { e.stopPropagation(); - }}> + }} + > { props.setSubActionExpanded?.(!props.isSubActionExpanded); e.stopPropagation(); - }}> + }} + >
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)}> + onMouseLeave={() => setIsHovered(false)} + > @@ -1152,7 +1163,8 @@ const WaterfallPiece: React.FC<{ style={{ left: `calc(${leftPositionPercentage}% - 20px)`, width: `calc(${widthPercentage}% + 40px)` // 20px buffer on each side - }}>
+ }} + >
{
+ }} + > {hoverItem}
} @@ -1823,7 +1836,8 @@ const ParentLink = (props: {
+ to={`/project/${props.projectId}/${props.parentPointer.partition_key}/${props.parentPointer.app_id}`} + > {props.parentPointer.app_id} @ diff --git a/telemetry/ui/src/examples/Chatbot.tsx b/telemetry/ui/src/examples/Chatbot.tsx index 8f63cbfeb..cfa84b4be 100644 --- a/telemetry/ui/src/examples/Chatbot.tsx +++ b/telemetry/ui/src/examples/Chatbot.tsx @@ -23,7 +23,7 @@ import { Button } from '../components/common/button'; import { TwoColumnLayout } from '../components/common/layout'; import { ApplicationSummary, ChatItem, DefaultService } from '../api'; import { KeyboardEvent, useEffect, useState } from 'react'; -import { useMutation, useQuery } from 'react-query'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { Loading } from '../components/common/loading'; import Markdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -99,7 +99,8 @@ const ChatMessage = (props: { message: ChatItem; id?: string }) => { a: ({ ...props }) => }} remarkPlugins={[remarkGfm]} - className={`whitespace-pre-wrap break-lines max-w-full ${props.message.type === ChatItem.type.ERROR ? 'bg-dwred/10' : ''} p-0.5`}> + className={`whitespace-pre-wrap break-lines max-w-full ${props.message.type === ChatItem.type.ERROR ? 'bg-dwred/10' : ''} p-0.5`} + > {props.message.content} ) : ( @@ -128,41 +129,40 @@ const scrollToLatest = () => { export const Chatbot = (props: { projectId: string; appId: string | undefined }) => { const [currentPrompt, setCurrentPrompt] = useState(''); const [displayedChatHistory, setDisplayedChatHistory] = useState(DEFAULT_CHAT_HISTORY); - const { isLoading } = useQuery( + const { data: chatHistoryData, isLoading } = useQuery({ // TODO -- handle errors - ['chat', props.projectId, props.appId], - () => + queryKey: ['chat', props.projectId, props.appId], + queryFn: () => DefaultService.chatHistoryApiV0ChatbotResponseProjectIdAppIdGet( props.projectId, props.appId || '' // TODO -- find a cleaner way of doing a skip-token like thing here ), - { - enabled: props.appId !== undefined, - onSuccess: (data) => { - setDisplayedChatHistory(data); // when its succesful we want to set the displayed chat history - } + enabled: props.appId !== undefined + }); + + useEffect(() => { + if (chatHistoryData) { + setDisplayedChatHistory(chatHistoryData); } - ); + }, [chatHistoryData]); // Scroll to the latest message when the chat history changes useEffect(() => { scrollToLatest(); }, [displayedChatHistory]); - const mutation = useMutation( - (message: string) => { + const mutation = useMutation({ + mutationFn: (message: string) => { return DefaultService.chatResponseApiV0ChatbotResponseProjectIdAppIdPost( props.projectId, props.appId || '', message ); }, - { - onSuccess: (data) => { - setDisplayedChatHistory(data); - } + onSuccess: (data) => { + setDisplayedChatHistory(data); } - ); + }); if (isLoading) { return ; @@ -181,7 +181,7 @@ export const Chatbot = (props: { projectId: string; appId: string | undefined }) ]); } }; - const isChatWaiting = mutation.isLoading; + const isChatWaiting = mutation.isPending; return (

{'Learn Burr '}

@@ -194,7 +194,8 @@ export const Chatbot = (props: { projectId: string; appId: string | undefined }) + id={i === displayedChatHistory.length - 1 ? LAST_MESSAGE_ID : undefined} + > ))}
@@ -217,7 +218,8 @@ export const Chatbot = (props: { projectId: string; appId: string | undefined }) disabled={isChatWaiting || props.appId === undefined} onClick={() => { sendPrompt(); - }}> + }} + > Send
@@ -240,6 +242,7 @@ export const ChatbotWithTelemetry = () => { createNewApp={DefaultService.createNewApplicationApiV0ChatbotCreateProjectIdAppIdPost} /> } - mode={'third'}> + mode={'third'} + > ); }; diff --git a/telemetry/ui/src/examples/Common.tsx b/telemetry/ui/src/examples/Common.tsx index 4993a6d4f..da1d3bcca 100644 --- a/telemetry/ui/src/examples/Common.tsx +++ b/telemetry/ui/src/examples/Common.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { useMutation, useQuery } from 'react-query'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { ApplicationSummary } from '../api/models/ApplicationSummary'; import { DateTimeDisplay } from '../components/common/dates'; import { MiniTelemetry } from './MiniTelemetry'; @@ -46,7 +46,8 @@ export const TelemetryWithSelector = (props: { + partitionKey={null} + >
); }; @@ -71,26 +72,25 @@ export const ChatbotAppSelector = (props: { createNewApp: CreateNewApp; }) => { const { projectId, setApp } = props; - const { data, refetch } = useQuery( - ['apps', projectId], + const { data, refetch } = useQuery({ + queryKey: ['apps', projectId], // TODO - use the right partition key - () => DefaultService.getAppsApiV0ProjectIdPartitionKeyAppsGet(projectId as string, '__none__'), - { enabled: projectId !== undefined } - ); - const createAndUpdateMutation = useMutation( - (app_id: string) => props.createNewApp(projectId, app_id), - { - onSuccess: (appID) => { - refetch().then((data) => { - const appSummaries = data.data?.applications || []; - const app = appSummaries.find((app) => app.app_id === appID); - if (app) { - setApp(app); - } - }); - } + queryFn: () => + DefaultService.getAppsApiV0ProjectIdPartitionKeyAppsGet(projectId as string, '__none__'), + enabled: projectId !== undefined + }); + const createAndUpdateMutation = useMutation({ + mutationFn: (app_id: string) => props.createNewApp(projectId, app_id), + onSuccess: (appID) => { + refetch().then((data) => { + const appSummaries = data.data?.applications || []; + const app = appSummaries.find((app) => app.app_id === appID); + if (app) { + setApp(app); + } + }); } - ); + }); const appSummaries = data?.applications || []; const appSetter = (appID: string) => createAndUpdateMutation.mutate(appID); const dataOrEmpty = Array.from(appSummaries || []); diff --git a/telemetry/ui/src/examples/Counter.tsx b/telemetry/ui/src/examples/Counter.tsx index 63ca5916d..3bea40775 100644 --- a/telemetry/ui/src/examples/Counter.tsx +++ b/telemetry/ui/src/examples/Counter.tsx @@ -20,48 +20,47 @@ import { Button } from '../components/common/button'; import { TwoColumnLayout } from '../components/common/layout'; import { ApplicationSummary, DefaultService } from '../api'; -import { useState } from 'react'; -import { useMutation, useQuery } from 'react-query'; +import { useEffect, useState } from 'react'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { Loading } from '../components/common/loading'; import { TelemetryWithSelector } from './Common'; const CounterApp = (props: { projectId: string; appId: string | undefined }) => { const [counterValue, setCounterValue] = useState(0); - const { isLoading } = useQuery( - ['counter-state', props.projectId, props.appId], - () => + const { data: counterData, isLoading } = useQuery({ + queryKey: ['counter-state', props.projectId, props.appId], + queryFn: () => DefaultService.getCounterStateApiV0CounterStateProjectIdAppIdGet( props.projectId, props.appId || '' ), - { - enabled: props.appId !== undefined, - onSuccess: (data) => { - setCounterValue(data.counter); - } + enabled: props.appId !== undefined + }); + + useEffect(() => { + if (counterData) { + setCounterValue(counterData.counter); } - ); + }, [counterData]); - const mutation = useMutation( - () => { + const mutation = useMutation({ + mutationFn: () => { return DefaultService.countApiV0CounterCountProjectIdAppIdPost( props.projectId, props.appId || '' ); }, - { - onSuccess: (data) => { - setCounterValue(data.counter); - } + onSuccess: (data) => { + setCounterValue(data.counter); } - ); + }); if (isLoading) { return ; } - const isWaiting = mutation.isLoading; + const isWaiting = mutation.isPending; return (
@@ -76,7 +75,8 @@ const CounterApp = (props: { projectId: string; appId: string | undefined }) => className="w-40 cursor-pointer text-lg" color="dark" disabled={isWaiting || props.appId === undefined} - onClick={() => mutation.mutate()}> + onClick={() => mutation.mutate()} + > {isWaiting ? 'Counting...' : 'Count +1'} {props.appId === undefined && ( diff --git a/telemetry/ui/src/examples/DeepResearcher.tsx b/telemetry/ui/src/examples/DeepResearcher.tsx index a9c0ecd07..94e0c8f0a 100644 --- a/telemetry/ui/src/examples/DeepResearcher.tsx +++ b/telemetry/ui/src/examples/DeepResearcher.tsx @@ -22,7 +22,7 @@ import { ApplicationSummary, DefaultService, ResearchSummary } from '../api'; import { TwoColumnLayout } from '../components/common/layout'; import { TelemetryWithSelector } from './Common'; import { Button } from '../components/common/button'; -import { useMutation, useQuery } from 'react-query'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { Text } from '../components/common/text'; import { Field, Label } from '../components/common/fieldset'; @@ -43,25 +43,23 @@ export const ResearchSummaryView = (props: { summary: string }) => { export const DeepResearcher = (props: { projectId: string; appId: string | undefined }) => { const [currentTopic, setCurrentTopic] = useState(''); const [displayedReport, setDisplayedReport] = useState(DEFAULT_RESEARCH_SUMMARY); - const { data: validationData, isLoading: isValidationLoading } = useQuery( - ['valid'], - DefaultService.validateEnvironmentApiV0DeepResearcherValidateGet - ); + const { data: validationData, isLoading: isValidationLoading } = useQuery({ + queryKey: ['valid'], + queryFn: DefaultService.validateEnvironmentApiV0DeepResearcherValidateGet + }); - const mutation = useMutation( - (topic: string) => { + const mutation = useMutation({ + mutationFn: (topic: string) => { return DefaultService.researchResponseApiV0DeepResearcherResponseProjectIdAppIdPost( props.projectId, props.appId || '', topic ); }, - { - onSuccess: (data) => { - setDisplayedReport(data); - } + onSuccess: (data) => { + setDisplayedReport(data); } - ); + }); const sendTopic = () => { if (currentTopic !== '') { mutation.mutate(currentTopic); @@ -69,7 +67,7 @@ export const DeepResearcher = (props: { projectId: string; appId: string | undef setDisplayedReport(DEFAULT_RESEARCH_SUMMARY); } }; - const isResearcherWaiting = mutation.isLoading || isValidationLoading; + const isResearcherWaiting = mutation.isPending || isValidationLoading; const displayValidationError = validationData !== null; return ( @@ -110,7 +108,8 @@ export const DeepResearcher = (props: { projectId: string; appId: string | undef disabled={isResearcherWaiting || props.appId === undefined || displayValidationError} onClick={() => { sendTopic(); - }}> + }} + > Send
@@ -134,6 +133,7 @@ export const DeepResearcherWithTelemetry = () => { } /> } - mode={'third'}> + mode={'third'} + > ); }; diff --git a/telemetry/ui/src/examples/EmailAssistant.tsx b/telemetry/ui/src/examples/EmailAssistant.tsx index 5aa801a97..08b853555 100644 --- a/telemetry/ui/src/examples/EmailAssistant.tsx +++ b/telemetry/ui/src/examples/EmailAssistant.tsx @@ -28,7 +28,7 @@ import { QuestionAnswers } from '../api'; import { useEffect, useState } from 'react'; -import { useMutation, useQuery } from 'react-query'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { Loading } from '../components/common/loading'; import { Field, Label } from '../components/common/fieldset'; import { Textarea } from '../components/common/textarea'; @@ -86,7 +86,8 @@ export const InitialDraftView = (props: { email_to_respond: userProvidedEmailToRespond, response_instructions: userProvidedResponseInstructions }) - }> + } + > {'Submit'} )} @@ -129,7 +130,8 @@ export const SubmitAnswersView = (props: { color="white" className="cursor-pointer w-full" disabled={props.isLoading} - onClick={() => props.submitAnswers({ answers: answers })}> + onClick={() => props.submitAnswers({ answers: answers })} + > {'Submit'} )} @@ -181,14 +183,16 @@ export const SubmitFeedbackView = (props: { disabled={props.isLoading} onClick={() => { props.submitFeedback({ feedback: feedback }), setFeedback(''); - }}> + }} + > {'Submit Feedback'}
@@ -201,10 +205,10 @@ export const SubmitFeedbackView = (props: { export const EmailAssistant = (props: { projectId: string; appId: string | undefined }) => { // starts off as null const [emailAssistantState, setEmailAssistantState] = useState(null); - const { data: validationData, isLoading: isValidationLoading } = useQuery( - ['valid', props.projectId, props.appId], - DefaultService.validateEnvironmentApiV0EmailAssistantValidateProjectIdAppIdGet - ); + const { data: validationData, isLoading: isValidationLoading } = useQuery({ + queryKey: ['valid', props.projectId, props.appId], + queryFn: DefaultService.validateEnvironmentApiV0EmailAssistantValidateProjectIdAppIdGet + }); useEffect(() => { if (props.appId !== undefined) { @@ -220,69 +224,64 @@ export const EmailAssistant = (props: { projectId: string; appId: string | undef } }, [props.appId, props.projectId]); // This will only get called when the appID or projectID changes, which will be at the beginning - const { isLoading: isGetInitialStateLoading } = useQuery( + const { data: initialStateData, isLoading: isGetInitialStateLoading } = useQuery({ // TODO -- handle errors - ['emailAssistant', props.projectId, props.appId], - () => + queryKey: ['emailAssistant', props.projectId, props.appId], + queryFn: () => DefaultService.getStateApiV0EmailAssistantStateProjectIdAppIdGet( props.projectId, props.appId || '' // TODO -- find a cleaner way of doing a skip-token like thing here // This is skipped if the appId is undefined so this is just to make the type-checker happy ), - { - enabled: props.appId !== undefined, - onSuccess: (data) => { - setEmailAssistantState(data); // when its succesful we want to set the displayed chat history - } + enabled: props.appId !== undefined + }); + + useEffect(() => { + if (initialStateData) { + setEmailAssistantState(initialStateData); } - ); + }, [initialStateData]); - const submitInitialMutation = useMutation( - (draftData: DraftInit) => + const submitInitialMutation = useMutation({ + mutationFn: (draftData: DraftInit) => DefaultService.initializeDraftApiV0EmailAssistantCreateProjectIdAppIdPost( props.projectId, props.appId || 'create_new', draftData ), - { - onSuccess: (data) => { - setEmailAssistantState(data); - } + onSuccess: (data) => { + setEmailAssistantState(data); } - ); + }); - const submitAnswersMutation = useMutation( - (answers: QuestionAnswers) => + const submitAnswersMutation = useMutation({ + mutationFn: (answers: QuestionAnswers) => DefaultService.answerQuestionsApiV0EmailAssistantAnswerQuestionsProjectIdAppIdPost( props.projectId, props.appId || '', answers ), - { - onSuccess: (data) => { - setEmailAssistantState(data); - } + onSuccess: (data) => { + setEmailAssistantState(data); } - ); - const submitFeedbackMutation = useMutation( - (feedbacks: Feedback) => + }); + const submitFeedbackMutation = useMutation({ + mutationFn: (feedbacks: Feedback) => DefaultService.provideFeedbackApiV0EmailAssistantProvideFeedbackProjectIdAppIdPost( props.projectId, props.appId || '', feedbacks ), - { - onSuccess: (data) => { - setEmailAssistantState(data); - } + onSuccess: (data) => { + setEmailAssistantState(data); } - ); + }); const isLoading = isGetInitialStateLoading; const anyMutationLoading = - submitInitialMutation.isLoading || - submitAnswersMutation.isLoading || - submitFeedbackMutation.isLoading; + submitInitialMutation.isPending || + submitAnswersMutation.isPending || + submitFeedbackMutation.isPending; if (isLoading || isValidationLoading) { return ; @@ -371,14 +370,14 @@ export const TelemetryWithSelector = (props: { projectId={props.projectId} setApp={props.setCurrentApp} currentApp={props.currentApp} - placeholder={ - 'Select a conversation or create a new one by typing...' - }> + placeholder={'Select a conversation or create a new one by typing...'} + >
+ partitionKey={null} + >
); }; @@ -402,29 +401,28 @@ export const EmailAssistantAppSelector = (props: { placeholder: string; }) => { const { projectId, setApp } = props; - const { data, refetch } = useQuery( - ['apps', projectId], - () => DefaultService.getAppsApiV0ProjectIdPartitionKeyAppsGet(projectId as string, '__none__'), - { enabled: projectId !== undefined } - ); - const createAndUpdateMutation = useMutation( - (app_id: string) => + const { data, refetch } = useQuery({ + queryKey: ['apps', projectId], + queryFn: () => + DefaultService.getAppsApiV0ProjectIdPartitionKeyAppsGet(projectId as string, '__none__'), + enabled: projectId !== undefined + }); + const createAndUpdateMutation = useMutation({ + mutationFn: (app_id: string) => DefaultService.createNewApplicationApiV0EmailAssistantCreateNewProjectIdAppIdPost( projectId, app_id ), - { - onSuccess: (appID) => { - refetch().then((data) => { - const appSummaries = data.data?.applications || []; - const app = appSummaries.find((app) => app.app_id === appID); - if (app) { - setApp(app); - } - }); - } + onSuccess: (appID) => { + refetch().then((data) => { + const appSummaries = data.data?.applications || []; + const app = appSummaries.find((app) => app.app_id === appID); + if (app) { + setApp(app); + } + }); } - ); + }); const appSetter = (appID: string) => createAndUpdateMutation.mutate(appID); const dataOrEmpty = Array.from(data?.applications || []); const options = dataOrEmpty @@ -468,6 +466,7 @@ export const EmailAssistantWithTelemetry = () => { setCurrentApp={setCurrentApp} /> } - mode={'third'}> + mode={'third'} + > ); }; diff --git a/telemetry/ui/src/examples/StreamingChatbot.tsx b/telemetry/ui/src/examples/StreamingChatbot.tsx index e006ab019..28fc6f648 100644 --- a/telemetry/ui/src/examples/StreamingChatbot.tsx +++ b/telemetry/ui/src/examples/StreamingChatbot.tsx @@ -23,7 +23,7 @@ import { Button } from '../components/common/button'; import { TwoColumnLayout } from '../components/common/layout'; import { ApplicationSummary, ChatItem, DefaultService } from '../api'; import { KeyboardEvent, useEffect, useState } from 'react'; -import { useQuery } from 'react-query'; +import { useQuery } from '@tanstack/react-query'; import { Loading } from '../components/common/loading'; import Markdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -95,7 +95,8 @@ const ChatMessage = (props: { message: ChatItem; id?: string }) => { a: ({ ...props }) => }} remarkPlugins={[remarkGfm]} - className={`whitespace-pre-wrap break-lines max-w-full ${props.message.type === ChatItem.type.ERROR ? 'bg-dwred/10' : ''} p-0.5`}> + className={`whitespace-pre-wrap break-lines max-w-full ${props.message.type === ChatItem.type.ERROR ? 'bg-dwred/10' : ''} p-0.5`} + > {props.message.content} ) : ( @@ -150,31 +151,39 @@ export const StreamingChatbot = (props: { projectId: string; appId: string | und const [currentResponse, setCurrentResponse] = useState(''); const [isChatWaiting, setIsChatWaiting] = useState(false); - const { isLoading: isInitialLoading } = useQuery( + const { + data: chatHistoryData, + error: chatHistoryError, + isLoading: isInitialLoading + } = useQuery({ // TODO -- handle errors - ['chat', props.projectId, props.appId], - () => + queryKey: ['chat', props.projectId, props.appId], + queryFn: () => DefaultService.chatHistoryApiV0StreamingChatbotHistoryProjectIdAppIdGet( props.projectId, props.appId || '' // TODO -- find a cleaner way of doing a skip-token like thing here ), - { - enabled: props.appId !== undefined, - onSuccess: (data) => { - setDisplayedChatHistory(data); // when its succesful we want to set the displayed chat history - }, - onError: (error: Error) => { - setDisplayedChatHistory([ - ...DEFAULT_CHAT_HISTORY, - { - content: `Unable to load from server: ${error.message}`, - role: ChatItem.role.ASSISTANT, - type: ChatItem.type.ERROR - } - ]); - } + enabled: props.appId !== undefined + }); + + useEffect(() => { + if (chatHistoryData) { + setDisplayedChatHistory(chatHistoryData); } - ); + }, [chatHistoryData]); + + useEffect(() => { + if (chatHistoryError) { + setDisplayedChatHistory([ + ...DEFAULT_CHAT_HISTORY, + { + content: `Unable to load from server: ${(chatHistoryError as Error).message}`, + role: ChatItem.role.ASSISTANT, + type: ChatItem.type.ERROR + } + ]); + } + }, [chatHistoryError]); const submitPrompt = async () => { setCurrentResponse(''); // Reset it @@ -292,7 +301,8 @@ export const StreamingChatbot = (props: { projectId: string; appId: string | und disabled={isChatWaiting || props.appId === undefined} onClick={() => { submitPrompt(); - }}> + }} + > Send
@@ -317,6 +327,7 @@ export const StreamingChatbotWithTelemetry = () => { } /> } - mode={'third'}> + mode={'third'} + > ); };