mirror of
https://github.com/Egonex-AI/Understand-Anything.git
synced 2026-06-22 10:58:03 +08:00
feat(dashboard): add DomainGraphView with domain overview and detail views
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { validateGraph } from "@understand-anything/core/schema";
|
||||
import type { GraphIssue } from "@understand-anything/core/schema";
|
||||
import { useDashboardStore } from "./store";
|
||||
import GraphView from "./components/GraphView";
|
||||
import DomainGraphView from "./components/DomainGraphView";
|
||||
import CodeViewer from "./components/CodeViewer";
|
||||
import SearchBar from "./components/SearchBar";
|
||||
import NodeInfo from "./components/NodeInfo";
|
||||
@@ -448,7 +449,11 @@ function Dashboard({ accessToken }: { accessToken: string }) {
|
||||
<div className="flex-1 flex min-h-0 relative">
|
||||
{/* Graph area */}
|
||||
<div className="flex-1 min-w-0 min-h-0 relative">
|
||||
<GraphView />
|
||||
{viewMode === "domain" && domainGraph ? (
|
||||
<DomainGraphView />
|
||||
) : (
|
||||
<GraphView />
|
||||
)}
|
||||
<div className="absolute top-3 right-3 text-sm text-text-muted/60 pointer-events-none select-none">
|
||||
Press <kbd className="kbd">?</kbd> for keyboard shortcuts
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import {
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Controls,
|
||||
MiniMap,
|
||||
} from "@xyflow/react";
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
|
||||
import DomainClusterNode from "./DomainClusterNode";
|
||||
import FlowNode from "./FlowNode";
|
||||
import StepNode from "./StepNode";
|
||||
import { useDashboardStore } from "../store";
|
||||
import { useTheme } from "../themes/index.ts";
|
||||
import { applyDagreLayout } from "../utils/layout";
|
||||
import type { KnowledgeGraph, GraphNode } from "@understand-anything/core/types";
|
||||
|
||||
const nodeTypes = {
|
||||
"domain-cluster": DomainClusterNode,
|
||||
"flow-node": FlowNode,
|
||||
"step-node": StepNode,
|
||||
};
|
||||
|
||||
// Dimensions for domain-specific nodes
|
||||
const DOMAIN_NODE_DIMENSIONS = new Map<string, { width: number; height: number }>();
|
||||
|
||||
function getDomainMeta(node: GraphNode): Record<string, unknown> | undefined {
|
||||
return (node as any).domainMeta;
|
||||
}
|
||||
|
||||
function buildDomainOverview(graph: KnowledgeGraph): { nodes: Node[]; edges: Edge[] } {
|
||||
const domainNodes = graph.nodes.filter((n) => n.type === "domain");
|
||||
|
||||
// Count flows per domain
|
||||
const flowCountMap = new Map<string, number>();
|
||||
for (const edge of graph.edges) {
|
||||
if (edge.type === "contains_flow") {
|
||||
flowCountMap.set(edge.source, (flowCountMap.get(edge.source) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const rfNodes: Node[] = domainNodes.map((node) => {
|
||||
const meta = getDomainMeta(node);
|
||||
const data = {
|
||||
label: node.name,
|
||||
summary: node.summary,
|
||||
entities: meta?.entities as string[] | undefined,
|
||||
flowCount: flowCountMap.get(node.id) ?? 0,
|
||||
businessRules: meta?.businessRules as string[] | undefined,
|
||||
domainId: node.id,
|
||||
};
|
||||
DOMAIN_NODE_DIMENSIONS.set(node.id, { width: 320, height: 180 });
|
||||
return {
|
||||
id: node.id,
|
||||
type: "domain-cluster" as const,
|
||||
position: { x: 0, y: 0 },
|
||||
data,
|
||||
};
|
||||
});
|
||||
|
||||
const rfEdges: Edge[] = graph.edges
|
||||
.filter((e) => e.type === "cross_domain")
|
||||
.map((e) => ({
|
||||
id: `${e.source}-${e.target}`,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
label: e.description ?? "",
|
||||
style: { stroke: "var(--color-accent)", strokeDasharray: "6 3", strokeWidth: 2 },
|
||||
labelStyle: { fill: "var(--color-text-muted)", fontSize: 10 },
|
||||
animated: true,
|
||||
}));
|
||||
|
||||
return applyDagreLayout(rfNodes, rfEdges, "LR", DOMAIN_NODE_DIMENSIONS);
|
||||
}
|
||||
|
||||
function buildDomainDetail(
|
||||
graph: KnowledgeGraph,
|
||||
domainId: string,
|
||||
): { nodes: Node[]; edges: Edge[] } {
|
||||
// Find flows for this domain
|
||||
const flowIds = new Set(
|
||||
graph.edges
|
||||
.filter((e) => e.type === "contains_flow" && e.source === domainId)
|
||||
.map((e) => e.target),
|
||||
);
|
||||
|
||||
const flowNodes = graph.nodes.filter((n) => flowIds.has(n.id));
|
||||
const stepEdges = graph.edges.filter(
|
||||
(e) => e.type === "flow_step" && flowIds.has(e.source),
|
||||
);
|
||||
const stepIds = new Set(stepEdges.map((e) => e.target));
|
||||
const stepNodes = graph.nodes.filter((n) => stepIds.has(n.id));
|
||||
|
||||
// Build step order map
|
||||
const stepOrderMap = new Map<string, number>();
|
||||
for (const edge of stepEdges) {
|
||||
stepOrderMap.set(edge.target, edge.weight);
|
||||
}
|
||||
|
||||
// Count steps per flow
|
||||
const stepCountMap = new Map<string, number>();
|
||||
for (const edge of stepEdges) {
|
||||
stepCountMap.set(edge.source, (stepCountMap.get(edge.source) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const dims = new Map<string, { width: number; height: number }>();
|
||||
|
||||
const rfNodes: Node[] = [
|
||||
...flowNodes.map((node) => {
|
||||
const meta = getDomainMeta(node);
|
||||
dims.set(node.id, { width: 260, height: 120 });
|
||||
return {
|
||||
id: node.id,
|
||||
type: "flow-node" as const,
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: node.name,
|
||||
summary: node.summary,
|
||||
entryPoint: meta?.entryPoint as string | undefined,
|
||||
entryType: meta?.entryType as string | undefined,
|
||||
stepCount: stepCountMap.get(node.id) ?? 0,
|
||||
flowId: node.id,
|
||||
},
|
||||
};
|
||||
}),
|
||||
...stepNodes.map((node) => {
|
||||
dims.set(node.id, { width: 200, height: 90 });
|
||||
return {
|
||||
id: node.id,
|
||||
type: "step-node" as const,
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: node.name,
|
||||
summary: node.summary,
|
||||
filePath: node.filePath,
|
||||
stepId: node.id,
|
||||
order: Math.round((stepOrderMap.get(node.id) ?? 0) * 10),
|
||||
},
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
const rfEdges: Edge[] = stepEdges.map((e) => ({
|
||||
id: `${e.source}-${e.target}`,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
style: { stroke: "var(--color-border-medium)", strokeWidth: 1.5 },
|
||||
animated: false,
|
||||
}));
|
||||
|
||||
return applyDagreLayout(rfNodes, rfEdges, "LR", dims);
|
||||
}
|
||||
|
||||
function DomainGraphViewInner() {
|
||||
const domainGraph = useDashboardStore((s) => s.domainGraph);
|
||||
const activeDomainId = useDashboardStore((s) => s.activeDomainId);
|
||||
const navigateToDomain = useDashboardStore((s) => s.navigateToDomain);
|
||||
const theme = useTheme();
|
||||
|
||||
const { nodes, edges } = useMemo(() => {
|
||||
if (!domainGraph) return { nodes: [], edges: [] };
|
||||
if (activeDomainId) {
|
||||
return buildDomainDetail(domainGraph, activeDomainId);
|
||||
}
|
||||
return buildDomainOverview(domainGraph);
|
||||
}, [domainGraph, activeDomainId]);
|
||||
|
||||
const onNodeDoubleClick = useCallback(
|
||||
(_: React.MouseEvent, node: Node) => {
|
||||
if (node.type === "domain-cluster" && node.data && "domainId" in node.data) {
|
||||
navigateToDomain(node.data.domainId as string);
|
||||
}
|
||||
},
|
||||
[navigateToDomain],
|
||||
);
|
||||
|
||||
if (!domainGraph) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-text-muted text-sm">
|
||||
No domain graph available. Run /understand-domain to generate one.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full relative">
|
||||
{activeDomainId && (
|
||||
<div className="absolute top-3 left-3 z-10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
useDashboardStore.setState({ activeDomainId: null });
|
||||
}}
|
||||
className="px-3 py-1.5 text-xs rounded-lg bg-elevated border border-border-subtle text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
Back to domains
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodeDoubleClick={onNodeDoubleClick}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
minZoom={0.1}
|
||||
maxZoom={2}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={20}
|
||||
size={1}
|
||||
color="var(--color-border-subtle)"
|
||||
/>
|
||||
<Controls
|
||||
showInteractive={false}
|
||||
style={{ bottom: 16, left: 16 }}
|
||||
/>
|
||||
<MiniMap
|
||||
nodeColor={() => theme.preset.colors.accent ?? "#d4a574"}
|
||||
maskColor="rgba(0,0,0,0.7)"
|
||||
style={{ bottom: 16, right: 16, width: 160, height: 100 }}
|
||||
/>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DomainGraphView() {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<DomainGraphViewInner />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user