diff --git a/apps/webapp/app/components/activity/contribution-graph.tsx b/apps/webapp/app/components/activity/contribution-graph.tsx new file mode 100644 index 0000000..e654481 --- /dev/null +++ b/apps/webapp/app/components/activity/contribution-graph.tsx @@ -0,0 +1,71 @@ +import React, { useMemo } from "react"; +import CalendarHeatmap from "react-calendar-heatmap"; +import { cn } from "~/lib/utils"; + +interface ContributionGraphProps { + data: Array<{ + date: string; + count: number; + status?: string; + }>; + className?: string; +} + +export function ContributionGraph({ data, className }: ContributionGraphProps) { + const processedData = useMemo(() => { + const endDate = new Date(); + const startDate = new Date(); + startDate.setFullYear(endDate.getFullYear() - 1); + + return data.map((item) => ({ + date: item.date, + count: item.count, + status: item.status, + })); + }, [data]); + + const getClassForValue = (value: any) => { + if (!value || value.count === 0) { + return "fill-background dark:fill-background"; + } + + const count = value.count; + if (count >= 20) return "fill-success"; + if (count >= 15) return "fill-success/85"; + if (count >= 10) return "fill-success/70"; + if (count >= 5) return "fill-success/50"; + return "fill-success/30"; + }; + + const getTitleForValue = (value: any) => { + if (!value || value.count === 0) { + return `No activity on ${value?.date || "this date"}`; + } + + const count = value.count; + const date = new Date(value.date).toLocaleDateString(); + return `${count} ${count === 1 ? "activity" : "activities"} on ${date}`; + }; + + const endDate = new Date(); + const startDate = new Date(); + startDate.setFullYear(endDate.getFullYear() - 1); + + return ( +
+
+ +
+
+ ); +} diff --git a/apps/webapp/app/components/graph/graph-client.tsx b/apps/webapp/app/components/graph/graph-client.tsx index 40e2949..5126c6f 100644 --- a/apps/webapp/app/components/graph/graph-client.tsx +++ b/apps/webapp/app/components/graph/graph-client.tsx @@ -1,15 +1,21 @@ +import { GraphClusteringProps } from "./graph-clustering"; +import { type GraphClusteringVisualizationProps } from "./graph-clustering-visualization"; import { type GraphVisualizationProps } from "./graph-visualization"; import { useState, useEffect } from "react"; -export function GraphVisualizationClient(props: GraphVisualizationProps) { +export function GraphVisualizationClient( + props: GraphClusteringVisualizationProps, +) { const [Component, setComponent] = useState(undefined); useEffect(() => { if (typeof window === "undefined") return; - import("./graph-visualization").then(({ GraphVisualization }) => { - setComponent(GraphVisualization); - }); + import("./graph-clustering-visualization").then( + ({ GraphClusteringVisualization }) => { + setComponent(GraphClusteringVisualization); + }, + ); }, []); if (!Component) { diff --git a/apps/webapp/app/components/graph/graph-clustering-visualization.tsx b/apps/webapp/app/components/graph/graph-clustering-visualization.tsx new file mode 100644 index 0000000..99a535a --- /dev/null +++ b/apps/webapp/app/components/graph/graph-clustering-visualization.tsx @@ -0,0 +1,271 @@ +import { useState, useMemo, forwardRef, useEffect } from "react"; +import { useTheme } from "remix-themes"; +import { GraphClustering, type GraphClusteringRef } from "./graph-clustering"; +import { GraphPopovers } from "./graph-popover"; +import type { RawTriplet, NodePopupContent, EdgePopupContent } from "./type"; +import { Card, CardContent } from "~/components/ui/card"; + +import { createLabelColorMap, nodeColorPalette } from "./node-colors"; +import { toGraphTriplets } from "./utils"; +import { cn } from "~/lib/utils"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../ui/select"; + +interface ClusterData { + uuid: string; + name: string; + description?: string; + size: number; + cohesionScore?: number; + aspectType?: "thematic" | "social" | "activity"; +} + +export interface GraphClusteringVisualizationProps { + triplets: RawTriplet[]; + clusters: ClusterData[]; + width?: number; + height?: number; + zoomOnMount?: boolean; + className?: string; + selectedClusterId?: string | null; + onClusterSelect?: (clusterId: string | null) => void; +} + +export const GraphClusteringVisualization = forwardRef< + GraphClusteringRef, + GraphClusteringVisualizationProps +>( + ( + { + triplets, + clusters, + width = window.innerWidth * 0.85, + height = window.innerHeight * 0.85, + zoomOnMount = true, + className = "rounded-md h-full overflow-hidden relative", + selectedClusterId, + onClusterSelect, + }, + ref, + ) => { + const [themeMode] = useTheme(); + + // Graph state for popovers + const [showNodePopup, setShowNodePopup] = useState(false); + const [showEdgePopup, setShowEdgePopup] = useState(false); + const [nodePopupContent, setNodePopupContent] = + useState(null); + const [edgePopupContent, setEdgePopupContent] = + useState(null); + + // Filter triplets based on selected cluster (like Marvel's comic filter) + const filteredTriplets = useMemo(() => { + if (!selectedClusterId) return triplets; + + // Filter triplets to show only nodes from the selected cluster + return triplets.filter( + (triplet) => + triplet.sourceNode.attributes?.clusterId === selectedClusterId || + triplet.targetNode.attributes?.clusterId === selectedClusterId, + ); + }, [triplets, selectedClusterId]); + + // Convert filtered triplets to graph triplets + const graphTriplets = useMemo( + () => toGraphTriplets(filteredTriplets), + [filteredTriplets], + ); + + // Extract all unique labels from triplets + const allLabels = useMemo(() => { + const labels = new Set(); + labels.add("Entity"); // Always include Entity as default + + graphTriplets.forEach((triplet) => { + if (triplet.source.primaryLabel) + labels.add(triplet.source.primaryLabel); + if (triplet.target.primaryLabel) + labels.add(triplet.target.primaryLabel); + }); + + return Array.from(labels).sort((a, b) => { + // Always put "Entity" first + if (a === "Entity") return -1; + if (b === "Entity") return 1; + // Sort others alphabetically + return a.localeCompare(b); + }); + }, [graphTriplets]); + + // Create a shared label color map + const sharedLabelColorMap = useMemo(() => { + return createLabelColorMap(allLabels); + }, [allLabels]); + + // Handle node click + const handleNodeClick = (nodeId: string) => { + // Find the triplet that contains this node by searching through graphTriplets + let foundNode = null; + for (const triplet of filteredTriplets) { + if (triplet.sourceNode.uuid === nodeId) { + foundNode = triplet.sourceNode; + break; + } else if (triplet.targetNode.uuid === nodeId) { + foundNode = triplet.targetNode; + break; + } + } + + if (!foundNode) { + // Try to find in the converted graph triplets + for (const graphTriplet of graphTriplets) { + if (graphTriplet.source.id === nodeId) { + foundNode = { + uuid: graphTriplet.source.id, + value: graphTriplet.source.value, + primaryLabel: graphTriplet.source.primaryLabel, + attributes: graphTriplet.source, + } as any; + break; + } else if (graphTriplet.target.id === nodeId) { + foundNode = { + uuid: graphTriplet.target.id, + value: graphTriplet.target.value, + primaryLabel: graphTriplet.target.primaryLabel, + attributes: graphTriplet.target, + }; + break; + } + } + } + + if (!foundNode) return; + + // Set popup content and show the popup + setNodePopupContent({ + id: nodeId, + node: foundNode, + }); + setShowNodePopup(true); + setShowEdgePopup(false); + }; + + // Handle edge click + const handleEdgeClick = (edgeId: string) => { + // Find the triplet that contains this edge + const triplet = triplets.find((t) => t.edge.uuid === edgeId); + + if (!triplet) return; + + // Set popup content and show the popup + setEdgePopupContent({ + id: edgeId, + source: triplet.sourceNode, + target: triplet.targetNode, + relation: triplet.edge, + }); + setShowEdgePopup(true); + setShowNodePopup(false); + }; + + // Handle cluster click - toggle filter like Marvel + const handleClusterClick = (clusterId: string) => { + if (onClusterSelect) { + const newSelection = selectedClusterId === clusterId ? null : clusterId; + onClusterSelect(newSelection); + } + }; + + // Handle popover close + const handlePopoverClose = () => { + setShowNodePopup(false); + setShowEdgePopup(false); + }; + + return ( +
+ {/* Cluster Filter Dropdown - Marvel style */} +
+ + +
+ +
+
+
+
+ + {filteredTriplets.length > 0 ? ( + + ) : ( +
+

No graph data to visualize.

+
+ )} + + {/* Standard Graph Popovers */} + +
+ ); + }, +); diff --git a/apps/webapp/app/components/graph/graph-clustering.tsx b/apps/webapp/app/components/graph/graph-clustering.tsx new file mode 100644 index 0000000..820fc81 --- /dev/null +++ b/apps/webapp/app/components/graph/graph-clustering.tsx @@ -0,0 +1,851 @@ +import { + useEffect, + useRef, + useMemo, + useCallback, + useImperativeHandle, + forwardRef, +} from "react"; +import Sigma from "sigma"; +import GraphologyGraph from "graphology"; +import forceAtlas2 from "graphology-layout-forceatlas2"; +import FA2Layout from "graphology-layout-forceatlas2/worker"; +import { EdgeLineProgram } from "sigma/rendering"; +import colors from "tailwindcss/colors"; +import type { GraphTriplet, IdValue, GraphNode } from "./type"; +import { + createLabelColorMap, + getNodeColor as getNodeColorByLabel, + nodeColorPalette, +} from "./node-colors"; +import { useTheme } from "remix-themes"; +import { drawHover } from "./utils"; + +interface ClusterData { + uuid: string; + name: string; + description?: string; + size: number; + cohesionScore?: number; +} + +export interface GraphClusteringProps { + triplets: GraphTriplet[]; + clusters: ClusterData[]; + width?: number; + height?: number; + zoomOnMount?: boolean; + onNodeClick?: (nodeId: string) => void; + onEdgeClick?: (edgeId: string) => void; + onClusterClick?: (clusterId: string) => void; + onBlur?: () => void; + labelColorMap?: Map; + showClusterLabels?: boolean; + enableClusterColors?: boolean; +} + +export interface GraphClusteringRef { + zoomToLinkById: (linkId: string) => void; + zoomToCluster: (clusterId: string) => void; + highlightCluster: (clusterId: string) => void; + resetHighlights: () => void; +} + +// Use node-colors palette for cluster colors +const generateClusterColors = ( + clusterCount: number, + isDarkMode: boolean, +): string[] => { + const palette = isDarkMode ? nodeColorPalette.dark : nodeColorPalette.light; + const colors: string[] = []; + + for (let i = 0; i < clusterCount; i++) { + colors.push(palette[i % palette.length]); + } + + return colors; +}; + +export const GraphClustering = forwardRef< + GraphClusteringRef, + GraphClusteringProps +>( + ( + { + triplets, + clusters, + width = 1000, + height = 800, + zoomOnMount = false, + onNodeClick, + onEdgeClick, + onClusterClick, + onBlur, + labelColorMap: externalLabelColorMap, + showClusterLabels = true, + enableClusterColors = true, + }, + ref, + ) => { + const containerRef = useRef(null); + const sigmaRef = useRef(null); + const graphRef = useRef(null); + const clustersLayerRef = useRef(null); + const [themeMode] = useTheme(); + + const isInitializedRef = useRef(false); + const selectedNodeRef = useRef(null); + const selectedEdgeRef = useRef(null); + const selectedClusterRef = useRef(null); + + // Create cluster color mapping + const clusterColorMap = useMemo(() => { + if (!enableClusterColors) return new Map(); + + const clusterIds = clusters.map((c) => c.uuid); + const clusterColors = generateClusterColors( + clusterIds.length, + themeMode === "dark", + ); + const colorMap = new Map(); + + clusterIds.forEach((id, index) => { + colorMap.set(id, clusterColors[index]); + }); + + return colorMap; + }, [clusters, enableClusterColors, themeMode]); + + // Memoize theme to prevent unnecessary recreation + const theme = useMemo( + () => ({ + node: { + fill: colors.pink[500], + stroke: themeMode === "dark" ? colors.slate[100] : colors.slate[900], + hover: "#646464", + text: themeMode === "dark" ? colors.slate[100] : colors.slate[900], + selected: "#646464", + dimmed: colors.pink[300], + }, + link: { + stroke: colors.gray[400], + selected: "#646464", + dimmed: themeMode === "dark" ? colors.slate[800] : colors.slate[200], + }, + cluster: { + labelColor: + themeMode === "dark" ? colors.slate[100] : colors.slate[900], + labelBg: + themeMode === "dark" + ? colors.slate[800] + "CC" + : colors.slate[200] + "CC", + }, + background: + themeMode === "dark" ? colors.slate[900] : colors.slate[100], + }), + [themeMode], + ); + + // Extract all unique labels from triplets + const allLabels = useMemo(() => { + if (externalLabelColorMap) return []; + const labels = new Set(); + labels.add("Entity"); + triplets.forEach((triplet) => { + if (triplet.source.primaryLabel) + labels.add(triplet.source.primaryLabel); + if (triplet.target.primaryLabel) + labels.add(triplet.target.primaryLabel); + }); + return Array.from(labels); + }, [triplets, externalLabelColorMap]); + + // Create a mapping of label to color + const labelColorMap = useMemo(() => { + return externalLabelColorMap || createLabelColorMap(allLabels); + }, [allLabels, externalLabelColorMap]); + + // Create a mapping of node IDs to their data + const nodeDataMap = useMemo(() => { + const result = new Map(); + triplets.forEach((triplet) => { + result.set(triplet.source.id, triplet.source); + result.set(triplet.target.id, triplet.target); + }); + return result; + }, [triplets]); + + // Function to get node color (with cluster coloring support) + const getNodeColor = useCallback( + (node: any): string => { + if (!node) { + return getNodeColorByLabel(null, themeMode === "dark", labelColorMap); + } + + const nodeData = nodeDataMap.get(node.id) || node; + + // Check if this is a Statement node + const isStatementNode = + nodeData.attributes.nodeType === "Statement" || + (nodeData.labels && nodeData.labels.includes("Statement")); + + if (isStatementNode) { + // Statement nodes with cluster IDs use cluster colors + if ( + enableClusterColors && + nodeData.clusterId && + clusterColorMap.has(nodeData.clusterId) + ) { + return clusterColorMap.get(nodeData.clusterId)!; + } + + // Unclustered statement nodes use a specific light color + return themeMode === "dark" ? "#2b9684" : "#54935b"; // Teal/Green from palette + } + + // Entity nodes use light gray + return themeMode === "dark" ? "#6B7280" : "#9CA3AF"; // Tailwind gray-500/gray-400 + }, + [ + labelColorMap, + nodeDataMap, + themeMode, + enableClusterColors, + clusterColorMap, + ], + ); + + // Process graph data for Sigma + const { nodes, edges } = useMemo(() => { + const nodeMap = new Map(); + triplets.forEach((triplet) => { + if (!nodeMap.has(triplet.source.id)) { + const nodeColor = getNodeColor(triplet.source); + const isStatementNode = + triplet.source.attributes?.nodeType === "Statement" || + (triplet.source.labels && + triplet.source.labels.includes("Statement")); + + nodeMap.set(triplet.source.id, { + id: triplet.source.id, + label: triplet.source.value + ? triplet.source.value.split(/\s+/).slice(0, 4).join(" ") + + (triplet.source.value.split(/\s+/).length > 4 ? " ..." : "") + : "", + size: isStatementNode ? 4 : 2, // Statement nodes slightly larger + color: nodeColor, + x: width, + y: height, + nodeData: triplet.source, + clusterId: triplet.source.clusterId, + // Enhanced border for visual appeal, thicker for Statement nodes + borderSize: 1, + borderColor: nodeColor, + }); + } + if (!nodeMap.has(triplet.target.id)) { + const nodeColor = getNodeColor(triplet.target); + const isStatementNode = + triplet.target.attributes?.nodeType === "Statement" || + (triplet.target.labels && + triplet.target.labels.includes("Statement")); + + nodeMap.set(triplet.target.id, { + id: triplet.target.id, + label: triplet.target.value + ? triplet.target.value.split(/\s+/).slice(0, 4).join(" ") + + (triplet.target.value.split(/\s+/).length > 4 ? " ..." : "") + : "", + size: isStatementNode ? 4 : 2, // Statement nodes slightly larger + color: nodeColor, + x: width, + y: height, + nodeData: triplet.target, + clusterId: triplet.target.clusterId, + // Enhanced border for visual appeal, thicker for Statement nodes + borderSize: 1, + borderColor: nodeColor, + }); + } + }); + + const linkGroups = triplets.reduce( + (groups, triplet) => { + if (triplet.relation.type === "_isolated_node_") { + return groups; + } + let key = `${triplet.source.id}-${triplet.target.id}`; + const reverseKey = `${triplet.target.id}-${triplet.source.id}`; + if (groups[reverseKey]) { + key = reverseKey; + } + if (!groups[key]) { + groups[key] = { + id: key, + source: triplet.source.id, + target: triplet.target.id, + relations: [], + relationData: [], + label: "", + color: "#0000001A", + labelColor: "#0000001A", + size: 1, + }; + } + groups[key].relations.push(triplet.relation.value); + groups[key].relationData.push(triplet.relation); + + return groups; + }, + {} as Record, + ); + + return { + nodes: Array.from(nodeMap.values()), + edges: Object.values(linkGroups), + }; + }, [triplets, getNodeColor, width, height]); + + // Helper function to reset highlights without affecting camera + const resetHighlights = useCallback(() => { + if (!graphRef.current || !sigmaRef.current) return; + const graph = graphRef.current; + const sigma = sigmaRef.current; + + // Store camera state before making changes + const camera = sigma.getCamera(); + const currentState = camera.getState(); + + graph.forEachNode((node) => { + const nodeData = graph.getNodeAttribute(node, "nodeData"); + const originalColor = getNodeColor(nodeData); + const isStatementNode = + nodeData?.attributes.nodeType === "Statement" || + (nodeData?.labels && nodeData.labels.includes("Statement")); + + graph.setNodeAttribute(node, "highlighted", false); + graph.setNodeAttribute(node, "color", originalColor); + graph.setNodeAttribute(node, "size", isStatementNode ? 4 : 2); + graph.setNodeAttribute(node, "zIndex", 1); + }); + graph.forEachEdge((edge) => { + graph.setEdgeAttribute(edge, "highlighted", false); + graph.setEdgeAttribute(edge, "color", "#0000001A"); + graph.setEdgeAttribute(edge, "size", 1); + }); + + // Restore camera state to prevent unwanted movements + camera.setState(currentState); + + selectedNodeRef.current = null; + selectedEdgeRef.current = null; + selectedClusterRef.current = null; + }, [getNodeColor]); + + // Highlight entire cluster + const highlightCluster = useCallback( + (clusterId: string) => { + if (!graphRef.current || !sigmaRef.current) return; + + const graph = graphRef.current; + const sigma = sigmaRef.current; + + resetHighlights(); + selectedClusterRef.current = clusterId; + + const clusterNodes: string[] = []; + const clusterColor = + clusterColorMap.get(clusterId) || theme.node.selected; + + // Find all nodes in the cluster + graph.forEachNode((nodeId, attributes) => { + if (attributes.clusterId === clusterId) { + clusterNodes.push(nodeId); + graph.setNodeAttribute(nodeId, "highlighted", true); + graph.setNodeAttribute(nodeId, "color", clusterColor); + graph.setNodeAttribute(nodeId, "size", attributes.size * 1.75); + graph.setNodeAttribute(nodeId, "zIndex", 2); + } else { + // Dim other nodes + graph.setNodeAttribute(nodeId, "color", theme.node.dimmed); + graph.setNodeAttribute(nodeId, "size", attributes.size * 0.7); + graph.setNodeAttribute(nodeId, "zIndex", 0); + } + }); + + // Highlight edges within the cluster + graph.forEachEdge((edgeId, attributes, source, target) => { + const sourceInCluster = clusterNodes.includes(source); + const targetInCluster = clusterNodes.includes(target); + + if (sourceInCluster && targetInCluster) { + graph.setEdgeAttribute(edgeId, "highlighted", true); + graph.setEdgeAttribute(edgeId, "color", clusterColor); + graph.setEdgeAttribute(edgeId, "size", 3); + } else { + graph.setEdgeAttribute(edgeId, "color", theme.link.dimmed); + graph.setEdgeAttribute(edgeId, "size", 1); + } + }); + }, + [graphRef, sigmaRef, clusterColorMap, theme, resetHighlights], + ); + + // Zoom to cluster + const zoomToCluster = useCallback( + (clusterId: string) => { + if (!graphRef.current || !sigmaRef.current) return; + + const graph = graphRef.current; + const sigma = sigmaRef.current; + const clusterNodes: string[] = []; + + // Find all nodes in the cluster + graph.forEachNode((nodeId, attributes) => { + if (attributes.clusterId === clusterId) { + clusterNodes.push(nodeId); + } + }); + + if (clusterNodes.length === 0) return; + + // Calculate bounding box of cluster nodes + let minX = Infinity, + maxX = -Infinity; + let minY = Infinity, + maxY = -Infinity; + + clusterNodes.forEach((nodeId) => { + const pos = sigma.getNodeDisplayData(nodeId); + if (pos) { + minX = Math.min(minX, pos.x); + maxX = Math.max(maxX, pos.x); + minY = Math.min(minY, pos.y); + maxY = Math.max(maxY, pos.y); + } + }); + + // Calculate center and zoom level + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + const containerRect = containerRef.current?.getBoundingClientRect(); + + if (containerRect) { + const padding = 100; + const clusterWidth = maxX - minX + padding; + const clusterHeight = maxY - minY + padding; + const ratio = Math.min( + containerRect.width / clusterWidth, + containerRect.height / clusterHeight, + 2.0, // Maximum zoom + ); + + sigma + .getCamera() + .animate({ x: centerX, y: centerY, ratio }, { duration: 500 }); + } + + highlightCluster(clusterId); + }, + [highlightCluster], + ); + + // Expose methods via ref + useImperativeHandle(ref, () => ({ + zoomToLinkById: (linkId: string) => { + // Implementation similar to original graph component + if (!sigmaRef.current || !graphRef.current) return; + // ... existing zoomToLinkById logic + }, + zoomToCluster, + highlightCluster, + resetHighlights, + })); + + // Calculate optimal ForceAtlas2 parameters based on graph properties + const calculateOptimalParameters = useCallback((graph: GraphologyGraph) => { + const nodeCount = graph.order; + const edgeCount = graph.size; + + if (nodeCount === 0) + return { scalingRatio: 30, gravity: 5, iterations: 600 }; + + // Similar logic to original implementation + const maxPossibleEdges = (nodeCount * (nodeCount - 1)) / 2; + const density = maxPossibleEdges > 0 ? edgeCount / maxPossibleEdges : 0; + + let scalingRatio: number; + if (nodeCount < 10) { + scalingRatio = 15; + } else if (nodeCount < 50) { + scalingRatio = 20 + (nodeCount - 10) * 0.5; + } else if (nodeCount < 200) { + scalingRatio = 40 + (nodeCount - 50) * 0.2; + } else { + scalingRatio = Math.min(80, 70 + (nodeCount - 200) * 0.05); + } + + let gravity: number; + if (density > 0.3) { + gravity = 1 + density * 2; + } else if (density > 0.1) { + gravity = 3 + density * 5; + } else { + gravity = Math.min(8, 5 + (1 - density) * 3); + } + + if (nodeCount < 20) { + gravity *= 1.5; + } else if (nodeCount > 100) { + gravity *= 0.8; + } + + const complexity = nodeCount + edgeCount; + let durationSeconds: number; + if (complexity < 50) { + durationSeconds = 1.5; + } else if (complexity < 200) { + durationSeconds = 2.5; + } else if (complexity < 500) { + durationSeconds = 3.5; + } else { + durationSeconds = Math.min(6, 4 + (complexity - 500) * 0.004); + } + + return { + scalingRatio: Math.round(scalingRatio * 10) / 10, + gravity: Math.round(gravity * 10) / 10, + duration: Math.round(durationSeconds * 100) / 100, // in seconds + }; + }, []); + + useEffect(() => { + if (isInitializedRef.current || !containerRef.current) return; + isInitializedRef.current = true; + + // Create graphology graph + const graph = new GraphologyGraph(); + graphRef.current = graph; + + // Add nodes + nodes.forEach((node) => { + graph.addNode(node.id, node); + }); + + // Add edges + edges.forEach((edge) => { + if (graph.hasNode(edge.source) && graph.hasNode(edge.target)) { + graph.addEdge(edge.source, edge.target, { ...edge }); + } + }); + + // No virtual edges - let the natural graph structure determine layout + + // Apply layout + if (graph.order > 0) { + // Strong cluster-based positioning for Statement nodes only + const clusterNodeMap = new Map(); + const entityNodes: string[] = []; + + // Group Statement nodes by their cluster ID, separate Entity nodes + graph.forEachNode((nodeId, attributes) => { + const isStatementNode = + attributes.nodeData?.nodeType === "Statement" || + (attributes.nodeData?.labels && + attributes.nodeData.labels.includes("Statement")); + + if (isStatementNode && attributes.clusterId) { + // Statement nodes with cluster IDs go into clusters + if (!clusterNodeMap.has(attributes.clusterId)) { + clusterNodeMap.set(attributes.clusterId, []); + } + clusterNodeMap.get(attributes.clusterId)!.push(nodeId); + } else { + // Entity nodes (or unclustered nodes) positioned separately + entityNodes.push(nodeId); + } + }); + + const clusterIds = Array.from(clusterNodeMap.keys()); + + if (clusterIds.length > 0) { + // Use a more aggressive clustering approach - create distinct regions + const padding = Math.min(width, height) * 0.1; // 10% padding + const availableWidth = width - 2 * padding; + const availableHeight = height - 2 * padding; + + // Calculate optimal grid layout + const cols = Math.ceil(Math.sqrt(clusterIds.length)); + const rows = Math.ceil(clusterIds.length / cols); + const cellWidth = availableWidth / cols; + const cellHeight = availableHeight / rows; + + clusterIds.forEach((clusterId, index) => { + const col = index % cols; + const row = Math.floor(index / cols); + + // Calculate cluster region with more separation + const regionLeft = padding + col * cellWidth; + const regionTop = padding + row * cellHeight; + const regionCenterX = regionLeft + cellWidth / 2; + const regionCenterY = regionTop + cellHeight / 2; + + // Get nodes in this cluster + const nodesInCluster = clusterNodeMap.get(clusterId)!; + const clusterSize = nodesInCluster.length; + + // Create cluster radius with Marvel-style spacing - more generous + const maxRadius = Math.min(cellWidth, cellHeight) * 0.35; + const baseSpacing = 150; // Larger base spacing between nodes + const clusterRadius = Math.max( + baseSpacing, + Math.min(maxRadius, Math.sqrt(clusterSize) * baseSpacing * 1.2), + ); + + if (clusterSize === 1) { + // Single node at region center + graph.setNodeAttribute(nodesInCluster[0], "x", regionCenterX); + graph.setNodeAttribute(nodesInCluster[0], "y", regionCenterY); + } else if (clusterSize <= 6) { + // Small clusters - tight circle + nodesInCluster.forEach((nodeId, nodeIndex) => { + const angle = (nodeIndex / clusterSize) * 2 * Math.PI; + const x = regionCenterX + Math.cos(angle) * clusterRadius; + const y = regionCenterY + Math.sin(angle) * clusterRadius; + graph.setNodeAttribute(nodeId, "x", x); + graph.setNodeAttribute(nodeId, "y", y); + }); + } else { + // Larger clusters - dense spiral pattern + nodesInCluster.forEach((nodeId, nodeIndex) => { + const spiralTurns = Math.ceil(clusterSize / 8); + const angle = + (nodeIndex / clusterSize) * 2 * Math.PI * spiralTurns; + const radius = (nodeIndex / clusterSize) * clusterRadius; + const x = regionCenterX + Math.cos(angle) * radius; + const y = regionCenterY + Math.sin(angle) * radius; + graph.setNodeAttribute(nodeId, "x", x); + graph.setNodeAttribute(nodeId, "y", y); + }); + } + }); + } + + // Position Entity nodes using ForceAtlas2 natural positioning + // They will be positioned by the algorithm based on their connections to Statement nodes + entityNodes.forEach((nodeId) => { + // Give them initial random positions, ForceAtlas2 will adjust based on connections + graph.setNodeAttribute(nodeId, "x", Math.random() * width); + graph.setNodeAttribute(nodeId, "y", Math.random() * height); + }); + + const optimalParams = calculateOptimalParameters(graph); + const settings = forceAtlas2.inferSettings(graph); + + console.log(optimalParams); + const layout = new FA2Layout(graph, { + settings: { + ...settings, + barnesHutOptimize: true, + strongGravityMode: false, // Marvel doesn't use strong gravity + gravity: Math.max(0.1, optimalParams.gravity * 0.005), // Much weaker gravity like Marvel + scalingRatio: optimalParams.scalingRatio * 10, // Higher scaling for more spacing + slowDown: 20, // Much slower to preserve cluster positions + outboundAttractionDistribution: false, // Use standard distribution + linLogMode: false, // Linear mode + edgeWeightInfluence: 0, // Disable edge weight influence to maintain positioning + }, + }); + + layout.start(); + setTimeout(() => layout.stop(), (optimalParams.duration ?? 2) * 1000); + } + + // Create Sigma instance + const sigma = new Sigma(graph, containerRef.current, { + renderEdgeLabels: true, + defaultEdgeColor: "#0000001A", + defaultNodeColor: theme.node.fill, + defaultEdgeType: "edges-fast", + edgeProgramClasses: { + "edges-fast": EdgeLineProgram, + }, + renderLabels: false, + enableEdgeEvents: true, + minCameraRatio: 0.01, + defaultDrawNodeHover: drawHover, + + maxCameraRatio: 2, + allowInvalidContainer: false, + }); + + sigmaRef.current = sigma; + + // Set up camera for zoom on mount + if (zoomOnMount) { + setTimeout(() => { + sigma + .getCamera() + .animate(sigma.getCamera().getState(), { duration: 750 }); + }, 100); + } + + // Update cluster labels after any camera movement + sigma.getCamera().on("updated", () => { + if (showClusterLabels) { + } + }); + + // Drag and drop implementation (same as original) + let draggedNode: string | null = null; + let isDragging = false; + + sigma.on("downNode", (e) => { + isDragging = true; + draggedNode = e.node; + graph.setNodeAttribute(draggedNode, "highlighted", true); + if (!sigma.getCustomBBox()) sigma.setCustomBBox(sigma.getBBox()); + }); + + sigma.on("moveBody", ({ event }) => { + if (!isDragging || !draggedNode) return; + const pos = sigma.viewportToGraph(event); + graph.setNodeAttribute(draggedNode, "x", pos.x); + graph.setNodeAttribute(draggedNode, "y", pos.y); + event.preventSigmaDefault?.(); + event.original?.preventDefault?.(); + event.original?.stopPropagation?.(); + }); + + const handleUp = () => { + if (draggedNode) { + graph.removeNodeAttribute(draggedNode, "highlighted"); + } + isDragging = false; + draggedNode = null; + }; + sigma.on("upNode", handleUp); + sigma.on("upStage", handleUp); + + // Node click handler + sigma.on("clickNode", (event) => { + const { node } = event; + + // Store current camera state to prevent unwanted movements + const camera = sigma.getCamera(); + const currentState = camera.getState(); + + resetHighlights(); // Clear previous highlights first + + // Restore camera state after reset to prevent zoom changes + setTimeout(() => { + camera.setState(currentState); + }, 0); + + if (onNodeClick) { + onNodeClick(node); + } + + // Highlight the clicked node + graph.setNodeAttribute(node, "highlighted", true); + graph.setNodeAttribute(node, "color", theme.node.selected); + graph.setNodeAttribute( + node, + "size", + graph.getNodeAttribute(node, "size"), + ); + // Enhanced border for selected node + graph.setNodeAttribute(node, "borderSize", 3); + graph.setNodeAttribute(node, "borderColor", theme.node.selected); + graph.setNodeAttribute(node, "zIndex", 3); + selectedNodeRef.current = node; + + // Highlight connected edges and nodes + graph.forEachEdge(node, (edge, _attributes, source, target) => { + graph.setEdgeAttribute(edge, "highlighted", true); + graph.setEdgeAttribute(edge, "color", theme.link.selected); + graph.setEdgeAttribute(edge, "size", 2); + const otherNode = source === node ? target : source; + graph.setNodeAttribute(otherNode, "highlighted", true); + graph.setNodeAttribute(otherNode, "color", theme.node.hover); + graph.setNodeAttribute( + otherNode, + "size", + graph.getNodeAttribute(otherNode, "size"), + ); + graph.setNodeAttribute(otherNode, "zIndex", 2); + }); + }); + + // Edge click handler + sigma.on("clickEdge", (event) => { + const { edge } = event; + resetHighlights(); + const edgeAttrs = graph.getEdgeAttributes(edge); + if (edgeAttrs.relationData && edgeAttrs.relationData.length > 0) { + const relation = edgeAttrs.relationData[0]; + if (onEdgeClick) { + onEdgeClick(relation.id); + } + } + graph.setEdgeAttribute(edge, "highlighted", true); + graph.setEdgeAttribute(edge, "color", theme.link.selected); + selectedEdgeRef.current = edge; + const source = graph.source(edge); + const target = graph.target(edge); + graph.setNodeAttribute(source, "highlighted", true); + graph.setNodeAttribute(source, "color", theme.node.selected); + graph.setNodeAttribute(target, "highlighted", true); + graph.setNodeAttribute(target, "color", theme.node.selected); + }); + + // Background click handler + sigma.on("clickStage", (event) => { + // Store camera state before reset + const camera = sigma.getCamera(); + const currentState = camera.getState(); + + resetHighlights(); + + // Restore camera state + camera.setState(currentState); + + if (onBlur) { + onBlur(); + } + }); + + // Cleanup function + return () => { + if (sigmaRef.current) { + sigmaRef.current.kill(); + sigmaRef.current = null; + } + if (graphRef.current) { + graphRef.current.clear(); + graphRef.current = null; + } + if (clustersLayerRef.current) { + clustersLayerRef.current.remove(); + clustersLayerRef.current = null; + } + isInitializedRef.current = false; + }; + }, [nodes, edges, clusters, showClusterLabels]); + + return ( +
+ ); + }, +); diff --git a/apps/webapp/app/components/graph/graph-popover.tsx b/apps/webapp/app/components/graph/graph-popover.tsx index 36dd8d5..369fce2 100644 --- a/apps/webapp/app/components/graph/graph-popover.tsx +++ b/apps/webapp/app/components/graph/graph-popover.tsx @@ -36,9 +36,7 @@ interface GraphPopoversProps { export function GraphPopovers({ showNodePopup, - showEdgePopup, nodePopupContent, - edgePopupContent, onOpenChange, labelColorMap, }: GraphPopoversProps) { @@ -52,8 +50,12 @@ export function GraphPopovers({ // Check if node has primaryLabel property (GraphNode) const nodeAny = nodePopupContent.node as any; - if (nodeAny.primaryLabel && typeof nodeAny.primaryLabel === "string") { - return nodeAny.primaryLabel; + + if ( + nodeAny.attributes.nodeType && + typeof nodeAny.attributes.nodeType === "string" + ) { + return nodeAny.attributes.nodeType; } // Fall back to original logic with labels @@ -93,7 +95,7 @@ export function GraphPopovers({
Node Details {primaryNodeLabel && ( {primaryNodeLabel} @@ -118,7 +120,9 @@ export function GraphPopovers({ {attributesToDisplay.map(({ key, value }) => (

- {key}: + {key.charAt(0).toUpperCase() + + key.slice(1).toLowerCase()} + : {" "} {typeof value === "object" @@ -134,48 +138,6 @@ export function GraphPopovers({

- - - -
- - e.preventDefault()} - > -
-

- Episode → {edgePopupContent?.target.name || "Unknown"} -

-
-
-

Relationship

-
-

- - UUID: - - {edgePopupContent?.relation.uuid || "Unknown"} -

-

- - Type: - - {edgePopupContent?.relation.type || "Unknown"} -

-

- - Created: - - {formatDate(edgePopupContent?.relation.createdAt)} -

-
-
-
-
); } diff --git a/apps/webapp/app/components/graph/node-colors.ts b/apps/webapp/app/components/graph/node-colors.ts index 78222f5..52000ee 100644 --- a/apps/webapp/app/components/graph/node-colors.ts +++ b/apps/webapp/app/components/graph/node-colors.ts @@ -1,5 +1,3 @@ -import colors from "tailwindcss/colors"; - // Define a color palette for node coloring using hex values directly export const nodeColorPalette = { light: [ diff --git a/apps/webapp/app/components/graph/type.ts b/apps/webapp/app/components/graph/type.ts index 2394581..75bc6f3 100644 --- a/apps/webapp/app/components/graph/type.ts +++ b/apps/webapp/app/components/graph/type.ts @@ -5,6 +5,7 @@ export interface Node { labels?: string[]; attributes?: Record; createdAt: string; + clusterId?: string; } export interface Edge { @@ -25,6 +26,7 @@ export interface GraphNode extends Node { id: string; value: string; primaryLabel?: string; + clusterId?: string; // Add cluster information } export interface GraphEdge extends Edge { diff --git a/apps/webapp/app/components/graph/utils.ts b/apps/webapp/app/components/graph/utils.ts index d3b90d7..9b14260 100644 --- a/apps/webapp/app/components/graph/utils.ts +++ b/apps/webapp/app/components/graph/utils.ts @@ -21,6 +21,7 @@ export function toGraphNode(node: Node): GraphNode { summary: node.summary, labels: node.labels, primaryLabel, + clusterId: node?.clusterId, // Extract cluster ID from attributes }; } @@ -44,62 +45,109 @@ export function toGraphTriplets(triplets: RawTriplet[]): GraphTriplet[] { return triplets.map(toGraphTriplet); } -export function createTriplets(edges: Edge[], nodes: Node[]): RawTriplet[] { - // Create a Set of node UUIDs that are connected by edges - const connectedNodeIds = new Set(); - - // Create triplets from edges - const edgeTriplets = edges - .map((edge) => { - const sourceNode = nodes.find( - (node) => node.uuid === edge.source_node_uuid, - ); - const targetNode = nodes.find( - (node) => node.uuid === edge.target_node_uuid, - ); - - if (!sourceNode || !targetNode) return null; - - // Add source and target node IDs to connected set - connectedNodeIds.add(sourceNode.uuid); - connectedNodeIds.add(targetNode.uuid); - - return { - sourceNode, - edge, - targetNode, - }; - }) - .filter( - (t): t is RawTriplet => - t !== null && t.sourceNode !== undefined && t.targetNode !== undefined, - ); - - // Find isolated nodes (nodes that don't appear in any edge) - const isolatedNodes = nodes.filter( - (node) => !connectedNodeIds.has(node.uuid), - ); - - // For isolated nodes, create special triplets - const isolatedTriplets: RawTriplet[] = isolatedNodes.map((node) => { - // Create a special marker edge for isolated nodes - const virtualEdge: Edge = { - uuid: `isolated-node-${node.uuid}`, - source_node_uuid: node.uuid, - target_node_uuid: node.uuid, - // Use a special type that we can filter out in the Graph component - type: "_isolated_node_", - - createdAt: node.createdAt, - }; - - return { - sourceNode: node, - edge: virtualEdge, - targetNode: node, - }; - }); - - // Combine edge triplets with isolated node triplets - return [...edgeTriplets, ...isolatedTriplets]; +export function drawRoundRect( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + radius: number, +): void { + ctx.beginPath(); + ctx.moveTo(x + radius, y); + ctx.lineTo(x + width - radius, y); + ctx.quadraticCurveTo(x + width, y, x + width, y + radius); + ctx.lineTo(x + width, y + height - radius); + ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); + ctx.lineTo(x + radius, y + height); + ctx.quadraticCurveTo(x, y + height, x, y + height - radius); + ctx.lineTo(x, y + radius); + ctx.quadraticCurveTo(x, y, x + radius, y); + ctx.closePath(); +} + +const TEXT_COLOR = "#000000"; + +export function drawHover( + context: CanvasRenderingContext2D, + data: any, + settings: any, +) { + const size = settings.labelSize; + const font = settings.labelFont; + const weight = settings.labelWeight; + const subLabelSize = size - 2; + + const label = data.label; + const subLabel = data.tag !== "unknown" ? data.tag : ""; + const entityLabel = data.nodeData.attributes.nodeType; + + // Simulate the --shadow-1 Tailwind shadow: + // lch(0 0 0 / 0.022) 0px 3px 6px -2px, lch(0 0 0 / 0.044) 0px 1px 1px; + // Canvas only supports a single shadow, so we approximate with the stronger one. + // lch(0 0 0 / 0.044) is roughly rgba(0,0,0,0.044) + context.beginPath(); + context.fillStyle = "#fff"; + context.shadowOffsetX = 0; + context.shadowOffsetY = 1; + context.shadowBlur = 1; + context.shadowColor = "rgba(0,0,0,0.044)"; + + context.font = `${weight} ${size}px ${font}`; + const labelWidth = context.measureText(label).width; + context.font = `${weight} ${subLabelSize}px ${font}`; + const subLabelWidth = subLabel ? context.measureText(subLabel).width : 0; + context.font = `${weight} ${subLabelSize}px ${font}`; + const entityLabelWidth = entityLabel + ? context.measureText(entityLabel).width + : 0; + + const textWidth = Math.max(labelWidth, subLabelWidth, entityLabelWidth); + + const x = Math.round(data.x); + const y = Math.round(data.y); + const w = Math.round(textWidth + size / 2 + data.size + 3); + const hLabel = Math.round(size / 2 + 4); + const hSubLabel = subLabel ? Math.round(subLabelSize / 2 + 9) : 0; + const hentityLabel = Math.round(subLabelSize / 2 + 9); + + drawRoundRect( + context, + x, + y - hSubLabel - 12, + w, + hentityLabel + hLabel + hSubLabel + 12, + 5, + ); + context.closePath(); + context.fill(); + + // Remove shadow for text + context.shadowOffsetX = 0; + context.shadowOffsetY = 0; + context.shadowBlur = 0; + context.shadowColor = "transparent"; + + // And finally we draw the labels + context.fillStyle = TEXT_COLOR; + context.font = `${weight} ${size}px ${font}`; + context.fillText(label, data.x + data.size + 3, data.y + size / 3); + + if (subLabel) { + context.fillStyle = TEXT_COLOR; + context.font = `${weight} ${subLabelSize}px ${font}`; + context.fillText( + subLabel, + data.x + data.size + 3, + data.y - (2 * size) / 3 - 2, + ); + } + + context.fillStyle = data.color; + context.font = `${weight} ${subLabelSize}px ${font}`; + context.fillText( + entityLabel, + data.x + data.size + 3, + data.y + size / 3 + 3 + subLabelSize, + ); } diff --git a/apps/webapp/app/components/icon-utils.tsx b/apps/webapp/app/components/icon-utils.tsx index cc45617..db5e00b 100644 --- a/apps/webapp/app/components/icon-utils.tsx +++ b/apps/webapp/app/components/icon-utils.tsx @@ -6,6 +6,10 @@ import { } from "@remixicon/react"; import { LayoutGrid } from "lucide-react"; import { LinearIcon, SlackIcon } from "./icons"; +import { Cursor } from "./icons/cursor"; +import { Claude } from "./icons/claude"; +import { Cline } from "./icons/cline"; +import { VSCode } from "./icons/vscode"; export const ICON_MAPPING = { slack: SlackIcon, @@ -15,6 +19,10 @@ export const ICON_MAPPING = { gmail: RiMailFill, linear: LinearIcon, + cursor: Cursor, + claude: Claude, + cline: Cline, + vscode: VSCode, // Default icon integration: LayoutGrid, diff --git a/apps/webapp/app/components/icons/claude.tsx b/apps/webapp/app/components/icons/claude.tsx new file mode 100644 index 0000000..bb81634 --- /dev/null +++ b/apps/webapp/app/components/icons/claude.tsx @@ -0,0 +1,20 @@ +import type { IconProps } from "./types"; + +export function Claude({ size = 18, className }: IconProps) { + return ( + + Claude + + + ); +} diff --git a/apps/webapp/app/components/icons/cline.tsx b/apps/webapp/app/components/icons/cline.tsx new file mode 100644 index 0000000..669a25a --- /dev/null +++ b/apps/webapp/app/components/icons/cline.tsx @@ -0,0 +1,19 @@ +import type { IconProps } from "./types"; + +export function Cline({ size = 18, className }: IconProps) { + return ( + + Cline + + + + ); +} diff --git a/apps/webapp/app/components/icons/cursor.tsx b/apps/webapp/app/components/icons/cursor.tsx new file mode 100644 index 0000000..d3bd83d --- /dev/null +++ b/apps/webapp/app/components/icons/cursor.tsx @@ -0,0 +1,64 @@ +import type { IconProps } from "./types"; + +export function Cursor({ size = 18, className }: IconProps) { + return ( + + Cursor + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/components/icons/vscode.tsx b/apps/webapp/app/components/icons/vscode.tsx new file mode 100644 index 0000000..56db7d5 --- /dev/null +++ b/apps/webapp/app/components/icons/vscode.tsx @@ -0,0 +1,26 @@ +import type { IconProps } from "./types"; + +export function VSCode({ size = 18, className }: IconProps) { + return ( + + + + + + ); +} diff --git a/apps/webapp/app/components/integrations/integration-card.tsx b/apps/webapp/app/components/integrations/integration-card.tsx index 5d4bb47..a71e943 100644 --- a/apps/webapp/app/components/integrations/integration-card.tsx +++ b/apps/webapp/app/components/integrations/integration-card.tsx @@ -1,10 +1,7 @@ -import React from "react"; import { Link } from "@remix-run/react"; import { Card, - CardContent, CardDescription, - CardFooter, CardHeader, CardTitle, } from "~/components/ui/card"; @@ -35,23 +32,24 @@ export function IntegrationCard({ > -
- +
+
+ +
+ + {isConnected && ( +
+ + Connected + +
+ )}
{integration.name} {integration.description || `Connect to ${integration.name}`} - {isConnected && ( - -
- - Connected - -
-
- )} ); diff --git a/apps/webapp/app/components/integrations/utils.tsx b/apps/webapp/app/components/integrations/utils.tsx new file mode 100644 index 0000000..05ac9b3 --- /dev/null +++ b/apps/webapp/app/components/integrations/utils.tsx @@ -0,0 +1,34 @@ +export const FIXED_INTEGRATIONS = [ + { + id: "claude", + name: "Claude", + description: "AI assistant for coding, writing, and analysis", + icon: "claude", + slug: "claude", + spec: {}, + }, + { + id: "cursor", + name: "Cursor", + description: "AI-powered code editor", + icon: "cursor", + slug: "cursor", + spec: {}, + }, + { + id: "cline", + name: "Cline", + description: "AI coding assistant for terminal and command line", + icon: "cline", + slug: "cline", + spec: {}, + }, + { + id: "vscode", + name: "Visual Studio Code", + description: "Popular code editor with extensive extensions", + icon: "vscode", + slug: "vscode", + spec: {}, + }, +]; diff --git a/apps/webapp/app/components/logs/log-text-collapse.tsx b/apps/webapp/app/components/logs/log-text-collapse.tsx index dfc6123..d1f74b4 100644 --- a/apps/webapp/app/components/logs/log-text-collapse.tsx +++ b/apps/webapp/app/components/logs/log-text-collapse.tsx @@ -4,6 +4,7 @@ import { AlertCircle, Info, Trash } from "lucide-react"; import { cn } from "~/lib/utils"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog"; import { Button } from "../ui"; +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import { AlertDialog, AlertDialogAction, @@ -15,21 +16,42 @@ import { AlertDialogTitle, AlertDialogTrigger, } from "../ui/alert-dialog"; +import { Badge } from "../ui/badge"; +import { LogItem } from "~/hooks/use-logs"; interface LogTextCollapseProps { text?: string; error?: string; logData: any; + log: LogItem; id: string; episodeUUID?: string; } +const getStatusColor = (status: string) => { + switch (status) { + case "PROCESSING": + return "bg-blue-100 text-blue-800 hover:bg-blue-100 hover:text-blue-800"; + case "PENDING": + return "bg-yellow-100 text-yellow-800 hover:bg-yellow-100 hover:text-yellow-800"; + case "COMPLETED": + return "bg-success/10 text-success hover:bg-success/10 hover:text-success"; + case "FAILED": + return "bg-destructive/10 text-destructive hover:bg-destructive/10 hover:text-destructive"; + case "CANCELLED": + return "bg-gray-100 text-gray-800 hover:bg-gray-100 hover:text-gray-800"; + default: + return "bg-gray-100 text-gray-800 hover:bg-gray-100 hover:text-gray-800"; + } +}; + export function LogTextCollapse({ episodeUUID, text, error, id, logData, + log, }: LogTextCollapseProps) { const [dialogOpen, setDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); @@ -75,19 +97,28 @@ export function LogTextCollapse({ } return ( - <> -
-

+

+
+ > +
+
setDialogOpen(true)} + > +
+
- {isLong && ( - <> @@ -101,66 +132,84 @@ export function LogTextCollapse({ style={{ lineHeight: "1.5" }} dangerouslySetInnerHTML={{ __html: text }} /> + {error && ( +
+
+ +
+

+ Error Details +

+

+ {error} +

+
+
+
+ )}
- - )} -
-
- {isLong && ( -
- - {episodeUUID && ( - - - - - - - Delete Episode - - Are you sure you want to delete this episode? This action - cannot be undone. - - - - Cancel - - Continue - - - - - )} +
+
+ + {log.status.charAt(0).toUpperCase() + + log.status.slice(1).toLowerCase()} + + +
+ {new Date(log.time).toLocaleString()} +
+ + + {episodeUUID && ( + + + + + + + Delete Episode + + Are you sure you want to delete this episode? This + action cannot be undone. + + + + Cancel + + Continue + + + + + )} +
+
- )} - {error && ( -
- - - {error} - -
- )} +
- +
); } diff --git a/apps/webapp/app/components/logs/logs-filters.tsx b/apps/webapp/app/components/logs/logs-filters.tsx index 4d391f5..4ee1d47 100644 --- a/apps/webapp/app/components/logs/logs-filters.tsx +++ b/apps/webapp/app/components/logs/logs-filters.tsx @@ -51,7 +51,7 @@ export function LogsFilters({ const handleBack = () => setStep("main"); return ( -
+
{ diff --git a/apps/webapp/app/components/logs/virtual-logs-list.tsx b/apps/webapp/app/components/logs/virtual-logs-list.tsx index 39d5dc1..c8e15f8 100644 --- a/apps/webapp/app/components/logs/virtual-logs-list.tsx +++ b/apps/webapp/app/components/logs/virtual-logs-list.tsx @@ -9,7 +9,6 @@ import { } from "react-virtualized"; import { type LogItem } from "~/hooks/use-logs"; import { Badge } from "~/components/ui/badge"; -import { Card, CardContent } from "~/components/ui/card"; import { cn } from "~/lib/utils"; import { ScrollManagedList } from "../virtualized-list"; import { LogTextCollapse } from "./log-text-collapse"; @@ -46,23 +45,6 @@ function LogItemRenderer( ); } - const getStatusColor = (status: string) => { - switch (status) { - case "PROCESSING": - return "bg-blue-100 text-blue-800 hover:bg-blue-100 hover:text-blue-800"; - case "PENDING": - return "bg-yellow-100 text-yellow-800 hover:bg-yellow-100 hover:text-yellow-800"; - case "COMPLETED": - return "bg-green-100 text-green-800 hover:bg-green-100 hover:text-green-800"; - case "FAILED": - return "bg-red-100 text-red-800 hover:bg-red-100 hover:text-red-800"; - case "CANCELLED": - return "bg-gray-100 text-gray-800 hover:bg-gray-100 hover:text-gray-800"; - default: - return "bg-gray-100 text-gray-800 hover:bg-gray-100 hover:text-gray-800"; - } - }; - return ( -
- - -
-
- - {log.source} - -
- - {log.status.charAt(0).toUpperCase() + - log.status.slice(1).toLowerCase()} - -
-
-
- {new Date(log.time).toLocaleString()} -
-
- - -
-
+
+
+ +
); diff --git a/apps/webapp/app/lib/neo4j.server.ts b/apps/webapp/app/lib/neo4j.server.ts index e3c05e4..43fe71c 100644 --- a/apps/webapp/app/lib/neo4j.server.ts +++ b/apps/webapp/app/lib/neo4j.server.ts @@ -102,6 +102,153 @@ export const getNodeLinks = async (userId: string) => { return triplets; }; +// Get graph data with cluster information for reified graph +export const getClusteredGraphData = async (userId: string) => { + const session = driver.session(); + try { + // Get the proper reified graph structure: Entity -> Statement -> Entity + const result = await session.run( + `// Get all statements and their entity connections for reified graph + MATCH (s:Statement) + WHERE s.userId = $userId AND s.invalidAt IS NULL + + // Get all entities connected to each statement + MATCH (s)-[:HAS_SUBJECT]->(subj:Entity) + MATCH (s)-[:HAS_PREDICATE]->(pred:Entity) + MATCH (s)-[:HAS_OBJECT]->(obj:Entity) + + // Return both Entity->Statement and Statement->Entity relationships + WITH s, subj, pred, obj + UNWIND [ + // Subject Entity -> Statement + {source: subj, target: s, type: 'HAS_SUBJECT', isEntityToStatement: true}, + // Statement -> Predicate Entity + {source: s, target: pred, type: 'HAS_PREDICATE', isStatementToEntity: true}, + // Statement -> Object Entity + {source: s, target: obj, type: 'HAS_OBJECT', isStatementToEntity: true} + ] AS rel + + RETURN DISTINCT + rel.source.uuid as sourceUuid, + rel.source.name as sourceName, + rel.source.labels as sourceLabels, + rel.source.type as sourceType, + rel.source.properties as sourceProperties, + rel.target.uuid as targetUuid, + rel.target.name as targetName, + rel.target.type as targetType, + rel.target.labels as targetLabels, + rel.target.properties as targetProperties, + rel.type as relationshipType, + s.uuid as statementUuid, + s.clusterId as clusterId, + s.fact as fact, + s.createdAt as createdAt, + rel.isEntityToStatement as isEntityToStatement, + rel.isStatementToEntity as isStatementToEntity`, + { userId }, + ); + + const triplets: RawTriplet[] = []; + const processedEdges = new Set(); + + result.records.forEach((record) => { + const sourceUuid = record.get("sourceUuid"); + const sourceName = record.get("sourceName"); + const sourceType = record.get("sourceType"); + const sourceLabels = record.get("sourceLabels") || []; + const sourceProperties = record.get("sourceProperties") || {}; + + const targetUuid = record.get("targetUuid"); + const targetName = record.get("targetName"); + const targetLabels = record.get("targetLabels") || []; + const targetProperties = record.get("targetProperties") || {}; + const targetType = record.get("targetType"); + + const relationshipType = record.get("relationshipType"); + const statementUuid = record.get("statementUuid"); + const clusterId = record.get("clusterId"); + const fact = record.get("fact"); + const createdAt = record.get("createdAt"); + + // Create unique edge identifier to avoid duplicates + const edgeKey = `${sourceUuid}-${targetUuid}-${relationshipType}`; + if (processedEdges.has(edgeKey)) return; + processedEdges.add(edgeKey); + + // Determine node types and add appropriate cluster information + const isSourceStatement = + sourceLabels.includes("Statement") || sourceUuid === statementUuid; + const isTargetStatement = + targetLabels.includes("Statement") || targetUuid === statementUuid; + + // Statement nodes get cluster info, Entity nodes get default attributes + const sourceAttributes = isSourceStatement + ? { + ...sourceProperties, + clusterId, + nodeType: "Statement", + fact, + } + : { + ...sourceProperties, + nodeType: "Entity", + type: sourceType, + name: sourceName, + }; + + const targetAttributes = isTargetStatement + ? { + ...targetProperties, + clusterId, + nodeType: "Statement", + fact, + } + : { + ...targetProperties, + nodeType: "Entity", + type: targetType, + name: targetName, + }; + + triplets.push({ + sourceNode: { + uuid: sourceUuid, + labels: sourceLabels, + attributes: sourceAttributes, + name: isSourceStatement ? fact : sourceName || sourceUuid, + clusterId, + createdAt: createdAt || "", + }, + edge: { + uuid: `${sourceUuid}-${targetUuid}-${relationshipType}`, + type: relationshipType, + source_node_uuid: sourceUuid, + target_node_uuid: targetUuid, + createdAt: createdAt || "", + }, + targetNode: { + uuid: targetUuid, + labels: targetLabels, + attributes: targetAttributes, + clusterId, + name: isTargetStatement ? fact : targetName || targetUuid, + createdAt: createdAt || "", + }, + }); + }); + + return triplets; + } catch (error) { + logger.error( + `Error getting clustered graph data for user ${userId}: ${error}`, + ); + throw error; + } finally { + await session.close(); + } +}; + export async function initNeo4jSchemaOnce() { if (schemaInitialized) return; diff --git a/apps/webapp/app/routes/api.v1.activity.contribution.tsx b/apps/webapp/app/routes/api.v1.activity.contribution.tsx new file mode 100644 index 0000000..41ecf76 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.activity.contribution.tsx @@ -0,0 +1,67 @@ +import { type LoaderFunctionArgs, json } from "@remix-run/node"; +import { requireUserId } from "~/services/session.server"; +import { prisma } from "~/db.server"; + +export async function loader({ request }: LoaderFunctionArgs) { + const userId = await requireUserId(request); + + // Get user's workspace + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { Workspace: { select: { id: true } } }, + }); + + if (!user?.Workspace) { + throw new Response("Workspace not found", { status: 404 }); + } + + // Get activity data for the last year + const oneYearAgo = new Date(); + oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1); + + const activities = await prisma.ingestionQueue.findMany({ + where: { + workspaceId: user.Workspace.id, + createdAt: { + gte: oneYearAgo, + }, + }, + select: { + createdAt: true, + status: true, + }, + orderBy: { + createdAt: "desc", + }, + }); + + // Group activities by date + const activityByDate = activities.reduce( + (acc, activity) => { + const date = activity.createdAt.toISOString().split("T")[0]; + if (!acc[date]) { + acc[date] = { count: 0, status: activity.status }; + } + acc[date].count += 1; + return acc; + }, + {} as Record, + ); + + // Convert to array format for the component + const contributionData = Object.entries(activityByDate).map( + ([date, data]) => ({ + date, + count: data.count, + status: data.status, + }), + ); + + return json({ + success: true, + data: { + contributionData, + totalActivities: activities.length, + }, + }); +} diff --git a/apps/webapp/app/routes/api.v1.clusters.$clusterId.statements.tsx b/apps/webapp/app/routes/api.v1.clusters.$clusterId.statements.tsx new file mode 100644 index 0000000..fb267f1 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.clusters.$clusterId.statements.tsx @@ -0,0 +1,50 @@ +import { json } from "@remix-run/node"; +import { z } from "zod"; +import { logger } from "~/services/logger.service"; +import { + createLoaderApiRoute, +} from "~/services/routeBuilders/apiBuilder.server"; +import { ClusteringService } from "~/services/clustering.server"; + +const clusteringService = new ClusteringService(); + +const loader = createLoaderApiRoute( + { + allowJWT: true, + findResource: async () => 1, // Dummy resource + authorization: { + action: "search", + }, + corsStrategy: "all", + params: z.object({ + clusterId: z.string(), + }), + }, + async ({ authentication, params }) => { + try { + const statements = await clusteringService.getClusterStatements( + params.clusterId, + authentication.userId, + ); + + return json({ + success: true, + data: { + clusterId: params.clusterId, + statements: statements, + }, + }); + } catch (error) { + logger.error("Error getting cluster statements:", { error }); + return json( + { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }, + { status: 500 }, + ); + } + }, +); + +export { loader }; \ No newline at end of file diff --git a/apps/webapp/app/routes/api.v1.clusters.tsx b/apps/webapp/app/routes/api.v1.clusters.tsx deleted file mode 100644 index e55a37f..0000000 --- a/apps/webapp/app/routes/api.v1.clusters.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { json } from "@remix-run/node"; -import { z } from "zod"; -import { logger } from "~/services/logger.service"; -import { - createActionApiRoute, - createLoaderApiRoute, -} from "~/services/routeBuilders/apiBuilder.server"; -import { ClusteringService } from "~/services/clustering.server"; - -const clusteringService = new ClusteringService(); - -const { action } = createActionApiRoute( - { - body: z.object({ - mode: z - .enum(["auto", "incremental", "complete"]) - .optional() - .default("auto"), - forceComplete: z.boolean().optional().default(false), - }), - allowJWT: true, - authorization: { - action: "search", - }, - corsStrategy: "all", - }, - async ({ body, authentication, request }) => { - console.log(request.method, "asd"); - try { - if (request.method === "POST") { - let result; - switch (body.mode) { - case "incremental": - result = await clusteringService.performIncrementalClustering( - authentication.userId, - ); - break; - case "complete": - result = await clusteringService.performCompleteClustering( - authentication.userId, - ); - break; - case "auto": - default: - result = await clusteringService.performClustering( - authentication.userId, - body.forceComplete, - ); - break; - } - - return json({ - success: true, - data: result, - }); - } else if (request.method === "GET") { - const clusters = await clusteringService.getClusters( - authentication.userId, - ); - return json({ - success: true, - data: clusters, - }); - } - - return json( - { success: false, error: "Method not allowed" }, - { status: 405 }, - ); - } catch (error) { - logger.error("Error in clustering action:", { error }); - return json( - { - success: false, - error: error instanceof Error ? error.message : "Unknown error", - }, - { status: 500 }, - ); - } - }, -); - -const loader = createLoaderApiRoute( - { - allowJWT: true, - findResource: async () => 1, - }, - async ({ authentication }) => { - const clusters = await clusteringService.getClusters(authentication.userId); - return json({ - success: true, - data: clusters, - }); - }, -); - -export { action, loader }; diff --git a/apps/webapp/app/routes/api.v1.graph.clustered.tsx b/apps/webapp/app/routes/api.v1.graph.clustered.tsx new file mode 100644 index 0000000..c285a34 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.graph.clustered.tsx @@ -0,0 +1,46 @@ +import { json } from "@remix-run/node"; +import { logger } from "~/services/logger.service"; +import { + createHybridLoaderApiRoute, + createLoaderApiRoute, +} from "~/services/routeBuilders/apiBuilder.server"; +import { getClusteredGraphData } from "~/lib/neo4j.server"; +import { ClusteringService } from "~/services/clustering.server"; + +const clusteringService = new ClusteringService(); + +const loader = createHybridLoaderApiRoute( + { + allowJWT: true, + corsStrategy: "all", + findResource: async () => 1, + }, + async ({ authentication }) => { + try { + // Get clustered graph data and cluster metadata in parallel + const [graphData, clusters] = await Promise.all([ + getClusteredGraphData(authentication.userId), + clusteringService.getClusters(authentication.userId), + ]); + + return json({ + success: true, + data: { + triplets: graphData, + clusters: clusters, + }, + }); + } catch (error) { + logger.error("Error in clustered graph loader:", { error }); + return json( + { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }, + { status: 500 }, + ); + } + }, +); + +export { loader }; diff --git a/apps/webapp/app/routes/api.v1.mcp.memory.tsx b/apps/webapp/app/routes/api.v1.mcp.memory.tsx index 3a26fc7..76013a1 100644 --- a/apps/webapp/app/routes/api.v1.mcp.memory.tsx +++ b/apps/webapp/app/routes/api.v1.mcp.memory.tsx @@ -17,22 +17,6 @@ const transports: { }; } = {}; -// Cleanup old sessions every 5 minutes -setInterval( - () => { - const now = Date.now(); - const maxAge = 30 * 60 * 1000; // 30 minutes - - Object.keys(transports).forEach((sessionId) => { - if (now - transports[sessionId].createdAt > maxAge) { - transports[sessionId].transport.close(); - delete transports[sessionId]; - } - }); - }, - 5 * 60 * 1000, -); - // MCP request body schema const MCPRequestSchema = z.object({}).passthrough(); const SourceParams = z.object({ diff --git a/apps/webapp/app/routes/home.dashboard.tsx b/apps/webapp/app/routes/home.dashboard.tsx index 648af22..cddc8b7 100644 --- a/apps/webapp/app/routes/home.dashboard.tsx +++ b/apps/webapp/app/routes/home.dashboard.tsx @@ -1,11 +1,13 @@ -import { useState, useEffect } from "react"; +import React, { useState } from "react"; +import { useFetcher } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { requireUserId } from "~/services/session.server"; import { useTypedLoaderData } from "remix-typedjson"; -import { GraphVisualizationClient } from "~/components/graph/graph-client"; import { LoaderCircle } from "lucide-react"; import { PageHeader } from "~/components/common/page-header"; +import { GraphVisualizationClient } from "~/components/graph/graph-client"; +import { GraphNode } from "~/components/graph/type"; export async function loader({ request }: LoaderFunctionArgs) { // Only return userId, not the heavy nodeLinks @@ -15,33 +17,31 @@ export async function loader({ request }: LoaderFunctionArgs) { export default function Dashboard() { const { userId } = useTypedLoaderData(); + const fetcher = useFetcher(); + const [selectedClusterId, setSelectedClusterId] = useState( + null, + ); - // State for nodeLinks and loading - const [nodeLinks, setNodeLinks] = useState(null); - const [loading, setLoading] = useState(false); - - useEffect(() => { - if (!loading && userId) { - fetchNodeLinks(); + // Kick off the fetcher on mount if not already done + React.useEffect(() => { + if (userId && fetcher.state === "idle" && !fetcher.data) { + fetcher.load("/api/v1/graph/clustered"); } - }, [userId]); + }, [userId, fetcher]); - const fetchNodeLinks = async () => { - setLoading(true); - try { - const res = await fetch( - "/node-links?userId=" + encodeURIComponent(userId), - ); - if (!res.ok) throw new Error("Failed to fetch node links"); - const data = await res.json(); + // Determine loading state + const loading = + fetcher.state === "loading" || + fetcher.state === "submitting" || + !fetcher.data; - setNodeLinks(data); - setLoading(false); - } catch (e) { - setNodeLinks([]); - setLoading(false); - } - }; + // Get graph data from fetcher + let graphData: any = null; + if (fetcher.data && fetcher.data.success) { + graphData = fetcher.data.data; + } else if (fetcher.data && !fetcher.data.success) { + graphData = { triplets: [], clusters: [] }; + } return ( <> @@ -55,7 +55,15 @@ export default function Dashboard() {
) : ( typeof window !== "undefined" && - nodeLinks && + graphData && ( + + ) )}
diff --git a/apps/webapp/app/routes/home.integration.$slug.tsx b/apps/webapp/app/routes/home.integration.$slug.tsx index 75aae0b..1d99356 100644 --- a/apps/webapp/app/routes/home.integration.$slug.tsx +++ b/apps/webapp/app/routes/home.integration.$slug.tsx @@ -1,10 +1,10 @@ -import React, { useMemo } from "react"; +import React, { useMemo, useState } from "react"; import { json, type LoaderFunctionArgs, type ActionFunctionArgs, } from "@remix-run/node"; -import { useLoaderData } from "@remix-run/react"; +import { useLoaderData, useParams } from "@remix-run/react"; import { requireUserId, requireWorkpace } from "~/services/session.server"; import { getIntegrationDefinitions } from "~/services/integrationDefinition.server"; import { getIntegrationAccounts } from "~/services/integrationAccount.server"; @@ -21,7 +21,15 @@ import { } from "~/services/ingestionRule.server"; import { Section } from "~/components/integrations/section"; import { PageHeader } from "~/components/common/page-header"; -import { Plus } from "lucide-react"; +import { Check, Copy, Plus } from "lucide-react"; +import { FIXED_INTEGRATIONS } from "~/components/integrations/utils"; +import { + IngestionRule, + type IntegrationAccount, + IntegrationDefinitionV2, +} from "@prisma/client"; +import { Input } from "~/components/ui/input"; +import { Button } from "~/components/ui"; export async function loader({ request, params }: LoaderFunctionArgs) { const userId = await requireUserId(request); @@ -33,7 +41,10 @@ export async function loader({ request, params }: LoaderFunctionArgs) { getIntegrationAccounts(userId), ]); - const integration = integrationDefinitions.find( + // Combine fixed integrations with dynamic ones + const allIntegrations = [...FIXED_INTEGRATIONS, ...integrationDefinitions]; + + const integration = allIntegrations.find( (def) => def.slug === slug || def.id === slug, ); @@ -78,7 +89,10 @@ export async function action({ request, params }: ActionFunctionArgs) { getIntegrationAccounts(userId), ]); - const integration = integrationDefinitions.find( + // Combine fixed integrations with dynamic ones + const allIntegrations = [...FIXED_INTEGRATIONS, ...integrationDefinitions]; + + const integration = allIntegrations.find( (def) => def.slug === slug || def.id === slug, ); @@ -119,14 +133,311 @@ function parseSpec(spec: any) { return spec; } -export default function IntegrationDetail() { - const { integration, integrationAccounts, ingestionRule } = - useLoaderData(); +function CustomIntegrationContent({ integration }: { integration: any }) { + const memoryUrl = `https://core.heysol.ai/api/v1/mcp/memory?source=${integration.slug}`; + const [copied, setCopied] = useState(false); + const copyToClipboard = async () => { + try { + await navigator.clipboard.writeText(memoryUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error("Failed to copy:", err); + } + }; + + const getCustomContent = () => { + switch (integration.id) { + case "claude": + return { + title: "About Claude", + content: ( +
+

+ Claude is an AI assistant created by Anthropic. It can help with + a wide variety of tasks including: +

+
    +
  • Code generation and debugging
  • +
  • Writing and editing
  • +
  • Analysis and research
  • +
  • Problem-solving
  • +
+ +

+ For Claude Web, Desktop, and Code - OAuth authentication handled + automatically +

+ +
+ + +
+
+ ), + }; + case "cursor": + return { + title: "About Cursor", + content: ( +
+

+ Cursor is an AI-powered code editor that helps developers write + code faster and more efficiently. +

+
    +
  • AI-powered code completion
  • +
  • Natural language to code conversion
  • +
  • Code explanation and debugging
  • +
  • Refactoring assistance
  • +
+
+
+                  {JSON.stringify(
+                    {
+                      memory: {
+                        url: memoryUrl,
+                      },
+                    },
+                    null,
+                    2,
+                  )}
+                
+ +
+
+ ), + }; + case "cline": + return { + title: "About Cline", + content: ( +
+

+ Cline is an AI coding assistant that works directly in your + terminal and command line environment. +

+
    +
  • Command line AI assistance
  • +
  • Terminal-based code generation
  • +
  • Shell script optimization
  • +
  • DevOps automation help
  • +
+
+ + +
+
+ ), + }; + case "vscode": + return { + title: "About Visual Studio Code", + content: ( +
+

+ Visual Studio Code is a lightweight but powerful source code + editor with extensive extension support. +

+
    +
  • Intelligent code completion
  • +
  • Built-in Git integration
  • +
  • Extensive extension marketplace
  • +
  • Debugging and testing tools
  • +
+

You need to enable MCP in settings

+
+
+                  {JSON.stringify(
+                    {
+                      "chat.mcp.enabled": true,
+                      "chat.mcp.discovery.enabled": true,
+                    },
+                    null,
+                    2,
+                  )}
+                
+
+
+
+                  {JSON.stringify(
+                    {
+                      memory: {
+                        type: "http",
+                        url: memoryUrl,
+                      },
+                    },
+                    null,
+                    2,
+                  )}
+                
+ +
+
+ ), + }; + default: + return null; + } + }; + + const customContent = getCustomContent(); + + if (!customContent) return null; + const Component = getIcon(integration.icon as IconType); + + return ( +
+ , + onClick: () => + window.open( + "https://github.com/redplanethq/core/issues/new", + "_blank", + ), + variant: "secondary", + }, + ]} + /> +
+
+
+ +
+ } + > +
{customContent.content}
+ +
+
+
+ ); +} + +interface IntegrationDetailProps { + integration: any; + integrationAccounts: any; + ingestionRule: any; +} + +export function IntegrationDetail({ + integration, + integrationAccounts, + ingestionRule, +}: IntegrationDetailProps) { const activeAccount = useMemo( () => integrationAccounts.find( - (acc) => acc.integrationDefinitionId === integration.id && acc.isActive, + (acc: IntegrationAccount) => + acc.integrationDefinitionId === integration.id && acc.isActive, ), [integrationAccounts, integration.id], ); @@ -181,21 +492,21 @@ export default function IntegrationDetail() {
{hasApiKey && (
- + API Key authentication
)} {hasOAuth2 && (
- + OAuth 2.0 authentication
)} {!hasApiKey && !hasOAuth2 && !hasMCPAuth && ( -
+
No authentication method specified
)} @@ -226,7 +537,7 @@ export default function IntegrationDetail() { )} {/* Connected Account Info */} - + {/* MCP Authentication Section */} ); } + +export default function IntegrationDetailWrapper() { + const { integration, integrationAccounts, ingestionRule } = + useLoaderData(); + + const { slug } = useParams(); + // You can now use the `slug` param in your component + + const fixedIntegration = FIXED_INTEGRATIONS.some( + (fixedInt) => fixedInt.slug === slug, + ); + + return ( + <> + {fixedIntegration ? ( + + ) : ( + + )} + + ); +} diff --git a/apps/webapp/app/routes/home.integrations.tsx b/apps/webapp/app/routes/home.integrations.tsx index 1a8af63..65a83e9 100644 --- a/apps/webapp/app/routes/home.integrations.tsx +++ b/apps/webapp/app/routes/home.integrations.tsx @@ -8,6 +8,7 @@ import { getIntegrationAccounts } from "~/services/integrationAccount.server"; import { IntegrationGrid } from "~/components/integrations/integration-grid"; import { PageHeader } from "~/components/common/page-header"; import { Plus } from "lucide-react"; +import { FIXED_INTEGRATIONS } from "~/components/integrations/utils"; export async function loader({ request }: LoaderFunctionArgs) { const userId = await requireUserId(request); @@ -18,8 +19,11 @@ export async function loader({ request }: LoaderFunctionArgs) { getIntegrationAccounts(userId), ]); + // Combine fixed integrations with dynamic ones + const allIntegrations = [...FIXED_INTEGRATIONS, ...integrationDefinitions]; + return json({ - integrationDefinitions, + integrationDefinitions: allIntegrations, integrationAccounts, userId, }); diff --git a/apps/webapp/app/routes/home.logs.all.tsx b/apps/webapp/app/routes/home.logs.all.tsx index 439c520..dc96362 100644 --- a/apps/webapp/app/routes/home.logs.all.tsx +++ b/apps/webapp/app/routes/home.logs.all.tsx @@ -1,5 +1,5 @@ -import { useState } from "react"; -import { useNavigate } from "@remix-run/react"; +import { useState, useEffect } from "react"; +import { useNavigate, useFetcher } from "@remix-run/react"; import { useLogs } from "~/hooks/use-logs"; import { LogsFilters } from "~/components/logs/logs-filters"; import { VirtualLogsList } from "~/components/logs/virtual-logs-list"; @@ -7,11 +7,13 @@ import { AppContainer, PageContainer } from "~/components/layout/app-layout"; import { Card, CardContent } from "~/components/ui/card"; import { Database, LoaderCircle } from "lucide-react"; import { PageHeader } from "~/components/common/page-header"; +import { ContributionGraph } from "~/components/activity/contribution-graph"; export default function LogsAll() { const navigate = useNavigate(); const [selectedSource, setSelectedSource] = useState(); const [selectedStatus, setSelectedStatus] = useState(); + const contributionFetcher = useFetcher(); const { logs, @@ -26,17 +28,41 @@ export default function LogsAll() { status: selectedStatus, }); + // Fetch contribution data on mount + useEffect(() => { + if (contributionFetcher.state === "idle" && !contributionFetcher.data) { + contributionFetcher.load("/api/v1/activity/contribution"); + } + }, [contributionFetcher]); + + // Get contribution data from fetcher + const contributionData = contributionFetcher.data?.success + ? contributionFetcher.data.data.contributionData + : []; + const totalActivities = contributionFetcher.data?.success + ? contributionFetcher.data.data.totalActivities + : 0; + const isContributionLoading = + contributionFetcher.state === "loading" || !contributionFetcher.data; + return ( <> -
+
+ {/* Contribution Graph */} +
+ {isContributionLoading ? ( + + ) : ( + + )} +
{isInitialLoad ? ( <> - {" "} + ) : ( <> - {" "} {/* Filters */} {logs.length > 0 && ( = { + params?: TParamsSchema; + searchParams?: TSearchParamsSchema; + headers?: THeadersSchema; + allowJWT?: boolean; + corsStrategy?: "all" | "none"; + findResource: ( + params: TParamsSchema extends + | z.ZodFirstPartySchemaTypes + | z.ZodDiscriminatedUnion + ? z.infer + : undefined, + authentication: HybridAuthenticationResult, + searchParams: TSearchParamsSchema extends + | z.ZodFirstPartySchemaTypes + | z.ZodDiscriminatedUnion + ? z.infer + : undefined, + ) => Promise; + shouldRetryNotFound?: boolean; + authorization?: { + action: AuthorizationAction; + resource: ( + resource: NonNullable, + params: TParamsSchema extends + | z.ZodFirstPartySchemaTypes + | z.ZodDiscriminatedUnion + ? z.infer + : undefined, + searchParams: TSearchParamsSchema extends + | z.ZodFirstPartySchemaTypes + | z.ZodDiscriminatedUnion + ? z.infer + : undefined, + headers: THeadersSchema extends + | z.ZodFirstPartySchemaTypes + | z.ZodDiscriminatedUnion + ? z.infer + : undefined, + ) => AuthorizationResources; + superScopes?: string[]; + }; +}; + +type HybridLoaderHandlerFunction< + TParamsSchema extends AnyZodSchema | undefined, + TSearchParamsSchema extends AnyZodSchema | undefined, + THeadersSchema extends AnyZodSchema | undefined = undefined, + TResource = never, +> = (args: { + params: TParamsSchema extends + | z.ZodFirstPartySchemaTypes + | z.ZodDiscriminatedUnion + ? z.infer + : undefined; + searchParams: TSearchParamsSchema extends + | z.ZodFirstPartySchemaTypes + | z.ZodDiscriminatedUnion + ? z.infer + : undefined; + headers: THeadersSchema extends + | z.ZodFirstPartySchemaTypes + | z.ZodDiscriminatedUnion + ? z.infer + : undefined; + authentication: HybridAuthenticationResult; + request: Request; + resource: NonNullable; +}) => Promise; + +export function createHybridLoaderApiRoute< + TParamsSchema extends AnyZodSchema | undefined = undefined, + TSearchParamsSchema extends AnyZodSchema | undefined = undefined, + THeadersSchema extends AnyZodSchema | undefined = undefined, + TResource = never, +>( + options: HybridLoaderRouteBuilderOptions< + TParamsSchema, + TSearchParamsSchema, + THeadersSchema, + TResource + >, + handler: HybridLoaderHandlerFunction< + TParamsSchema, + TSearchParamsSchema, + THeadersSchema, + TResource + >, +) { + return async function loader({ request, params }: LoaderFunctionArgs) { + const { + params: paramsSchema, + searchParams: searchParamsSchema, + headers: headersSchema, + allowJWT = false, + corsStrategy = "none", + authorization, + findResource, + shouldRetryNotFound, + } = options; + + if (corsStrategy !== "none" && request.method.toUpperCase() === "OPTIONS") { + return apiCors(request, json({})); + } + + try { + const authenticationResult = await authenticateHybridRequest(request, { + allowJWT, + }); + + if (!authenticationResult) { + return await wrapResponse( + request, + json({ error: "Authentication required" }, { status: 401 }), + corsStrategy !== "none", + ); + } + + let parsedParams: any = undefined; + if (paramsSchema) { + const parsed = paramsSchema.safeParse(params); + if (!parsed.success) { + return await wrapResponse( + request, + json( + { + error: "Params Error", + details: fromZodError(parsed.error).details, + }, + { status: 400 }, + ), + corsStrategy !== "none", + ); + } + parsedParams = parsed.data; + } + + let parsedSearchParams: any = undefined; + if (searchParamsSchema) { + const searchParams = Object.fromEntries( + new URL(request.url).searchParams, + ); + const parsed = searchParamsSchema.safeParse(searchParams); + if (!parsed.success) { + return await wrapResponse( + request, + json( + { + error: "Query Error", + details: fromZodError(parsed.error).details, + }, + { status: 400 }, + ), + corsStrategy !== "none", + ); + } + parsedSearchParams = parsed.data; + } + + let parsedHeaders: any = undefined; + if (headersSchema) { + const rawHeaders = Object.fromEntries(request.headers); + const headers = headersSchema.safeParse(rawHeaders); + if (!headers.success) { + return await wrapResponse( + request, + json( + { + error: "Headers Error", + details: fromZodError(headers.error).details, + }, + { status: 400 }, + ), + corsStrategy !== "none", + ); + } + parsedHeaders = headers.data; + } + + // Find the resource + const resource = await findResource( + parsedParams, + authenticationResult, + parsedSearchParams, + ); + + if (!resource) { + return await wrapResponse( + request, + json( + { error: "Not found" }, + { + status: 404, + headers: { + "x-should-retry": shouldRetryNotFound ? "true" : "false", + }, + }, + ), + corsStrategy !== "none", + ); + } + + // Authorization check - only applies to API key authentication + if (authorization && authenticationResult.type === "PRIVATE") { + const { action, resource: authResource, superScopes } = authorization; + const $authResource = authResource( + resource, + parsedParams, + parsedSearchParams, + parsedHeaders, + ); + + logger.debug("Checking authorization", { + action, + resource: $authResource, + superScopes, + scopes: authenticationResult.scopes, + }); + + const authorizationResult = checkAuthorization(authenticationResult); + + if (!authorizationResult.authorized) { + return await wrapResponse( + request, + json( + { + error: `Unauthorized: ${authorizationResult.reason}`, + code: "unauthorized", + param: "access_token", + type: "authorization", + }, + { status: 403 }, + ), + corsStrategy !== "none", + ); + } + } + + const result = await handler({ + params: parsedParams, + searchParams: parsedSearchParams, + headers: parsedHeaders, + authentication: authenticationResult, + request, + resource, + }); + return await wrapResponse(request, result, corsStrategy !== "none"); + } catch (error) { + try { + if (error instanceof Response) { + return await wrapResponse(request, error, corsStrategy !== "none"); + } + + logger.error("Error in hybrid loader", { + error: + error instanceof Error + ? { + name: error.name, + message: error.message, + stack: error.stack, + } + : String(error), + url: request.url, + }); + + return await wrapResponse( + request, + json({ error: "Internal Server Error" }, { status: 500 }), + corsStrategy !== "none", + ); + } catch (innerError) { + logger.error("[apiBuilder] Failed to handle error", { + error, + innerError, + }); + + return json({ error: "Internal Server Error" }, { status: 500 }); + } + } + }; +} diff --git a/apps/webapp/app/tailwind.css b/apps/webapp/app/tailwind.css index 80f78c5..d4921d2 100644 --- a/apps/webapp/app/tailwind.css +++ b/apps/webapp/app/tailwind.css @@ -465,6 +465,27 @@ } @layer base { + .react-calendar-heatmap { + font-size: 9px; + } + .react-calendar-heatmap .react-calendar-heatmap-month-label { + font-size: 9px; + fill: hsl(var(--muted-foreground)); + } + .react-calendar-heatmap .react-calendar-heatmap-weekday-label { + font-size: 9px; + fill: hsl(var(--muted-foreground)); + } + .react-calendar-heatmap rect { + + rx: 2; + } + .react-calendar-heatmap rect:hover { + + } + + + .tiptap { :first-child { margin-top: 0; @@ -535,3 +556,4 @@ } } } + diff --git a/apps/webapp/app/trigger/cluster/index.ts b/apps/webapp/app/trigger/cluster/index.ts new file mode 100644 index 0000000..7a62d93 --- /dev/null +++ b/apps/webapp/app/trigger/cluster/index.ts @@ -0,0 +1,115 @@ +import { queue, task } from "@trigger.dev/sdk"; +import { z } from "zod"; +import { ClusteringService } from "~/services/clustering.server"; +import { logger } from "~/services/logger.service"; + +const clusteringService = new ClusteringService(); + +// Define the payload schema for cluster tasks +export const ClusterPayload = z.object({ + userId: z.string(), + mode: z.enum(["auto", "incremental", "complete", "drift"]).default("auto"), + forceComplete: z.boolean().default(false), +}); + +const clusterQueue = queue({ + name: "cluster-queue", + concurrencyLimit: 10, +}); + +/** + * Single clustering task that handles all clustering operations based on payload mode + */ +export const clusterTask = task({ + id: "cluster", + queue: clusterQueue, + maxDuration: 1800, // 30 minutes max + run: async (payload: z.infer) => { + logger.info(`Starting ${payload.mode} clustering task for user ${payload.userId}`); + + try { + let result; + + switch (payload.mode) { + case "incremental": + result = await clusteringService.performIncrementalClustering( + payload.userId, + ); + logger.info(`Incremental clustering completed for user ${payload.userId}:`, { + newStatementsProcessed: result.newStatementsProcessed, + newClustersCreated: result.newClustersCreated, + }); + break; + + case "complete": + result = await clusteringService.performCompleteClustering( + payload.userId, + ); + logger.info(`Complete clustering completed for user ${payload.userId}:`, { + clustersCreated: result.clustersCreated, + statementsProcessed: result.statementsProcessed, + }); + break; + + case "drift": + // First detect drift + const driftMetrics = await clusteringService.detectClusterDrift( + payload.userId, + ); + + if (driftMetrics.driftDetected) { + // Handle drift by splitting low-cohesion clusters + const driftResult = await clusteringService.handleClusterDrift( + payload.userId, + ); + + logger.info(`Cluster drift handling completed for user ${payload.userId}:`, { + driftDetected: true, + clustersProcessed: driftResult.clustersProcessed, + newClustersCreated: driftResult.newClustersCreated, + splitClusters: driftResult.splitClusters, + }); + + result = { + driftDetected: true, + ...driftResult, + driftMetrics, + }; + } else { + logger.info(`No cluster drift detected for user ${payload.userId}`); + result = { + driftDetected: false, + clustersProcessed: 0, + newClustersCreated: 0, + splitClusters: [], + driftMetrics, + }; + } + break; + + case "auto": + default: + result = await clusteringService.performClustering( + payload.userId, + payload.forceComplete, + ); + logger.info(`Auto clustering completed for user ${payload.userId}:`, { + clustersCreated: result.clustersCreated, + statementsProcessed: result.statementsProcessed, + approach: result.approach, + }); + break; + } + + return { + success: true, + data: result, + }; + } catch (error) { + logger.error(`${payload.mode} clustering failed for user ${payload.userId}:`, { + error, + }); + throw error; + } + }, +}); diff --git a/apps/webapp/package.json b/apps/webapp/package.json index cf85652..ec16e13 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -75,6 +75,7 @@ "@tiptap/starter-kit": "2.11.9", "@trigger.dev/react-hooks": "^4.0.0-v4-beta.22", "@trigger.dev/sdk": "^4.0.0-v4-beta.22", + "@types/react-calendar-heatmap": "^1.9.0", "ai": "4.3.14", "axios": "^1.10.0", "bullmq": "^5.53.2", @@ -111,6 +112,7 @@ "ollama-ai-provider": "1.2.0", "posthog-js": "^1.116.6", "react": "^18.2.0", + "react-calendar-heatmap": "^1.10.0", "react-dom": "^18.2.0", "react-resizable-panels": "^1.0.9", "react-virtualized": "^9.22.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5022504..dd9fe51 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -463,6 +463,9 @@ importers: '@trigger.dev/sdk': specifier: ^4.0.0-v4-beta.22 version: 4.0.0-v4-beta.22(ai@4.3.14(react@18.3.1)(zod@3.23.8))(zod@3.23.8) + '@types/react-calendar-heatmap': + specifier: ^1.9.0 + version: 1.9.0 ai: specifier: 4.3.14 version: 4.3.14(react@18.3.1)(zod@3.23.8) @@ -571,6 +574,9 @@ importers: react: specifier: ^18.2.0 version: 18.3.1 + react-calendar-heatmap: + specifier: ^1.10.0 + version: 1.10.0(react@18.3.1) react-dom: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) @@ -4971,6 +4977,9 @@ packages: '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/react-calendar-heatmap@1.9.0': + resolution: {integrity: sha512-BH8M/nsXoLGa3hxWbrq3guPwlK0cV+w1i4c/ktrTxTzN5fBths6WbeUZ4dK0+tE76qiGoVSo9Tse8WVVuMIV+w==} + '@types/react-dom@18.2.18': resolution: {integrity: sha512-TJxDm6OfAX2KJWJdMEVTwWke5Sc/E/RlnPGvGfS0W7+6ocy2xhDVQVh/KvC2Uf7kACs+gDytdusDSdWfWkaNzw==} @@ -8272,6 +8281,9 @@ packages: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} + memoize-one@5.2.1: + resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + memorystream@0.3.1: resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} engines: {node: '>= 0.10.0'} @@ -9655,6 +9667,11 @@ packages: rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + react-calendar-heatmap@1.10.0: + resolution: {integrity: sha512-e5vcrzMWzKIF710egr1FpjWyuDEFeZm39nvV25muc8Wtqqi8iDOfqREELeQ9Wouqf9hhj939gq0i+iAxo7KdSw==} + peerDependencies: + react: '>=0.14.0' + react-css-styled@1.1.9: resolution: {integrity: sha512-M7fJZ3IWFaIHcZEkoFOnkjdiUFmwd8d+gTh2bpqMOcnxy/0Gsykw4dsL4QBiKsxcGow6tETUa4NAUcmJF+/nfw==} @@ -15906,6 +15923,10 @@ snapshots: '@types/range-parser@1.2.7': {} + '@types/react-calendar-heatmap@1.9.0': + dependencies: + '@types/react': 18.2.69 + '@types/react-dom@18.2.18': dependencies: '@types/react': 18.2.69 @@ -19898,6 +19919,8 @@ snapshots: media-typer@1.1.0: {} + memoize-one@5.2.1: {} + memorystream@0.3.1: {} meow@12.1.1: {} @@ -21488,6 +21511,12 @@ snapshots: defu: 6.1.4 destr: 2.0.5 + react-calendar-heatmap@1.10.0(react@18.3.1): + dependencies: + memoize-one: 5.2.1 + prop-types: 15.8.1 + react: 18.3.1 + react-css-styled@1.1.9: dependencies: css-styled: 1.0.8