Spaces:
Running
Running
| import React, { useState } from "react"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; | |
| import { Badge } from "@/components/ui/badge"; | |
| import { | |
| Database, | |
| ChevronDown, | |
| Clock, | |
| Activity, | |
| GitBranch, | |
| GitCompare, | |
| } from "lucide-react"; | |
| import { AvailableGraph } from "@/types"; | |
| interface GraphSelectorProps { | |
| availableGraphs: AvailableGraph[]; | |
| selectedGraph1: AvailableGraph | null; | |
| selectedGraph2: AvailableGraph | null; | |
| onSelectionChange: ( | |
| graph1: AvailableGraph | null, | |
| graph2: AvailableGraph | null | |
| ) => void; | |
| onCompareGraphs: () => Promise<void>; | |
| isLoading: boolean; | |
| } | |
| export const GraphSelector: React.FC<GraphSelectorProps> = ({ | |
| availableGraphs, | |
| selectedGraph1, | |
| selectedGraph2, | |
| onSelectionChange, | |
| onCompareGraphs, | |
| isLoading, | |
| }) => { | |
| const [expandedGraphs, setExpandedGraphs] = useState<Set<number>>(new Set()); | |
| const toggleGraphExpansion = (graphId: number) => { | |
| const newExpanded = new Set(expandedGraphs); | |
| if (newExpanded.has(graphId)) { | |
| newExpanded.delete(graphId); | |
| } else { | |
| newExpanded.add(graphId); | |
| } | |
| setExpandedGraphs(newExpanded); | |
| }; | |
| const handleGraphSelect = (graph: AvailableGraph) => { | |
| // If clicking on already selected graph, deselect it | |
| if (selectedGraph1?.id === graph.id) { | |
| onSelectionChange(null, selectedGraph2); | |
| return; | |
| } | |
| if (selectedGraph2?.id === graph.id) { | |
| onSelectionChange(selectedGraph1, null); | |
| return; | |
| } | |
| // Select in first available slot | |
| if (!selectedGraph1) { | |
| onSelectionChange(graph, selectedGraph2); | |
| } else if (!selectedGraph2) { | |
| onSelectionChange(selectedGraph1, graph); | |
| } else { | |
| // Both slots filled, replace the first one | |
| onSelectionChange(graph, selectedGraph2); | |
| } | |
| }; | |
| const clearAllSelections = () => { | |
| onSelectionChange(null, null); | |
| }; | |
| const getSelectionClass = (graph: AvailableGraph) => { | |
| if (selectedGraph1?.id === graph.id) return "selected selected-1"; | |
| if (selectedGraph2?.id === graph.id) return "selected selected-2"; | |
| return ""; | |
| }; | |
| const formatDate = (dateString: string) => { | |
| return new Date(dateString).toLocaleDateString(); | |
| }; | |
| const getGraphTypeIcon = (type: string) => { | |
| switch (type) { | |
| case "final": | |
| return Activity; | |
| case "chunk": | |
| return Clock; | |
| default: | |
| return GitBranch; | |
| } | |
| }; | |
| const truncateGraphName = ( | |
| name: string, | |
| maxLength: number = 35 | |
| ): string => { | |
| if (!name) return ""; | |
| if (name.length <= maxLength) return name; | |
| // Smart truncation - keep beginning and end if possible | |
| const start = name.substring(0, Math.floor(maxLength * 0.6)); | |
| const end = name.substring(name.length - Math.floor(maxLength * 0.3)); | |
| return `${start}...${end}`; | |
| }; | |
| // Prefer a human-friendly system name if available | |
| const getGraphDisplayName = (graph: AvailableGraph | null | undefined) => { | |
| if (!graph) return ""; | |
| return graph.system_name && graph.system_name.trim().length > 0 | |
| ? graph.system_name | |
| : graph.filename; | |
| }; | |
| const getSelectionNumber = (graph: AvailableGraph) => { | |
| if (selectedGraph1?.id === graph.id) return "1"; | |
| if (selectedGraph2?.id === graph.id) return "2"; | |
| return null; | |
| }; | |
| const canCompare = selectedGraph1 && selectedGraph2; | |
| return ( | |
| <div className="space-y-6"> | |
| {/* Header */} | |
| <div className="flex items-center justify-between"> | |
| <div> | |
| <h2 className="text-lg font-semibold">Select Graphs to Compare</h2> | |
| <p className="text-sm text-muted-foreground"> | |
| Choose exactly 2 knowledge graphs for comparison | |
| </p> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| {canCompare && ( | |
| <Button | |
| onClick={onCompareGraphs} | |
| disabled={isLoading} | |
| className="flex items-center gap-2" | |
| > | |
| <GitCompare className="h-4 w-4" /> | |
| {isLoading ? "Comparing..." : "Compare Graphs"} | |
| </Button> | |
| )} | |
| {(selectedGraph1 || selectedGraph2) && ( | |
| <Button variant="outline" onClick={clearAllSelections}> | |
| Clear Selection | |
| </Button> | |
| )} | |
| </div> | |
| </div> | |
| {/* Selection Indicators */} | |
| <div className="grid grid-cols-2 gap-4"> | |
| <Card | |
| className={`transition-all ${ | |
| selectedGraph1 ? "ring-2 ring-blue-500" : "border-dashed" | |
| }`} | |
| > | |
| <CardContent className="p-4"> | |
| <div className="flex items-center gap-2"> | |
| <div className="w-6 h-6 rounded-full bg-blue-500 text-white text-xs flex items-center justify-center font-medium"> | |
| 1 | |
| </div> | |
| <div className="flex-1"> | |
| {selectedGraph1 ? ( | |
| <div> | |
| <div | |
| className="font-medium text-sm whitespace-normal break-words" | |
| title={getGraphDisplayName(selectedGraph1)} | |
| > | |
| {getGraphDisplayName(selectedGraph1)} | |
| </div> | |
| <div className="text-xs text-muted-foreground"> | |
| {selectedGraph1.graph_type} •{" "} | |
| {selectedGraph1.entity_count} entities | |
| </div> | |
| </div> | |
| ) : ( | |
| <div className="text-sm text-muted-foreground"> | |
| Select first graph | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| <Card | |
| className={`transition-all ${ | |
| selectedGraph2 ? "ring-2 ring-green-500" : "border-dashed" | |
| }`} | |
| > | |
| <CardContent className="p-4"> | |
| <div className="flex items-center gap-2"> | |
| <div className="w-6 h-6 rounded-full bg-green-500 text-white text-xs flex items-center justify-center font-medium"> | |
| 2 | |
| </div> | |
| <div className="flex-1"> | |
| {selectedGraph2 ? ( | |
| <div> | |
| <div | |
| className="font-medium text-sm whitespace-normal break-words" | |
| title={getGraphDisplayName(selectedGraph2)} | |
| > | |
| {getGraphDisplayName(selectedGraph2)} | |
| </div> | |
| <div className="text-xs text-muted-foreground"> | |
| {selectedGraph2.graph_type} •{" "} | |
| {selectedGraph2.entity_count} entities | |
| </div> | |
| </div> | |
| ) : ( | |
| <div className="text-sm text-muted-foreground"> | |
| Select second graph | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| </div> | |
| {/* Graph List */} | |
| <div className="space-y-4"> | |
| {availableGraphs.length === 0 ? ( | |
| <Card> | |
| <CardContent className="p-8 text-center"> | |
| <Database className="h-12 w-12 mx-auto text-muted-foreground mb-4" /> | |
| <h3 className="text-lg font-semibold mb-2"> | |
| No Graphs Available | |
| </h3> | |
| <p className="text-muted-foreground"> | |
| No knowledge graphs found for comparison. Upload traces and | |
| generate graphs first. | |
| </p> | |
| </CardContent> | |
| </Card> | |
| ) : ( | |
| <Card> | |
| <CardHeader> | |
| <CardTitle className="text-base flex items-center gap-2"> | |
| <Database className="h-4 w-4" /> | |
| Available Graphs | |
| <Badge variant="outline">{availableGraphs.length}</Badge> | |
| </CardTitle> | |
| </CardHeader> | |
| <CardContent className="p-0"> | |
| <div className="space-y-0"> | |
| {availableGraphs.map((graph) => { | |
| const hasChunks = | |
| graph.chunk_graphs && graph.chunk_graphs.length > 0; | |
| const isExpanded = expandedGraphs.has(graph.id); | |
| const IconComponent = getGraphTypeIcon(graph.graph_type); | |
| return ( | |
| <div key={graph.id} className="border-b last:border-b-0"> | |
| {/* Final Graph Item */} | |
| <div | |
| className={`relative p-5 cursor-pointer hover:bg-muted/50 transition-all duration-200 ${getSelectionClass( | |
| graph | |
| )}`} | |
| onClick={() => handleGraphSelect(graph)} | |
| > | |
| <div className="flex items-center gap-4"> | |
| <div className="flex items-center gap-3 flex-1 min-w-0"> | |
| <IconComponent className="h-5 w-5 text-primary flex-shrink-0" /> | |
| <div className="flex-1 min-w-0"> | |
| <div | |
| className="font-semibold text-base text-foreground mb-1 whitespace-normal break-words" | |
| title={getGraphDisplayName(graph)} | |
| > | |
| {getGraphDisplayName(graph)} | |
| </div> | |
| <div className="flex items-center gap-3 flex-wrap"> | |
| <Badge | |
| variant={ | |
| graph.graph_type === "final" | |
| ? "default" | |
| : "secondary" | |
| } | |
| className="text-xs font-medium px-2 py-1" | |
| > | |
| {graph.graph_type} | |
| </Badge> | |
| {graph.trace_title && ( | |
| <span className="text-xs text-muted-foreground truncate"> | |
| from{" "} | |
| {truncateGraphName(graph.trace_title, 25)} | |
| </span> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-6 text-sm"> | |
| <div className="text-center"> | |
| <div className="font-bold text-foreground"> | |
| {graph.entity_count} | |
| </div> | |
| <div className="text-xs text-muted-foreground"> | |
| entities | |
| </div> | |
| </div> | |
| <div className="text-center"> | |
| <div className="font-bold text-foreground"> | |
| {graph.relation_count} | |
| </div> | |
| <div className="text-xs text-muted-foreground"> | |
| relations | |
| </div> | |
| </div> | |
| {hasChunks && ( | |
| <div className="text-center"> | |
| <div className="font-bold text-foreground"> | |
| {graph.chunk_graphs!.length} | |
| </div> | |
| <div className="text-xs text-muted-foreground"> | |
| chunks | |
| </div> | |
| </div> | |
| )} | |
| <div className="text-center"> | |
| <div className="font-bold text-foreground"> | |
| {formatDate(graph.creation_timestamp)} | |
| </div> | |
| <div className="text-xs text-muted-foreground"> | |
| created | |
| </div> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-2 flex-shrink-0"> | |
| {hasChunks && ( | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| toggleGraphExpansion(graph.id); | |
| }} | |
| className="p-1 h-8 w-8 hover:bg-muted" | |
| > | |
| <ChevronDown | |
| className={`h-4 w-4 transition-transform ${ | |
| isExpanded ? "rotate-180" : "" | |
| }`} | |
| /> | |
| </Button> | |
| )} | |
| </div> | |
| </div> | |
| {/* Selection Number Badge */} | |
| {getSelectionNumber(graph) && ( | |
| <div | |
| className={`absolute top-3 right-3 w-7 h-7 rounded-full text-white text-sm font-bold flex items-center justify-center shadow-lg transition-all duration-300 ${ | |
| getSelectionNumber(graph) === "1" | |
| ? "bg-blue-500 shadow-blue-500/30" | |
| : "bg-green-500 shadow-green-500/30" | |
| }`} | |
| style={{ | |
| animation: "selectionPulse 0.5s ease", | |
| }} | |
| > | |
| {getSelectionNumber(graph)} | |
| </div> | |
| )} | |
| </div> | |
| {/* Chunk Graphs */} | |
| {hasChunks && isExpanded && ( | |
| <div className="bg-muted/20 border-t"> | |
| {graph.chunk_graphs!.map((chunk) => { | |
| const ChunkIcon = getGraphTypeIcon( | |
| chunk.graph_type | |
| ); | |
| return ( | |
| <div | |
| key={chunk.id} | |
| className={`relative p-4 pl-12 cursor-pointer hover:bg-muted/40 transition-all duration-200 border-t border-muted/40 ${getSelectionClass( | |
| chunk | |
| )}`} | |
| onClick={() => handleGraphSelect(chunk)} | |
| > | |
| <div className="flex items-center gap-4"> | |
| <div className="flex items-center gap-3 flex-1 min-w-0"> | |
| <ChunkIcon className="h-4 w-4 text-orange-500 flex-shrink-0" /> | |
| <div className="flex-1 min-w-0"> | |
| <div className="font-semibold text-sm text-foreground mb-1"> | |
| Window{" "} | |
| {(chunk.window_info?.index || 0) + 1}/ | |
| {chunk.window_info?.total || "?"} | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <Badge | |
| variant="secondary" | |
| className="text-xs font-medium px-2 py-1" | |
| > | |
| {chunk.graph_type} | |
| </Badge> | |
| {chunk.window_info && ( | |
| <span className="text-xs text-muted-foreground"> | |
| {chunk.window_info.start_char?.toLocaleString() || | |
| "N/A"}{" "} | |
| -{" "} | |
| {chunk.window_info.end_char?.toLocaleString() || | |
| "N/A"}{" "} | |
| chars | |
| </span> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-6 text-sm"> | |
| <div className="text-center"> | |
| <div className="font-bold text-foreground"> | |
| {chunk.entity_count} | |
| </div> | |
| <div className="text-xs text-muted-foreground"> | |
| entities | |
| </div> | |
| </div> | |
| <div className="text-center"> | |
| <div className="font-bold text-foreground"> | |
| {chunk.relation_count} | |
| </div> | |
| <div className="text-xs text-muted-foreground"> | |
| relations | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Selection Number Badge for Chunks */} | |
| {getSelectionNumber(chunk) && ( | |
| <div | |
| className={`absolute top-3 right-3 w-6 h-6 rounded-full text-white text-xs font-bold flex items-center justify-center shadow-lg transition-all duration-300 ${ | |
| getSelectionNumber(chunk) === "1" | |
| ? "bg-blue-500 shadow-blue-500/30" | |
| : "bg-green-500 shadow-green-500/30" | |
| }`} | |
| style={{ | |
| animation: "selectionPulse 0.5s ease", | |
| }} | |
| > | |
| {getSelectionNumber(chunk)} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </CardContent> | |
| </Card> | |
| )} | |
| </div> | |
| {/* Selection Summary removed */} | |
| </div> | |
| ); | |
| }; | |