diff --git a/application/api/user/sources/routes.py b/application/api/user/sources/routes.py index b45bc603..f70e97f0 100644 --- a/application/api/user/sources/routes.py +++ b/application/api/user/sources/routes.py @@ -1169,3 +1169,115 @@ class EnableSourceGraphRAG(Resource): return make_response( jsonify({"success": True, "task_id": task.id}), 200 ) + + +def _graph_overview_payload(source_id, limit): + """Return a bounded ``{nodes, edges}`` overview for a graphrag source. + + An empty graph (extraction pending/capped/failed) yields empty lists rather + than an error, mirroring the ClassicRAG degradation guarantee. + """ + from application.graphrag.store import GraphStore + + store = GraphStore() + if store.count_nodes(source_id) == 0: + return {"nodes": [], "edges": []} + return store.get_graph_overview(source_id, limit) + + +@sources_ns.route("/sources//graph") +class SourceGraph(Resource): + @api.doc( + description="Bounded knowledge-graph overview for a graphrag source: " + "top nodes by degree and the edges among them (read access: owner or " + "shared). Returns empty lists when no graph has been built yet." + ) + def get(self, source_id): + decoded_token = request.decoded_token + if not decoded_token: + return make_response(jsonify({"success": False}), 401) + user = decoded_token.get("sub") + try: + limit = int(request.args.get("limit", 0)) or None + except (TypeError, ValueError): + limit = None + try: + with db_readonly() as conn: + doc = _resolve_readable_source(conn, source_id, user) + if doc is None: + return make_response( + jsonify({"success": False, "message": "Source not found"}), + 404, + ) + resolved_source_id = str(doc["id"]) + except Exception as err: + current_app.logger.error( + f"Error resolving source {source_id} for graph: {err}", + exc_info=True, + ) + return make_response(jsonify({"success": False}), 400) + try: + from application.graphrag.store import GRAPH_OVERVIEW_DEFAULT_LIMIT + + overview = _graph_overview_payload( + resolved_source_id, limit or GRAPH_OVERVIEW_DEFAULT_LIMIT + ) + except Exception as err: + current_app.logger.error( + f"Error building graph overview for {source_id}: {err}", + exc_info=True, + ) + return make_response(jsonify({"success": False}), 400) + return make_response( + jsonify( + { + "success": True, + "nodes": overview["nodes"], + "edges": overview["edges"], + } + ), + 200, + ) + + +@sources_ns.route("/sources//graph/node/") +class SourceGraphNode(Resource): + @api.doc( + description="A graph node's description and its linked chunks (read " + "access: owner or shared)." + ) + def get(self, source_id, node_id): + decoded_token = request.decoded_token + if not decoded_token: + return make_response(jsonify({"success": False}), 401) + user = decoded_token.get("sub") + try: + with db_readonly() as conn: + doc = _resolve_readable_source(conn, source_id, user) + if doc is None: + return make_response( + jsonify({"success": False, "message": "Source not found"}), + 404, + ) + resolved_source_id = str(doc["id"]) + except Exception as err: + current_app.logger.error( + f"Error resolving source {source_id} for graph node: {err}", + exc_info=True, + ) + return make_response(jsonify({"success": False}), 400) + try: + from application.graphrag.store import GraphStore + + node = GraphStore().get_node_detail(resolved_source_id, node_id) + except Exception as err: + current_app.logger.error( + f"Error fetching graph node {node_id} for {source_id}: {err}", + exc_info=True, + ) + return make_response(jsonify({"success": False}), 400) + if node is None: + return make_response( + jsonify({"success": False, "message": "Node not found"}), 404 + ) + return make_response(jsonify({"success": True, "node": node}), 200) diff --git a/application/graphrag/store.py b/application/graphrag/store.py index 3da835a6..7e03453e 100644 --- a/application/graphrag/store.py +++ b/application/graphrag/store.py @@ -24,6 +24,9 @@ DEFAULT_NAME_EMBEDDING_DIM = 768 MAX_SUBGRAPH_NODES = 500 MAX_SUBGRAPH_EDGES = 2000 +GRAPH_OVERVIEW_DEFAULT_LIMIT = 100 +GRAPH_OVERVIEW_MAX_LIMIT = 250 + PGVECTOR_SOURCE_COLUMN = "source_id" @@ -376,6 +379,7 @@ class GraphStore: return None finally: cursor.close() + conn.rollback() def count_nodes(self, source_id: str) -> int: """Number of nodes for a source. Zero drives the ClassicRAG fallback (G5).""" @@ -392,6 +396,7 @@ class GraphStore: return 0 finally: cursor.close() + conn.rollback() def search_nodes_by_embedding( self, source_id: str, query_embedding: List[float], k: int = 10 @@ -426,6 +431,7 @@ class GraphStore: return [] finally: cursor.close() + conn.rollback() def get_subgraph( self, source_id: str, node_ids: List[str], hops: int = 1 @@ -519,6 +525,71 @@ class GraphStore: return {"nodes": [], "edges": []} finally: cursor.close() + conn.rollback() + + def get_graph_overview( + self, source_id: str, limit: int = GRAPH_OVERVIEW_DEFAULT_LIMIT + ) -> Dict[str, List[Dict[str, Any]]]: + """Top-``limit`` nodes by degree and the edges among them. + + Bounds the visualization: the top nodes by degree are selected, then only + edges whose endpoints are both in that set are returned (edge ids + reference node ids). ``limit`` is clamped to ``GRAPH_OVERVIEW_MAX_LIMIT``. + """ + limit = max(1, min(int(limit), GRAPH_OVERVIEW_MAX_LIMIT)) + conn = self._get_connection() + cursor = conn.cursor() + try: + cursor.execute( + """ + SELECT id, name, type, description, degree + FROM graph_nodes + WHERE source_id = %s + ORDER BY degree DESC, id + LIMIT %s; + """, + (source_id, limit), + ) + nodes = [ + { + "id": str(row[0]), + "name": row[1], + "type": row[2], + "description": row[3], + "degree": row[4], + } + for row in cursor.fetchall() + ] + if not nodes: + return {"nodes": [], "edges": []} + + node_ids = [n["id"] for n in nodes] + cursor.execute( + """ + SELECT src_node_id, dst_node_id, type, weight + FROM graph_edges + WHERE source_id = %s + AND src_node_id = ANY(%s) AND dst_node_id = ANY(%s) + LIMIT %s; + """, + (source_id, node_ids, node_ids, MAX_SUBGRAPH_EDGES), + ) + edges = [ + { + "source": str(row[0]), + "target": str(row[1]), + "type": row[2], + "weight": row[3], + } + for row in cursor.fetchall() + ] + return {"nodes": nodes, "edges": edges} + except Exception as e: + logging.error(f"Error getting graph overview: {e}") + return {"nodes": [], "edges": []} + finally: + cursor.close() + conn.rollback() def get_chunk_ids_for_nodes( self, source_id: str, node_ids: List[str] @@ -545,6 +616,7 @@ class GraphStore: return {} finally: cursor.close() + conn.rollback() def get_chunk_texts( self, @@ -581,6 +653,63 @@ class GraphStore: return {} finally: cursor.close() + conn.rollback() + + def get_node_detail( + self, source_id: str, node_id: str, max_chunks: int = 20 + ) -> Optional[Dict[str, Any]]: + """A node's full record plus a bounded list of its linked chunks. + + Returns ``None`` when the node does not belong to the source. Chunk texts + are read from the co-located pgvector table; at most ``max_chunks`` are + returned so a hub node never streams an unbounded payload. + """ + conn = self._get_connection() + try: + cursor = conn.cursor() + try: + cursor.execute( + """ + SELECT id, name, type, description, degree, doc_freq + FROM graph_nodes + WHERE source_id = %s AND id = %s; + """, + (source_id, node_id), + ) + row = cursor.fetchone() + finally: + cursor.close() + if row is None: + return None + node = { + "id": str(row[0]), + "name": row[1], + "type": row[2], + "description": row[3], + "degree": row[4], + "doc_freq": row[5], + } + + chunk_ids = self.get_chunk_ids_for_nodes(source_id, [node_id]).get( + str(node_id), [] + )[: max(0, int(max_chunks))] + texts = ( + self.get_chunk_texts(source_id, chunk_ids) if chunk_ids else {} + ) + node["chunks"] = [ + { + "chunk_id": cid, + "text": texts.get(cid, {}).get("text", ""), + "metadata": texts.get(cid, {}).get("metadata", {}), + } + for cid in chunk_ids + ] + return node + except Exception as e: + logging.error(f"Error getting node detail: {e}") + return None + finally: + conn.rollback() def set_node_degrees(self, source_id: str): """Recompute every node's degree from its incident edges for a source. @@ -660,6 +789,7 @@ class GraphStore: return [str(c) for c in all_chunk_ids] finally: cursor.close() + conn.rollback() def get_progress(self, source_id: str) -> Dict[str, str]: conn = self._get_connection() @@ -676,6 +806,7 @@ class GraphStore: return {} finally: cursor.close() + conn.rollback() def delete_by_source(self, source_id: str): """Remove every graph row for a source (no FK cascade across clusters).""" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e3e3acbb..b783020e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -34,6 +34,7 @@ "react-day-picker": "^10.0.1", "react-dom": "^19.2.5", "react-dropzone": "^15.0.0", + "react-force-graph-2d": "^1.29.1", "react-google-drive-picker": "^1.2.2", "react-i18next": "^17.0.6", "react-markdown": "^9.0.1", @@ -4281,6 +4282,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@tweenjs/tween.js": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", + "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==", + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -4654,6 +4661,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -4663,7 +4671,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -5124,6 +5132,15 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/accessor-fn": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz", + "integrity": "sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -5459,6 +5476,16 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bezier-js": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/bezier-js/-/bezier-js-6.1.4.tgz", + "integrity": "sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==", + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://github.com/Pomax/bezierjs/blob/master/FUNDING.md" + } + }, "node_modules/brace-expansion": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", @@ -5620,6 +5647,18 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas-color-tracker": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/canvas-color-tracker/-/canvas-color-tracker-1.3.2.tgz", + "integrity": "sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg==", + "license": "MIT", + "dependencies": { + "tinycolor2": "^1.6.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -5974,6 +6013,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, "license": "MIT" }, "node_modules/cytoscape": { @@ -6086,6 +6126,12 @@ "node": ">=12" } }, + "node_modules/d3-binarytree": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz", + "integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==", + "license": "MIT" + }, "node_modules/d3-brush": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", @@ -6238,6 +6284,22 @@ "node": ">=12" } }, + "node_modules/d3-force-3d": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz", + "integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==", + "license": "MIT", + "dependencies": { + "d3-binarytree": "1", + "d3-dispatch": "1 - 3", + "d3-octree": "1", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-format": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", @@ -6280,6 +6342,12 @@ "node": ">=12" } }, + "node_modules/d3-octree": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz", + "integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==", + "license": "MIT" + }, "node_modules/d3-path": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", @@ -7727,6 +7795,20 @@ "dev": true, "license": "ISC" }, + "node_modules/float-tooltip": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/float-tooltip/-/float-tooltip-1.7.5.tgz", + "integrity": "sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==", + "license": "MIT", + "dependencies": { + "d3-selection": "2 - 3", + "kapsule": "^1.16", + "preact": "10" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -7743,6 +7825,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/force-graph": { + "version": "1.51.4", + "resolved": "https://registry.npmjs.org/force-graph/-/force-graph-1.51.4.tgz", + "integrity": "sha512-TdJ2KbkoiDQ7NIRx8IPGD0mAXXpLhamS7c+b7W98b0MHG7lphnda1VOQX/98UDTsttIAdH4TcP0l0MauSnLK8w==", + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "18 - 25", + "accessor-fn": "1", + "bezier-js": "3 - 6", + "canvas-color-tracker": "^1.3", + "d3-array": "1 - 3", + "d3-drag": "2 - 3", + "d3-force-3d": "2 - 3", + "d3-scale": "1 - 4", + "d3-scale-chromatic": "1 - 3", + "d3-selection": "2 - 3", + "d3-zoom": "2 - 3", + "float-tooltip": "^1.7", + "index-array-by": "1", + "kapsule": "^1.16", + "lodash-es": "4" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/format": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", @@ -8420,6 +8528,15 @@ "node": ">=0.8.19" } }, + "node_modules/index-array-by": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.2.tgz", + "integrity": "sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/inline-style-parser": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.6.tgz", @@ -8943,6 +9060,15 @@ "node": ">= 0.4" } }, + "node_modules/jerrypick": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/jerrypick/-/jerrypick-1.1.2.tgz", + "integrity": "sha512-YKnxXEekXKzhpf7CLYA0A+oDP8V0OhICNCr5lv96FvSsDEmrb0GKM776JgQvHTMjr7DTTPEVv/1Ciaw0uEWzBA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -9042,6 +9168,18 @@ "node": ">=4.0" } }, + "node_modules/kapsule": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/kapsule/-/kapsule-1.16.3.tgz", + "integrity": "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==", + "license": "MIT", + "dependencies": { + "lodash-es": "4" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/katex": { "version": "0.16.25", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.25.tgz", @@ -11116,6 +11254,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/preact": { + "version": "10.29.2", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz", + "integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -11459,6 +11607,23 @@ "react": ">= 16.8 || 18.0.0" } }, + "node_modules/react-force-graph-2d": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/react-force-graph-2d/-/react-force-graph-2d-1.29.1.tgz", + "integrity": "sha512-1Rl/1Z3xy2iTHKj6a0jRXGyiI86xUti81K+jBQZ+Oe46csaMikp47L5AjrzA9hY9fNGD63X8ffrqnvaORukCuQ==", + "license": "MIT", + "dependencies": { + "force-graph": "^1.51", + "prop-types": "15", + "react-kapsule": "^2.5" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-google-drive-picker": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/react-google-drive-picker/-/react-google-drive-picker-1.2.2.tgz", @@ -11502,6 +11667,21 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/react-kapsule": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-kapsule/-/react-kapsule-2.6.0.tgz", + "integrity": "sha512-HzLJoYb1n1kfwjXbqFFcRR0EA6oPsJ64tNdDmCSaL/bz2o9wUZRSb0cMe//grLFeF9EVoL4CD/e6ozLyzEv+PQ==", + "license": "MIT", + "dependencies": { + "jerrypick": "^1.1.2" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-markdown": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz", @@ -12706,6 +12886,12 @@ "dev": true, "license": "MIT" }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", @@ -12970,7 +13156,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/frontend/package.json b/frontend/package.json index 1a557b10..2b97bb44 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -47,6 +47,7 @@ "react-day-picker": "^10.0.1", "react-dom": "^19.2.5", "react-dropzone": "^15.0.0", + "react-force-graph-2d": "^1.29.1", "react-google-drive-picker": "^1.2.2", "react-i18next": "^17.0.6", "react-markdown": "^9.0.1", diff --git a/frontend/src/agents/workflow/WorkflowPreview.tsx b/frontend/src/agents/workflow/WorkflowPreview.tsx index 7caf9f32..abfb0d7c 100644 --- a/frontend/src/agents/workflow/WorkflowPreview.tsx +++ b/frontend/src/agents/workflow/WorkflowPreview.tsx @@ -526,7 +526,7 @@ export default function WorkflowPreview({ Workflow -
+
{queries.length === 0 ? (
diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 7cbbd07a..65a9dc15 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -59,6 +59,10 @@ const endpoints = { CREATE_WIKI: '/api/sources/wiki', CONVERT_TO_WIKI: (id: string) => `/api/sources/${id}/wiki/convert`, ENABLE_GRAPHRAG: (id: string) => `/api/sources/${id}/graphrag/enable`, + SOURCE_GRAPH: (id: string, limit?: number) => + `/api/sources/${id}/graph${limit ? `?limit=${limit}` : ''}`, + SOURCE_GRAPH_NODE: (id: string, nodeId: string) => + `/api/sources/${id}/graph/node/${encodeURIComponent(nodeId)}`, TASK_STATUS: (taskId: string) => `/api/task_status?task_id=${taskId}`, WIKI_PAGES: (id: string) => `/api/sources/${id}/wiki/pages`, WIKI_PAGE: (id: string, path: string) => diff --git a/frontend/src/api/services/userService.ts b/frontend/src/api/services/userService.ts index 121669a3..e3f49659 100644 --- a/frontend/src/api/services/userService.ts +++ b/frontend/src/api/services/userService.ts @@ -111,6 +111,21 @@ const userService = { apiClient.post(endpoints.USER.CONVERT_TO_WIKI(sourceId), {}, token), enableGraphRAG: (sourceId: string, token: string | null): Promise => apiClient.post(endpoints.USER.ENABLE_GRAPHRAG(sourceId), {}, token), + getSourceGraph: ( + sourceId: string, + token: string | null, + limit?: number, + ): Promise => + throttledApiClient.get(endpoints.USER.SOURCE_GRAPH(sourceId, limit), token), + getSourceGraphNode: ( + sourceId: string, + nodeId: string, + token: string | null, + ): Promise => + throttledApiClient.get( + endpoints.USER.SOURCE_GRAPH_NODE(sourceId, nodeId), + token, + ), getTaskStatus: (taskId: string, token: string | null): Promise => apiClient.get(endpoints.USER.TASK_STATUS(taskId), token), getWikiPages: (sourceId: string, token: string | null): Promise => diff --git a/frontend/src/components/GraphView.tsx b/frontend/src/components/GraphView.tsx new file mode 100644 index 00000000..7028b1ed --- /dev/null +++ b/frontend/src/components/GraphView.tsx @@ -0,0 +1,252 @@ +import { Network, X } from 'lucide-react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; +import ForceGraph2D from 'react-force-graph-2d'; + +import userService from '../api/services/userService'; +import ArrowLeft from '../assets/arrow-left.svg'; +import { selectToken } from '../preferences/preferenceSlice'; +import { Button } from './ui/button'; +import SkeletonLoader from './SkeletonLoader'; +import { + ForceGraphData, + GraphNode, + GraphNodeDetail, + GraphOverview, + maxDegree, + nodeLabelEl, + nodeRadius, + toForceGraphData, +} from './graphViewUtils'; + +interface GraphViewProps { + docId: string; + sourceName: string; + onBackToDocuments: () => void; +} + +const GRAPH_LIMIT = 100; + +const GraphView: React.FC = ({ + docId, + sourceName, + onBackToDocuments, +}) => { + const { t } = useTranslation(); + const token = useSelector(selectToken); + + const [data, setData] = useState({ nodes: [], links: [] }); + const [loading, setLoading] = useState(true); + const [selectedNode, setSelectedNode] = useState( + null, + ); + const [loadingNode, setLoadingNode] = useState(false); + + const containerRef = useRef(null); + const [size, setSize] = useState({ width: 0, height: 480 }); + + useEffect(() => { + let cancelled = false; + setLoading(true); + userService + .getSourceGraph(docId, token, GRAPH_LIMIT) + .then((response) => response.json()) + .then((body) => { + if (cancelled) return; + const overview: GraphOverview = { + nodes: body?.nodes ?? [], + edges: body?.edges ?? [], + }; + setData(toForceGraphData(overview)); + }) + .catch((error) => console.error('Error loading graph:', error)) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [docId, token]); + + useEffect(() => { + if (!containerRef.current) return; + const element = containerRef.current; + const observer = new ResizeObserver(() => { + setSize({ width: element.clientWidth, height: 480 }); + }); + observer.observe(element); + setSize({ width: element.clientWidth, height: 480 }); + return () => observer.disconnect(); + }, []); + + const maxNodeDegree = useMemo(() => maxDegree(data.nodes), [data.nodes]); + + const handleNodeClick = (node: GraphNode) => { + setLoadingNode(true); + setSelectedNode(null); + userService + .getSourceGraphNode(docId, node.id, token) + .then((response) => response.json()) + .then((body) => { + if (body?.node) setSelectedNode(body.node as GraphNodeDetail); + }) + .catch((error) => console.error('Error loading graph node:', error)) + .finally(() => setLoadingNode(false)); + }; + + const isEmpty = !loading && data.nodes.length === 0; + + return ( +
+
+ + + {sourceName} + +
+ +
+
+ + {loading ? ( + + ) : isEmpty ? ( +
+
+ ) : ( +
+
+ {size.width > 0 && ( + + nodeRadius((node as GraphNode).degree, maxNodeDegree) + } + nodeLabel={(node) => + nodeLabelEl( + (node as GraphNode).name, + ) as unknown as React.ReactHTMLElement + } + linkColor={() => 'rgba(150,150,150,0.35)'} + cooldownTicks={80} + onNodeClick={(node) => handleNodeClick(node as GraphNode)} + nodeCanvasObjectMode={() => 'after'} + nodeCanvasObject={(node, ctx, globalScale) => { + const graphNode = node as GraphNode & { + x?: number; + y?: number; + }; + if (globalScale < 1.2 || graphNode.x == null) return; + const label = graphNode.name ?? ''; + ctx.font = `${10 / globalScale}px sans-serif`; + ctx.fillStyle = 'rgba(120,120,120,0.95)'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.fillText( + label, + graphNode.x, + (graphNode.y ?? 0) + + nodeRadius(graphNode.degree, maxNodeDegree) / + globalScale + + 1, + ); + }} + /> + )} +
+ + +
+ )} +
+ ); +}; + +export default GraphView; diff --git a/frontend/src/components/graphViewUtils.test.ts b/frontend/src/components/graphViewUtils.test.ts new file mode 100644 index 00000000..9e723054 --- /dev/null +++ b/frontend/src/components/graphViewUtils.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; + +import { + GraphOverview, + maxDegree, + nodeLabelEl, + nodeRadius, + toForceGraphData, +} from './graphViewUtils'; + +describe('toForceGraphData', () => { + it('keeps nodes and maps edges to links', () => { + const overview: GraphOverview = { + nodes: [ + { id: 'a', name: 'A', degree: 2 }, + { id: 'b', name: 'B', degree: 1 }, + ], + edges: [{ source: 'a', target: 'b', type: 'rel', weight: 1 }], + }; + const data = toForceGraphData(overview); + expect(data.nodes).toHaveLength(2); + expect(data.links).toEqual([ + { source: 'a', target: 'b', type: 'rel', weight: 1 }, + ]); + }); + + it('drops edges that reference nodes outside the bounded set', () => { + const overview: GraphOverview = { + nodes: [{ id: 'a', name: 'A', degree: 1 }], + edges: [{ source: 'a', target: 'ghost' }], + }; + const data = toForceGraphData(overview); + expect(data.links).toEqual([]); + }); + + it('handles an empty graph', () => { + const data = toForceGraphData({ nodes: [], edges: [] }); + expect(data).toEqual({ nodes: [], links: [] }); + }); +}); + +describe('maxDegree', () => { + it('returns the highest degree', () => { + expect( + maxDegree([ + { id: 'a', name: 'A', degree: 3 }, + { id: 'b', name: 'B', degree: 7 }, + ]), + ).toBe(7); + }); + + it('returns 0 for an empty list', () => { + expect(maxDegree([])).toBe(0); + }); +}); + +describe('nodeLabelEl', () => { + it('renders an untrusted name as text, not live HTML', () => { + const payload = ''; + const el = nodeLabelEl(payload); + expect(el.textContent).toBe(payload); + expect(el.querySelector('img')).toBeNull(); + expect(el.innerHTML).not.toContain(' { + expect(nodeLabelEl('').textContent).toBe(''); + }); +}); + +describe('nodeRadius', () => { + it('returns the minimum radius when there is no spread', () => { + expect(nodeRadius(0, 0)).toBe(3); + }); + + it('scales monotonically with degree', () => { + const low = nodeRadius(1, 10); + const high = nodeRadius(9, 10); + expect(high).toBeGreaterThan(low); + expect(nodeRadius(10, 10)).toBeCloseTo(12); + }); +}); diff --git a/frontend/src/components/graphViewUtils.ts b/frontend/src/components/graphViewUtils.ts new file mode 100644 index 00000000..a91ed836 --- /dev/null +++ b/frontend/src/components/graphViewUtils.ts @@ -0,0 +1,62 @@ +export interface GraphNode { + id: string; + name: string; + type?: string | null; + description?: string | null; + degree: number; +} + +export interface GraphEdge { + source: string; + target: string; + type?: string | null; + weight?: number | null; +} + +export interface GraphOverview { + nodes: GraphNode[]; + edges: GraphEdge[]; +} + +export interface GraphNodeChunk { + chunk_id: string; + text: string; + metadata?: Record; +} + +export interface GraphNodeDetail extends GraphNode { + doc_freq?: number; + chunks: GraphNodeChunk[]; +} + +export interface ForceGraphData { + nodes: GraphNode[]; + links: GraphEdge[]; +} + +export function toForceGraphData(overview: GraphOverview): ForceGraphData { + const nodeIds = new Set(overview.nodes.map((node) => node.id)); + const links = overview.edges.filter( + (edge) => nodeIds.has(edge.source) && nodeIds.has(edge.target), + ); + return { nodes: overview.nodes, links }; +} + +const MIN_NODE_RADIUS = 3; +const MAX_NODE_RADIUS = 12; + +export function nodeRadius(degree: number, maxDegree: number): number { + if (maxDegree <= 0) return MIN_NODE_RADIUS; + const scale = Math.sqrt(Math.max(0, degree) / maxDegree); + return MIN_NODE_RADIUS + scale * (MAX_NODE_RADIUS - MIN_NODE_RADIUS); +} + +export function maxDegree(nodes: GraphNode[]): number { + return nodes.reduce((acc, node) => Math.max(acc, node.degree || 0), 0); +} + +export function nodeLabelEl(name: string): HTMLDivElement { + const el = document.createElement('div'); + el.textContent = name ?? ''; + return el; +} diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index eb489da6..f498395b 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -174,6 +174,16 @@ "forbidden": "You don't have permission to enable GraphRAG on this source.", "conflict": "This source is still ingesting. Wait for it to finish before enabling GraphRAG." } + }, + "view": { + "action": "View graph", + "title": "Knowledge graph", + "explainer": "Nodes are entities and edges are their relationships. Click a node to see its description and the chunks it was extracted from.", + "empty": "No graph yet — extraction may still be running.", + "selectNode": "Select a node to see its details.", + "linkedChunks": "Linked chunks", + "noChunks": "No linked chunks for this entity.", + "close": "Close" } }, "editConfig": "Source settings", diff --git a/frontend/src/settings/Sources.tsx b/frontend/src/settings/Sources.tsx index cdd01e8b..daae8e53 100644 --- a/frontend/src/settings/Sources.tsx +++ b/frontend/src/settings/Sources.tsx @@ -52,6 +52,7 @@ import FileTree from '../components/FileTree'; import ConnectorTree from '../components/ConnectorTree'; import Chunks from '../components/Chunks'; import WikiViewer from '../components/WikiViewer'; +import GraphView from '../components/GraphView'; import ConvertToWikiModal from './ConvertToWikiModal'; import EnableGraphRAGModal from './EnableGraphRAGModal'; import SourceConfigModal from './SourceConfigModal'; @@ -355,10 +356,12 @@ export default function Sources({ document.ownership !== 'team' || document.team_access === 'editor'; const actions: SourceMenuOption[] = [ { - icon: EyeView, + icon: isGraphRAG ? Network : EyeView, label: isWiki ? t('settings.sources.wiki.view') - : t('settings.sources.view'), + : isGraphRAG + ? t('settings.sources.graphrag.view.action') + : t('settings.sources.view'), onClick: () => { setDocumentToView(document); }, @@ -523,6 +526,12 @@ export default function Sources({ } onBackToDocuments={() => setDocumentToView(undefined)} /> + ) : documentToView.config?.kind === 'graphrag' ? ( + setDocumentToView(undefined)} + /> ) : documentToView.isNested ? ( documentToView.type === 'connector:file' ? (