diff --git a/ui/web/package.json b/ui/web/package.json index db6d7493..a7dea4f4 100644 --- a/ui/web/package.json +++ b/ui/web/package.json @@ -29,6 +29,7 @@ "radix-ui": "^1.4.3", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-force-graph-2d": "^1.29.1", "react-i18next": "^17.0.1", "react-markdown": "^10.1.0", "react-router": "^7.13.2", diff --git a/ui/web/pnpm-lock.yaml b/ui/web/pnpm-lock.yaml index 2f301ea2..c9673d3a 100644 --- a/ui/web/pnpm-lock.yaml +++ b/ui/web/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: react-dom: specifier: ^19.2.4 version: 19.2.4(react@19.2.4) + react-force-graph-2d: + specifier: ^1.29.1 + version: 1.29.1(react@19.2.4) react-i18next: specifier: ^17.0.1 version: 17.0.1(i18next@26.0.1(typescript@6.0.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.2) @@ -1124,6 +1127,9 @@ packages: resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} engines: {node: '>=12'} + '@tweenjs/tween.js@25.0.0': + resolution: {integrity: sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -1235,6 +1241,10 @@ packages: '@xyflow/system@0.0.76': resolution: {integrity: sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==} + accessor-fn@1.5.3: + resolution: {integrity: sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==} + engines: {node: '>=12'} + aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} @@ -1242,6 +1252,13 @@ packages: bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + bezier-js@6.1.4: + resolution: {integrity: sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==} + + canvas-color-tracker@1.3.2: + resolution: {integrity: sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg==} + engines: {node: '>=12'} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -1289,6 +1306,9 @@ packages: resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} engines: {node: '>=12'} + d3-binarytree@1.0.2: + resolution: {integrity: sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==} + d3-color@3.1.0: resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} engines: {node: '>=12'} @@ -1305,6 +1325,10 @@ packages: resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} engines: {node: '>=12'} + d3-force-3d@3.0.6: + resolution: {integrity: sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==} + engines: {node: '>=12'} + d3-force@3.0.0: resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} engines: {node: '>=12'} @@ -1317,6 +1341,9 @@ packages: resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} engines: {node: '>=12'} + d3-octree@1.1.0: + resolution: {integrity: sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==} + d3-path@3.1.0: resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} engines: {node: '>=12'} @@ -1325,6 +1352,10 @@ packages: resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} engines: {node: '>=12'} + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + d3-scale@4.0.2: resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} engines: {node: '>=12'} @@ -1420,6 +1451,14 @@ packages: picomatch: optional: true + float-tooltip@1.7.5: + resolution: {integrity: sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==} + engines: {node: '>=12'} + + force-graph@1.51.2: + resolution: {integrity: sha512-zZNdMqx8qIQGurgnbgYIUsdXxSfvhfRSIdncsKGv/twUOZpwCsk9hPHmdjdcme1+epATgb41G0rkIGHJ0Wydng==} + engines: {node: '>=12'} + framer-motion@12.38.0: resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==} peerDependencies: @@ -1485,6 +1524,10 @@ packages: immer@11.1.4: resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==} + index-array-by@1.4.2: + resolution: {integrity: sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==} + engines: {node: '>=12'} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -1514,13 +1557,24 @@ packages: isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + jerrypick@1.1.2: + resolution: {integrity: sha512-YKnxXEekXKzhpf7CLYA0A+oDP8V0OhICNCr5lv96FvSsDEmrb0GKM776JgQvHTMjr7DTTPEVv/1Ciaw0uEWzBA==} + engines: {node: '>=12'} + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + kapsule@1.16.3: + resolution: {integrity: sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==} + engines: {node: '>=12'} + lie@3.3.0: resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} @@ -1598,9 +1652,16 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + lowlight@3.3.0: resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} @@ -1758,6 +1819,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} @@ -1779,9 +1844,15 @@ packages: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} + preact@10.29.1: + resolution: {integrity: sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==} + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} @@ -1803,6 +1874,12 @@ packages: peerDependencies: react: ^19.2.4 + react-force-graph-2d@1.29.1: + resolution: {integrity: sha512-1Rl/1Z3xy2iTHKj6a0jRXGyiI86xUti81K+jBQZ+Oe46csaMikp47L5AjrzA9hY9fNGD63X8ffrqnvaORukCuQ==} + engines: {node: '>=12'} + peerDependencies: + react: '*' + react-i18next@17.0.1: resolution: {integrity: sha512-iG65FGnFHcYyHNuT01ukffYWCOBFTWSdVD8EZd/dCVWgtjFPObcSsvYYNwcsokO/rDcTb5d6D8Acv8MrOdm6Hw==} peerDependencies: @@ -1819,9 +1896,18 @@ packages: typescript: optional: true + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@19.2.4: resolution: {integrity: sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==} + react-kapsule@2.5.7: + resolution: {integrity: sha512-kifAF4ZPD77qZKc4CKLmozq6GY1sBzPEJTIJb0wWFK6HsePJatK3jXplZn2eeAt3x67CDozgi7/rO8fNQ/AL7A==} + engines: {node: '>=12'} + peerDependencies: + react: '>=16.13.1' + react-markdown@10.1.0: resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} peerDependencies: @@ -1970,6 +2056,9 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinycolor2@1.6.0: + resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -3139,6 +3228,8 @@ snapshots: '@tanstack/table-core@8.21.3': {} + '@tweenjs/tween.js@25.0.0': {} + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -3257,12 +3348,20 @@ snapshots: d3-selection: 3.0.0 d3-zoom: 3.0.0 + accessor-fn@1.5.3: {} + aria-hidden@1.2.6: dependencies: tslib: 2.8.1 bail@2.0.2: {} + bezier-js@6.1.4: {} + + canvas-color-tracker@1.3.2: + dependencies: + tinycolor2: 1.6.0 + ccount@2.0.1: {} character-entities-html4@2.1.0: {} @@ -3295,6 +3394,8 @@ snapshots: dependencies: internmap: 2.0.3 + d3-binarytree@1.0.2: {} + d3-color@3.1.0: {} d3-dispatch@3.0.1: {} @@ -3306,6 +3407,14 @@ snapshots: d3-ease@3.0.1: {} + d3-force-3d@3.0.6: + dependencies: + d3-binarytree: 1.0.2 + d3-dispatch: 3.0.1 + d3-octree: 1.1.0 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + d3-force@3.0.0: dependencies: d3-dispatch: 3.0.1 @@ -3318,10 +3427,17 @@ snapshots: dependencies: d3-color: 3.1.0 + d3-octree@1.1.0: {} + d3-path@3.1.0: {} d3-quadtree@3.0.1: {} + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + d3-scale@4.0.2: dependencies: d3-array: 3.2.4 @@ -3404,6 +3520,30 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + float-tooltip@1.7.5: + dependencies: + d3-selection: 3.0.0 + kapsule: 1.16.3 + preact: 10.29.1 + + force-graph@1.51.2: + dependencies: + '@tweenjs/tween.js': 25.0.0 + accessor-fn: 1.5.3 + bezier-js: 6.1.4 + canvas-color-tracker: 1.3.2 + d3-array: 3.2.4 + d3-drag: 3.0.0 + d3-force-3d: 3.0.6 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + float-tooltip: 1.7.5 + index-array-by: 1.4.2 + kapsule: 1.16.3 + lodash-es: 4.18.1 + framer-motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: motion-dom: 12.38.0 @@ -3475,6 +3615,8 @@ snapshots: immer@11.1.4: {} + index-array-by@1.4.2: {} + inherits@2.0.4: {} inline-style-parser@0.2.7: {} @@ -3496,8 +3638,12 @@ snapshots: isarray@1.0.0: {} + jerrypick@1.1.2: {} + jiti@2.6.1: {} + js-tokens@4.0.0: {} + jszip@3.10.1: dependencies: lie: 3.3.0 @@ -3505,6 +3651,10 @@ snapshots: readable-stream: 2.3.8 setimmediate: 1.0.5 + kapsule@1.16.3: + dependencies: + lodash-es: 4.18.1 + lie@3.3.0: dependencies: immediate: 3.0.6 @@ -3558,8 +3708,14 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + lodash-es@4.18.1: {} + longest-streak@3.1.0: {} + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + lowlight@3.3.0: dependencies: '@types/hast': 3.0.4 @@ -3930,6 +4086,8 @@ snapshots: nanoid@3.3.11: {} + object-assign@4.1.1: {} + pako@1.0.11: {} parse-entities@4.0.2: @@ -3957,8 +4115,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + preact@10.29.1: {} + process-nextick-args@2.0.1: {} + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + property-information@7.1.0: {} radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): @@ -4029,6 +4195,13 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 + react-force-graph-2d@1.29.1(react@19.2.4): + dependencies: + force-graph: 1.51.2 + prop-types: 15.8.1 + react: 19.2.4 + react-kapsule: 2.5.7(react@19.2.4) + react-i18next@17.0.1(i18next@26.0.1(typescript@6.0.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.2): dependencies: '@babel/runtime': 7.29.2 @@ -4040,8 +4213,15 @@ snapshots: react-dom: 19.2.4(react@19.2.4) typescript: 6.0.2 + react-is@16.13.1: {} + react-is@19.2.4: {} + react-kapsule@2.5.7(react@19.2.4): + dependencies: + jerrypick: 1.1.2 + react: 19.2.4 + react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4): dependencies: '@types/hast': 3.0.4 @@ -4247,6 +4427,8 @@ snapshots: tiny-invariant@1.3.3: {} + tinycolor2@1.6.0: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.4) diff --git a/ui/web/src/pages/memory/hooks/use-knowledge-graph.ts b/ui/web/src/pages/memory/hooks/use-knowledge-graph.ts index 2eb4e659..94a51aac 100644 --- a/ui/web/src/pages/memory/hooks/use-knowledge-graph.ts +++ b/ui/web/src/pages/memory/hooks/use-knowledge-graph.ts @@ -140,7 +140,7 @@ export function useKGGraph(agentId: string, userId?: string) { queryKey: queryKeys.kg.graph(agentId, userId), queryFn: async () => { if (!agentId) return { entities: [], relations: [] }; - const params: Record = { limit: "80" }; + const params: Record = { limit: "150" }; if (userId) params.user_id = userId; return http.get(`/v1/agents/${agentId}/kg/graph`, params); }, diff --git a/ui/web/src/pages/memory/kg-graph-view.tsx b/ui/web/src/pages/memory/kg-graph-view.tsx index 38a3b418..29de180b 100644 --- a/ui/web/src/pages/memory/kg-graph-view.tsx +++ b/ui/web/src/pages/memory/kg-graph-view.tsx @@ -1,50 +1,22 @@ -import { useMemo, useEffect, useCallback, useState, useTransition, memo } from "react"; -import { - ReactFlow, - ReactFlowProvider, - useNodesState, - useEdgesState, - useReactFlow, - Background, - Controls, - type Node, - type Edge, - type ColorMode, - Handle, - Position, -} from "@xyflow/react"; -import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, forceX, forceY, type SimulationNodeDatum } from "d3-force"; -import "@xyflow/react/dist/style.css"; +import { useRef, useMemo, useCallback, useState, useEffect } from "react"; +import ForceGraph2D, { type ForceGraphMethods } from "react-force-graph-2d"; +import { Maximize2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; import { useTranslation } from "react-i18next"; import { useUiStore } from "@/stores/use-ui-store"; import type { KGEntity, KGRelation } from "@/types/knowledge-graph"; -const GRAPH_LIMIT = 80; +const GRAPH_LIMIT = 150; +const NODE_R = 5; +const DOUBLE_CLICK_MS = 280; -// Dual-theme palette — separate dark/light values for readability on both backgrounds -interface TypeColor { - border: string; - dark: { bg: string; text: string }; - light: { bg: string; text: string }; -} -const TYPE_COLORS: Record = { - person: { border: "#E85D24", dark: { bg: "rgba(232,93,36,0.15)", text: "#f4a574" }, light: { bg: "#fde8d8", text: "#7a2610" } }, - organization: { border: "#ef4444", dark: { bg: "rgba(239,68,68,0.15)", text: "#fca5a5" }, light: { bg: "#fee2e2", text: "#991b1b" } }, - project: { border: "#22c55e", dark: { bg: "rgba(34,197,94,0.15)", text: "#86efac" }, light: { bg: "#dcfce7", text: "#166534" } }, - product: { border: "#f97316", dark: { bg: "rgba(249,115,22,0.15)", text: "#fdba74" }, light: { bg: "#ffedd5", text: "#9a3412" } }, - technology: { border: "#3b82f6", dark: { bg: "rgba(59,130,246,0.15)", text: "#93c5fd" }, light: { bg: "#dbeafe", text: "#1e40af" } }, - task: { border: "#f59e0b", dark: { bg: "rgba(245,158,11,0.15)", text: "#fcd34d" }, light: { bg: "#fef3c7", text: "#92400e" } }, - event: { border: "#ec4899", dark: { bg: "rgba(236,72,153,0.15)", text: "#f9a8d4" }, light: { bg: "#fce7f3", text: "#9d174d" } }, - document: { border: "#8b5cf6", dark: { bg: "rgba(139,92,246,0.15)", text: "#c4b5fd" }, light: { bg: "#ede9fe", text: "#6d28d9" } }, - concept: { border: "#a78bfa", dark: { bg: "rgba(167,139,250,0.12)", text: "#c4b5fd" }, light: { bg: "#ede9fe", text: "#5b21b6" } }, - location: { border: "#14b8a6", dark: { bg: "rgba(20,184,166,0.15)", text: "#5eead4" }, light: { bg: "#ccfbf1", text: "#115e59" } }, +// Solid colors per entity type — used for both circle fill and glow +const TYPE_COLORS: Record = { + person: "#E85D24", organization: "#ef4444", project: "#22c55e", + product: "#f97316", technology: "#3b82f6", task: "#f59e0b", + event: "#ec4899", document: "#8b5cf6", concept: "#a78bfa", location: "#14b8a6", }; -const DEFAULT_TC: TypeColor = { border: "#9ca3af", dark: { bg: "rgba(156,163,175,0.12)", text: "#d1d5db" }, light: { bg: "#f3f4f6", text: "#374151" } }; - -const TYPE_MASS: Record = { - organization: 8, project: 6, product: 5, person: 4, technology: 3.5, concept: 3, location: 3, document: 2.5, event: 2, task: 1.5, -}; -const DEFAULT_MASS = 2; +const DEFAULT_COLOR = "#9ca3af"; function computeDegreeMap(entities: KGEntity[], relations: KGRelation[]): Map { const deg = new Map(); @@ -56,89 +28,24 @@ function computeDegreeMap(entities: KGEntity[], relations: KGRelation[]): Map= 4; - return ( -
- - -
- {data.label} -
-
- {data.type} -
-
- ); -}); - -const nodeTypes = { entity: EntityNode }; - -interface SimNode extends SimulationNodeDatum { id: string; mass: number } - -function buildGraph(entities: KGEntity[], relations: KGRelation[], isDark: boolean) { - const entityIds = new Set(entities.map((e) => e.id)); - const degreeMap = computeDegreeMap(entities, relations); - const nodes: Node[] = entities.map((e) => ({ - id: e.id, type: "entity", position: { x: 0, y: 0 }, - data: { label: e.name, type: e.entity_type, degree: degreeMap.get(e.id) ?? 0, isDark }, - })); - // Edge color handled by ReactFlow colorMode — use neutral gray - const edgeColor = "#64748b"; - const edges: Edge[] = relations - .filter((r) => entityIds.has(r.source_entity_id) && entityIds.has(r.target_entity_id)) - .map((r) => ({ - id: r.id, source: r.source_entity_id, target: r.target_entity_id, - label: undefined, data: { relationLabel: r.relation_type.replace(/_/g, " ") }, - animated: false, type: "default", - style: { stroke: edgeColor, strokeWidth: 2, opacity: 0.6 }, - })); - return { nodes, edges }; +// --- Graph data types for react-force-graph --- +interface GraphNode { + id: string; + name: string; + entityType: string; + color: string; + neighbors: Set; + linkIds: Set; + degree: number; + // Force-graph adds these at runtime + x?: number; + y?: number; } - -function computeForceLayout(nodes: Node[], edges: Edge[], entities: KGEntity[]): Node[] { - if (nodes.length === 0) return nodes; - const entityTypeMap = new Map(entities.map((e) => [e.id, e.entity_type])); - const simNodes: SimNode[] = nodes.map((n) => ({ - id: n.id, x: n.position.x, y: n.position.y, - mass: TYPE_MASS[entityTypeMap.get(n.id) ?? ""] ?? DEFAULT_MASS, - })); - const simLinks = edges.map((e) => ({ source: e.source, target: e.target })); - const w = 600, h = 400; - // Scale forces by node count — tighter for small graphs, spread for large - const n = nodes.length; - const linkDist = n > 150 ? 280 : n > 100 ? 240 : n > 60 ? 200 : n > 30 ? 160 : 120; - const chargeMul = n > 150 ? -350 : n > 100 ? -300 : n > 60 ? -250 : n > 30 ? -180 : -120; - const centerPull = n > 150 ? 0.015 : n > 100 ? 0.02 : n > 60 ? 0.03 : n > 30 ? 0.05 : 0.08; - const collideBase = n > 150 ? 60 : n > 100 ? 55 : n > 60 ? 50 : n > 30 ? 40 : 35; - // Barnes-Hut theta: higher = faster approximation, slightly less accurate - const theta = n > 60 ? 1.2 : 0.9; - const charge = forceManyBody().strength((d: any) => chargeMul * (d.mass ?? 1)).theta(theta); - const simulation = forceSimulation(simNodes) - .force("link", forceLink(simLinks).id((d: any) => d.id).distance(linkDist).strength(0.5)) - .force("charge", charge) - .force("center", forceCenter(w / 2, h / 2)) - .force("x", forceX(w / 2).strength(centerPull)) - .force("y", forceY(h / 2).strength(centerPull)) - .force("collide", forceCollide().radius((d: any) => collideBase + (d.mass ?? 1) * 3).strength(0.7)) - .alphaDecay(n > 60 ? 0.05 : 0.0228) // faster convergence for large graphs - .stop(); - const ticks = Math.ceil(Math.log(simulation.alphaMin()) / Math.log(1 - simulation.alphaDecay())); - for (let i = 0; i < ticks; i++) simulation.tick(); - return nodes.map((n, i) => ({ ...n, position: { x: simNodes[i]!.x ?? 0, y: simNodes[i]!.y ?? 0 } })); +interface GraphLink { + id: string; + source: string; + target: string; + label: string; } interface KGGraphViewProps { @@ -147,151 +54,236 @@ interface KGGraphViewProps { onEntityClick?: (entity: KGEntity) => void; } -export function KGGraphView(props: KGGraphViewProps) { - return ( - - - - ); -} - -function KGGraphViewInner({ entities: allEntities, relations: allRelations, onEntityClick }: KGGraphViewProps) { +export function KGGraphView({ entities: allEntities, relations: allRelations, onEntityClick }: KGGraphViewProps) { const { t } = useTranslation("memory"); - const { fitView } = useReactFlow(); const theme = useUiStore((s) => s.theme); - const colorMode: ColorMode = theme === "system" ? "system" : theme; const isDark = theme === "dark" || (theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches); + const graphRef = useRef>(undefined); + const containerRef = useRef(null); + const [dimensions, setDimensions] = useState({ width: 600, height: 400 }); const [selectedNodeId, setSelectedNodeId] = useState(null); - const [layoutReady, setLayoutReady] = useState(false); - // Limit entities to GRAPH_LIMIT, filter relations accordingly + // Double-click detection refs + const clickTimerRef = useRef | null>(null); + const lastClickRef = useRef(null); + + // --- Container sizing via ResizeObserver --- + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const ro = new ResizeObserver(([entry]) => { + if (!entry) return; + setDimensions({ width: entry.contentRect.width, height: entry.contentRect.height }); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + // --- Limit entities by degree centrality --- const totalCount = allEntities.length; const isLimited = totalCount > GRAPH_LIMIT; - // Rank by degree centrality — hub nodes (most connections) always included in graph const entities = useMemo(() => { if (!isLimited) return allEntities; const deg = computeDegreeMap(allEntities, allRelations); return [...allEntities].sort((a, b) => (deg.get(b.id) ?? 0) - (deg.get(a.id) ?? 0)).slice(0, GRAPH_LIMIT); }, [allEntities, allRelations, isLimited]); - const entityIds = useMemo(() => new Set(entities.map((e) => e.id)), [entities]); - const relations = useMemo( - () => allRelations.filter((r) => entityIds.has(r.source_entity_id) && entityIds.has(r.target_entity_id)), - [allRelations, entityIds], - ); - // Build graph — isDark only affects node data colors, not layout - const { rawNodes, rawEdges } = useMemo(() => { - const { nodes, edges } = buildGraph(entities, relations, isDark); - return { rawNodes: nodes, rawEdges: edges }; - }, [entities, relations, isDark]); - - const [layoutNodes, setLayoutNodes] = useState([]); - const [nodes, setNodes, onNodesChange] = useNodesState(layoutNodes); - const [edges, setEdges, onEdgesChange] = useEdgesState(rawEdges); - - // Track which entity set was laid out — only re-layout when data changes, not theme - const [layoutKey, setLayoutKey] = useState(""); - const dataKey = useMemo(() => entities.map((e) => e.id).join(","), [entities]); - - useEffect(() => { - if (dataKey === layoutKey) { - // Theme change only — update node colors without re-layout - const freshMap = new Map(rawNodes.map((r) => [r.id, r.data])); - setNodes((prev) => prev.map((n) => { - const data = freshMap.get(n.id); - return data ? { ...n, data } : n; - })); - return; - } - // Data changed — full re-layout - setLayoutReady(false); - const timer = setTimeout(() => { - const positioned = computeForceLayout(rawNodes, rawEdges, entities); - setLayoutNodes(positioned); - setNodes(positioned); - setLayoutKey(dataKey); - setLayoutReady(true); - requestAnimationFrame(() => fitView({ padding: 0.15, duration: 300 })); - }, 0); - return () => clearTimeout(timer); - }, [rawNodes, rawEdges, entities, dataKey, layoutKey, setNodes, fitView]); - - useEffect(() => { setEdges(rawEdges); }, [rawEdges, setEdges]); - - // Selection: highlight connected edges, dim others const entityMap = useMemo(() => new Map(entities.map((e) => [e.id, e])), [entities]); - useEffect(() => { - if (!selectedNodeId) { - setEdges((eds) => eds.map((e) => ({ - ...e, label: undefined, animated: false, - style: { stroke: "#64748b", strokeWidth: 2, opacity: 0.6 }, - labelStyle: undefined, labelBgStyle: undefined, labelBgPadding: undefined, labelShowBg: undefined, - }))); - return; - } - const ent = entityMap.get(selectedNodeId); - const tc = TYPE_COLORS[ent?.entity_type ?? ""] || DEFAULT_TC; - const labelColor = isDark ? tc.dark.text : tc.light.text; - const fadedEdge = isDark ? "#1e293b" : "#e2e8f0"; - const labelBg = isDark ? "rgba(15,23,42,0.85)" : "rgba(255,255,255,0.9)"; - setEdges((eds) => eds.map((e) => { - const connected = e.source === selectedNodeId || e.target === selectedNodeId; - if (!connected) return { ...e, label: undefined, animated: false, style: { stroke: fadedEdge, strokeWidth: 0.5, opacity: 0.25 } }; - return { - ...e, - label: (e.data as { relationLabel?: string })?.relationLabel, - animated: true, - style: { stroke: tc.border, strokeWidth: 3, opacity: 0.9 }, - labelStyle: { fontSize: 9, fill: labelColor, fontWeight: 500 }, - labelBgStyle: { fill: labelBg, stroke: tc.border, rx: 4 }, - labelBgPadding: [4, 2] as [number, number], - labelShowBg: true, - }; + // --- Build graph data for react-force-graph --- + const graphData = useMemo(() => { + const entityIds = new Set(entities.map((e) => e.id)); + const degreeMap = computeDegreeMap(entities, allRelations); + + const nodes: GraphNode[] = entities.map((e) => ({ + id: e.id, + name: e.name, + entityType: e.entity_type, + color: TYPE_COLORS[e.entity_type] ?? DEFAULT_COLOR, + neighbors: new Set(), + linkIds: new Set(), + degree: degreeMap.get(e.id) ?? 0, })); - }, [selectedNodeId, entityMap, isDark, setEdges]); - const [, startTransition] = useTransition(); + const nodeMap = new Map(nodes.map((n) => [n.id, n])); - // Single click: highlight connected edges only - const handleNodeClick = useCallback((_: React.MouseEvent, node: Node) => { - startTransition(() => setSelectedNodeId((prev) => (prev === node.id ? null : node.id))); - }, [startTransition]); + const links: GraphLink[] = allRelations + .filter((r) => entityIds.has(r.source_entity_id) && entityIds.has(r.target_entity_id)) + .map((r) => { + nodeMap.get(r.source_entity_id)?.neighbors.add(r.target_entity_id); + nodeMap.get(r.target_entity_id)?.neighbors.add(r.source_entity_id); + nodeMap.get(r.source_entity_id)?.linkIds.add(r.id); + nodeMap.get(r.target_entity_id)?.linkIds.add(r.id); + return { + id: r.id, + source: r.source_entity_id, + target: r.target_entity_id, + label: r.relation_type.replace(/_/g, " "), + }; + }); - // Double click: open entity detail modal - const handleNodeDoubleClick = useCallback((_: React.MouseEvent, node: Node) => { - const entity = entityMap.get(node.id); - if (entity && onEntityClick) onEntityClick(entity); - }, [entityMap, onEntityClick]); + return { nodes, links }; + }, [entities, allRelations]); - const handlePaneClick = useCallback(() => { - startTransition(() => setSelectedNodeId(null)); - }, [startTransition]); + // --- Highlight sets derived from selected node --- + const { highlightNodes, highlightLinks } = useMemo(() => { + if (!selectedNodeId) return { highlightNodes: new Set(), highlightLinks: new Set() }; + const node = graphData.nodes.find((n) => n.id === selectedNodeId); + if (!node) return { highlightNodes: new Set(), highlightLinks: new Set() }; + return { + highlightNodes: new Set([selectedNodeId, ...node.neighbors]), + highlightLinks: new Set(node.linkIds), + }; + }, [selectedNodeId, graphData.nodes]); + // --- Node Canvas rendering: colored circle + name label --- + const nodeCanvasObject = useCallback( + (node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) => { + const x = node.x ?? 0; + const y = node.y ?? 0; + const isSelected = node.id === selectedNodeId; + const isNeighbor = highlightNodes.has(node.id); + const isDimmed = !!selectedNodeId && !isSelected && !isNeighbor; + const r = NODE_R + (node.degree >= 4 ? 2 : 0); + + // Circle fill + ctx.beginPath(); + ctx.arc(x, y, r, 0, 2 * Math.PI); + ctx.fillStyle = isDimmed ? `${node.color}30` : node.color; + ctx.fill(); + + // Selection glow ring + if (isSelected) { + ctx.strokeStyle = node.color; + ctx.lineWidth = 2.5 / globalScale; + ctx.shadowColor = node.color; + ctx.shadowBlur = 8 / globalScale; + ctx.stroke(); + ctx.shadowBlur = 0; + } else if (isNeighbor) { + ctx.strokeStyle = node.color; + ctx.lineWidth = 1.5 / globalScale; + ctx.stroke(); + } + + // Name label — show when zoomed enough or highlighted + if (globalScale > 0.5 || isSelected || isNeighbor) { + const fontSize = Math.max(11 / globalScale, 2); + ctx.font = `${isSelected ? "bold " : ""}${fontSize}px Inter, system-ui, sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + ctx.fillStyle = isDimmed + ? (isDark ? "rgba(255,255,255,0.12)" : "rgba(0,0,0,0.12)") + : (isDark ? "#e2e8f0" : "#1e293b"); + ctx.fillText(node.name, x, y + r + 2 / globalScale); + } + }, + [selectedNodeId, highlightNodes, isDark], + ); + + // --- Link canvas overlay: draw label text only for highlighted links --- + const linkCanvasObject = useCallback( + (link: any, ctx: CanvasRenderingContext2D, globalScale: number) => { + if (!highlightLinks.has(link.id)) return; + const src = link.source; + const tgt = link.target; + if (!src?.x || !tgt?.x) return; + const midX = (src.x + tgt.x) / 2; + const midY = (src.y + tgt.y) / 2; + const fontSize = Math.max(9 / globalScale, 2); + ctx.font = `${fontSize}px Inter, system-ui, sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + // Background pill for readability + const text = link.label || ""; + const metrics = ctx.measureText(text); + const pad = 2 / globalScale; + ctx.fillStyle = isDark ? "rgba(15,23,42,0.85)" : "rgba(255,255,255,0.9)"; + ctx.fillRect(midX - metrics.width / 2 - pad, midY - fontSize / 2 - pad, metrics.width + pad * 2, fontSize + pad * 2); + // Text + ctx.fillStyle = isDark ? "#94a3b8" : "#64748b"; + ctx.fillText(text, midX, midY); + }, + [highlightLinks, isDark], + ); + + // --- Click handlers: single (highlight) vs double (open detail) --- + const handleNodeClick = useCallback( + (node: GraphNode) => { + if (clickTimerRef.current && lastClickRef.current === node.id) { + // Double click → open detail modal + clearTimeout(clickTimerRef.current); + clickTimerRef.current = null; + lastClickRef.current = null; + const entity = entityMap.get(node.id); + if (entity && onEntityClick) onEntityClick(entity); + return; + } + // Single click → highlight (after delay to distinguish from double) + if (clickTimerRef.current) clearTimeout(clickTimerRef.current); + lastClickRef.current = node.id; + clickTimerRef.current = setTimeout(() => { + setSelectedNodeId((prev) => (prev === node.id ? null : node.id)); + clickTimerRef.current = null; + }, DOUBLE_CLICK_MS); + }, + [entityMap, onEntityClick], + ); + + const handleBackgroundClick = useCallback(() => { + setSelectedNodeId(null); + }, []); + + // --- Zoom to fit after engine stops --- + const handleEngineStop = useCallback(() => { + graphRef.current?.zoomToFit(300, 30); + }, []); + + // --- Empty state --- if (allEntities.length === 0) { return
{t("kg.graphView.empty")}
; } - if (!layoutReady) { - return
{t("kg.graphView.loading")}
; - } - return (
-
- - - - +
+ "replace"} + nodePointerAreaPaint={(node: GraphNode, color, ctx) => { + const r = NODE_R + (node.degree >= 4 ? 2 : 0) + 3; + ctx.beginPath(); + ctx.arc(node.x ?? 0, node.y ?? 0, r, 0, 2 * Math.PI); + ctx.fillStyle = color; + ctx.fill(); + }} + linkColor={(link: any) => + highlightLinks.has(link.id) ? "#64748b" : (isDark ? "#334155" : "#cbd5e1") + } + linkWidth={(link: any) => (highlightLinks.has(link.id) ? 2 : 0.5)} + linkCanvasObject={linkCanvasObject} + linkCanvasObjectMode={(link: any) => (highlightLinks.has(link.id) ? "after" : undefined)} + linkDirectionalParticles={(link: any) => (highlightLinks.has(link.id) ? 2 : 0)} + linkDirectionalParticleWidth={3} + linkDirectionalParticleSpeed={0.004} + onNodeClick={handleNodeClick} + onBackgroundClick={handleBackgroundClick} + onEngineStop={handleEngineStop} + backgroundColor="transparent" + d3AlphaDecay={0.04} + d3VelocityDecay={0.3} + warmupTicks={40} + cooldownTime={4000} + enableNodeDrag={true} + minZoom={0.1} + maxZoom={8} + />
{/* Stats bar */} @@ -303,6 +295,10 @@ function KGGraphViewInner({ entities: allEntities, relations: allRelations, onEn · {t("kg.graphView.limitNote", { limit: GRAPH_LIMIT, total: totalCount })} )} +
+
);