mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-11 16:48:27 +00:00
Improve graph
This commit is contained in:
parent
c9fbb0375b
commit
c0c7083ef8
71
apps/webapp/app/components/activity/contribution-graph.tsx
Normal file
71
apps/webapp/app/components/activity/contribution-graph.tsx
Normal file
@ -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 (
|
||||
<div className={cn("flex w-full flex-col justify-center", className)}>
|
||||
<div className="overflow-x-auto rounded-lg">
|
||||
<CalendarHeatmap
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
values={processedData}
|
||||
classForValue={getClassForValue}
|
||||
titleForValue={getTitleForValue}
|
||||
showWeekdayLabels={true}
|
||||
showMonthLabels={true}
|
||||
gutterSize={2}
|
||||
horizontal={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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<any>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
import("./graph-visualization").then(({ GraphVisualization }) => {
|
||||
setComponent(GraphVisualization);
|
||||
});
|
||||
import("./graph-clustering-visualization").then(
|
||||
({ GraphClusteringVisualization }) => {
|
||||
setComponent(GraphClusteringVisualization);
|
||||
},
|
||||
);
|
||||
}, []);
|
||||
|
||||
if (!Component) {
|
||||
|
||||
@ -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<boolean>(false);
|
||||
const [showEdgePopup, setShowEdgePopup] = useState<boolean>(false);
|
||||
const [nodePopupContent, setNodePopupContent] =
|
||||
useState<NodePopupContent | null>(null);
|
||||
const [edgePopupContent, setEdgePopupContent] =
|
||||
useState<EdgePopupContent | null>(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<string>();
|
||||
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 (
|
||||
<div className={cn("flex flex-col gap-4", className)}>
|
||||
{/* Cluster Filter Dropdown - Marvel style */}
|
||||
<div>
|
||||
<Card className="bg-transparent">
|
||||
<CardContent className="bg-transparent p-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={selectedClusterId || ""}
|
||||
onValueChange={(value) =>
|
||||
value === "all_clusters"
|
||||
? onClusterSelect?.("")
|
||||
: onClusterSelect?.(value || null)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="bg-background w-48 rounded px-2 py-1 text-sm"
|
||||
showIcon
|
||||
>
|
||||
<SelectValue placeholder="All Clusters" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all_clusters">All Clusters</SelectItem>
|
||||
{clusters.map((cluster, index) => {
|
||||
// Get cluster color from the same palette used in the graph
|
||||
const palette = themeMode === "dark" ? nodeColorPalette.dark : nodeColorPalette.light;
|
||||
const clusterColor = palette[index % palette.length];
|
||||
|
||||
return (
|
||||
<SelectItem key={cluster.uuid} value={cluster.uuid}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: clusterColor }}
|
||||
/>
|
||||
<span>{cluster.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{filteredTriplets.length > 0 ? (
|
||||
<GraphClustering
|
||||
ref={ref}
|
||||
triplets={graphTriplets}
|
||||
clusters={clusters}
|
||||
width={width}
|
||||
height={height}
|
||||
onNodeClick={handleNodeClick}
|
||||
onEdgeClick={handleEdgeClick}
|
||||
onClusterClick={handleClusterClick}
|
||||
onBlur={handlePopoverClose}
|
||||
zoomOnMount={zoomOnMount}
|
||||
labelColorMap={sharedLabelColorMap}
|
||||
showClusterLabels={!selectedClusterId} // Show cluster labels when not filtering
|
||||
enableClusterColors={true} // Always enable cluster colors
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground">No graph data to visualize.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Standard Graph Popovers */}
|
||||
<GraphPopovers
|
||||
showNodePopup={showNodePopup}
|
||||
showEdgePopup={showEdgePopup}
|
||||
nodePopupContent={nodePopupContent}
|
||||
edgePopupContent={edgePopupContent}
|
||||
onOpenChange={handlePopoverClose}
|
||||
labelColorMap={sharedLabelColorMap}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
851
apps/webapp/app/components/graph/graph-clustering.tsx
Normal file
851
apps/webapp/app/components/graph/graph-clustering.tsx
Normal file
@ -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<string, number>;
|
||||
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<HTMLDivElement>(null);
|
||||
const sigmaRef = useRef<Sigma | null>(null);
|
||||
const graphRef = useRef<GraphologyGraph | null>(null);
|
||||
const clustersLayerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [themeMode] = useTheme();
|
||||
|
||||
const isInitializedRef = useRef(false);
|
||||
const selectedNodeRef = useRef<string | null>(null);
|
||||
const selectedEdgeRef = useRef<string | null>(null);
|
||||
const selectedClusterRef = useRef<string | null>(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<string, string>();
|
||||
|
||||
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<string>();
|
||||
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<string, GraphNode>();
|
||||
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<string, any>();
|
||||
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<string, any>,
|
||||
);
|
||||
|
||||
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<string, string[]>();
|
||||
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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className=""
|
||||
style={{
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
borderRadius: "8px",
|
||||
cursor: "grab",
|
||||
fontSize: "12px",
|
||||
position: "relative",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
@ -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({
|
||||
<div className="pointer-events-none h-4 w-4" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="h-60 max-w-80 overflow-auto"
|
||||
className="h-35 max-w-80 overflow-auto"
|
||||
side="bottom"
|
||||
align="end"
|
||||
sideOffset={5}
|
||||
@ -104,7 +106,7 @@ export function GraphPopovers({
|
||||
<h4 className="leading-none font-medium">Node Details</h4>
|
||||
{primaryNodeLabel && (
|
||||
<span
|
||||
className="rounded-full px-2 py-1 text-xs font-medium text-white"
|
||||
className="rounded-md px-2 py-1 text-xs font-medium text-white"
|
||||
style={{ backgroundColor: labelColor }}
|
||||
>
|
||||
{primaryNodeLabel}
|
||||
@ -118,7 +120,9 @@ export function GraphPopovers({
|
||||
{attributesToDisplay.map(({ key, value }) => (
|
||||
<p key={key} className="text-sm">
|
||||
<span className="font-medium text-black dark:text-white">
|
||||
{key}:
|
||||
{key.charAt(0).toUpperCase() +
|
||||
key.slice(1).toLowerCase()}
|
||||
:
|
||||
</span>{" "}
|
||||
<span className="text-muted-foreground break-words">
|
||||
{typeof value === "object"
|
||||
@ -134,48 +138,6 @@ export function GraphPopovers({
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Popover open={showEdgePopup} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="pointer-events-none h-4 w-4" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-80 overflow-hidden"
|
||||
side="bottom"
|
||||
align="end"
|
||||
sideOffset={5}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="bg-grayAlpha-100 mb-4 rounded-md p-2">
|
||||
<p className="text-sm break-all">
|
||||
Episode → {edgePopupContent?.target.name || "Unknown"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h4 className="leading-none font-medium">Relationship</h4>
|
||||
<div className="grid gap-2">
|
||||
<p className="text-muted-foreground text-sm break-all">
|
||||
<span className="mr-2 text-sm font-medium text-black dark:text-white">
|
||||
UUID:
|
||||
</span>
|
||||
{edgePopupContent?.relation.uuid || "Unknown"}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm break-all">
|
||||
<span className="mr-2 text-sm font-medium text-black dark:text-white">
|
||||
Type:
|
||||
</span>
|
||||
{edgePopupContent?.relation.type || "Unknown"}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm break-all">
|
||||
<span className="mr-2 text-sm font-medium text-black dark:text-white">
|
||||
Created:
|
||||
</span>
|
||||
{formatDate(edgePopupContent?.relation.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
import colors from "tailwindcss/colors";
|
||||
|
||||
// Define a color palette for node coloring using hex values directly
|
||||
export const nodeColorPalette = {
|
||||
light: [
|
||||
|
||||
@ -5,6 +5,7 @@ export interface Node {
|
||||
labels?: string[];
|
||||
attributes?: Record<string, any>;
|
||||
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 {
|
||||
|
||||
@ -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<string>();
|
||||
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
20
apps/webapp/app/components/icons/claude.tsx
Normal file
20
apps/webapp/app/components/icons/claude.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import type { IconProps } from "./types";
|
||||
|
||||
export function Claude({ size = 18, className }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
height={size}
|
||||
className={className}
|
||||
width={size}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Claude</title>
|
||||
<path
|
||||
d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"
|
||||
fill="#D97757"
|
||||
fill-rule="nonzero"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
19
apps/webapp/app/components/icons/cline.tsx
Normal file
19
apps/webapp/app/components/icons/cline.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import type { IconProps } from "./types";
|
||||
|
||||
export function Cline({ size = 18, className }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
viewBox="0 0 24 24"
|
||||
height={size}
|
||||
className={className}
|
||||
width={size}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Cline</title>
|
||||
<path d="M17.035 3.991c2.75 0 4.98 2.24 4.98 5.003v1.667l1.45 2.896a1.01 1.01 0 01-.002.909l-1.448 2.864v1.668c0 2.762-2.23 5.002-4.98 5.002H7.074c-2.751 0-4.98-2.24-4.98-5.002V17.33l-1.48-2.855a1.01 1.01 0 01-.003-.927l1.482-2.887V8.994c0-2.763 2.23-5.003 4.98-5.003h9.962zM8.265 9.6a2.274 2.274 0 00-2.274 2.274v4.042a2.274 2.274 0 004.547 0v-4.042A2.274 2.274 0 008.265 9.6zm7.326 0a2.274 2.274 0 00-2.274 2.274v4.042a2.274 2.274 0 104.548 0v-4.042A2.274 2.274 0 0015.59 9.6z"></path>
|
||||
<path d="M12.054 5.558a2.779 2.779 0 100-5.558 2.779 2.779 0 000 5.558z"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
64
apps/webapp/app/components/icons/cursor.tsx
Normal file
64
apps/webapp/app/components/icons/cursor.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import type { IconProps } from "./types";
|
||||
|
||||
export function Cursor({ size = 18, className }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
viewBox="0 0 24 24"
|
||||
className={className}
|
||||
height={size}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Cursor</title>
|
||||
<path
|
||||
d="M11.925 24l10.425-6-10.425-6L1.5 18l10.425 6z"
|
||||
fill="url(#lobe-icons-cursorundefined-fill-0)"
|
||||
></path>
|
||||
<path
|
||||
d="M22.35 18V6L11.925 0v12l10.425 6z"
|
||||
fill="url(#lobe-icons-cursorundefined-fill-1)"
|
||||
></path>
|
||||
<path
|
||||
d="M11.925 0L1.5 6v12l10.425-6V0z"
|
||||
fill="url(#lobe-icons-cursorundefined-fill-2)"
|
||||
></path>
|
||||
<path d="M22.35 6L11.925 24V12L22.35 6z" fill="#555"></path>
|
||||
<path d="M22.35 6l-10.425 6L1.5 6h20.85z" fill="#000"></path>
|
||||
<defs>
|
||||
<linearGradient
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="lobe-icons-cursorundefined-fill-0"
|
||||
x1="11.925"
|
||||
x2="11.925"
|
||||
y1="12"
|
||||
y2="24"
|
||||
>
|
||||
<stop offset=".16" stop-color="#000" stop-opacity=".39"></stop>
|
||||
<stop offset=".658" stop-color="#000" stop-opacity=".8"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="lobe-icons-cursorundefined-fill-1"
|
||||
x1="22.35"
|
||||
x2="11.925"
|
||||
y1="6.037"
|
||||
y2="12.15"
|
||||
>
|
||||
<stop offset=".182" stop-color="#000" stop-opacity=".31"></stop>
|
||||
<stop offset=".715" stop-color="#000" stop-opacity="0"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="lobe-icons-cursorundefined-fill-2"
|
||||
x1="11.925"
|
||||
x2="1.5"
|
||||
y1="0"
|
||||
y2="18"
|
||||
>
|
||||
<stop stop-color="#000" stop-opacity=".6"></stop>
|
||||
<stop offset=".667" stop-color="#000" stop-opacity=".22"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
26
apps/webapp/app/components/icons/vscode.tsx
Normal file
26
apps/webapp/app/components/icons/vscode.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import type { IconProps } from "./types";
|
||||
|
||||
export function VSCode({ size = 18, className }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 48 48"
|
||||
width={size}
|
||||
className={className}
|
||||
height={size}
|
||||
>
|
||||
<path
|
||||
fill="#29b6f6"
|
||||
d="M44,11.11v25.78c0,1.27-0.79,2.4-1.98,2.82l-8.82,4.14L34,33V15L33.2,4.15l8.82,4.14 C43.21,8.71,44,9.84,44,11.11z"
|
||||
/>
|
||||
<path
|
||||
fill="#0277bd"
|
||||
d="M9,33.896L34,15V5.353c0-1.198-1.482-1.758-2.275-0.86L4.658,29.239 c-0.9,0.83-0.849,2.267,0.107,3.032c0,0,1.324,1.232,1.803,1.574C7.304,34.37,8.271,34.43,9,33.896z"
|
||||
/>
|
||||
<path
|
||||
fill="#0288d1"
|
||||
d="M9,14.104L34,33v9.647c0,1.198-1.482,1.758-2.275,0.86L4.658,18.761 c-0.9-0.83-0.849-2.267,0.107-3.032c0,0,1.324-1.232,1.803-1.574C7.304,13.63,8.271,13.57,9,14.104z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@ -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({
|
||||
>
|
||||
<Card className="transition-all">
|
||||
<CardHeader className="p-4">
|
||||
<div className="bg-background-2 mb-2 flex h-6 w-6 items-center justify-center rounded">
|
||||
<Component size={18} />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="bg-background-2 mb-2 flex h-6 w-6 items-center justify-center rounded">
|
||||
<Component size={18} />
|
||||
</div>
|
||||
|
||||
{isConnected && (
|
||||
<div className="flex w-full items-center justify-end">
|
||||
<Badge className="h-6 rounded bg-green-100 p-2 text-xs text-green-800">
|
||||
Connected
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="text-base">{integration.name}</CardTitle>
|
||||
<CardDescription className="line-clamp-2 text-xs">
|
||||
{integration.description || `Connect to ${integration.name}`}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{isConnected && (
|
||||
<CardFooter className="p-3">
|
||||
<div className="flex w-full items-center justify-end">
|
||||
<Badge className="h-6 rounded bg-green-100 p-2 text-xs text-green-800">
|
||||
Connected
|
||||
</Badge>
|
||||
</div>
|
||||
</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
|
||||
34
apps/webapp/app/components/integrations/utils.tsx
Normal file
34
apps/webapp/app/components/integrations/utils.tsx
Normal file
@ -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: {},
|
||||
},
|
||||
];
|
||||
@ -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 (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<p
|
||||
<div className="flex w-full items-center">
|
||||
<div
|
||||
className={cn(
|
||||
"group-hover:bg-grayAlpha-100 flex min-w-[0px] shrink grow items-start gap-2 rounded-md px-4",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"whitespace-p-wrap pt-2 text-sm break-words",
|
||||
isLong ? "max-h-16 overflow-hidden" : "",
|
||||
"border-border flex w-full min-w-[0px] shrink flex-col border-b py-2",
|
||||
)}
|
||||
style={{ lineHeight: "1.5" }}
|
||||
dangerouslySetInnerHTML={{ __html: text }}
|
||||
/>
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<div
|
||||
className="inline-flex min-h-[24px] min-w-[0px] shrink cursor-pointer items-center justify-start"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
>
|
||||
<div
|
||||
className={cn("truncate text-left")}
|
||||
dangerouslySetInnerHTML={{ __html: text }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
{isLong && (
|
||||
<>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-2xl p-4">
|
||||
<DialogHeader>
|
||||
@ -101,66 +132,84 @@ export function LogTextCollapse({
|
||||
style={{ lineHeight: "1.5" }}
|
||||
dangerouslySetInnerHTML={{ __html: text }}
|
||||
/>
|
||||
{error && (
|
||||
<div className="mt-4 border-t px-3 py-2">
|
||||
<div className="flex items-start gap-2 text-red-600">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="mb-1 text-sm font-medium">
|
||||
Error Details
|
||||
</p>
|
||||
<p className="text-sm break-words whitespace-pre-wrap">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"text-muted-foreground flex items-center justify-end text-xs",
|
||||
isLong && "justify-between",
|
||||
)}
|
||||
>
|
||||
{isLong && (
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-2 rounded px-2"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
>
|
||||
<Info size={15} />
|
||||
</Button>
|
||||
{episodeUUID && (
|
||||
<AlertDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="rounded px-2">
|
||||
<Trash size={15} />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Episode</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this episode? This action
|
||||
cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete}>
|
||||
Continue
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
<div className="text-muted-foreground flex items-center justify-end text-xs">
|
||||
<div className="flex items-center">
|
||||
<Badge
|
||||
className={cn(
|
||||
"mr-3 rounded text-xs",
|
||||
getStatusColor(log.status),
|
||||
)}
|
||||
>
|
||||
{log.status.charAt(0).toUpperCase() +
|
||||
log.status.slice(1).toLowerCase()}
|
||||
</Badge>
|
||||
|
||||
<div className="text-muted-foreground mr-3">
|
||||
{new Date(log.time).toLocaleString()}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-2 rounded px-2"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
>
|
||||
<Info size={15} />
|
||||
</Button>
|
||||
{episodeUUID && (
|
||||
<AlertDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="rounded px-2"
|
||||
>
|
||||
<Trash size={15} />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Episode</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this episode? This
|
||||
action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete}>
|
||||
Continue
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="flex items-center gap-1 text-red-600">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
<span className="max-w-[200px] truncate" title={error}>
|
||||
{error}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -51,7 +51,7 @@ export function LogsFilters({
|
||||
const handleBack = () => setStep("main");
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex w-full items-center justify-start gap-2">
|
||||
<div className="mb-4 flex w-full items-center justify-start gap-2 px-5">
|
||||
<Popover
|
||||
open={popoverOpen}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
@ -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 (
|
||||
<CellMeasurer
|
||||
key={key}
|
||||
@ -71,40 +53,17 @@ function LogItemRenderer(
|
||||
parent={parent}
|
||||
rowIndex={index}
|
||||
>
|
||||
<div key={key} style={style} className="pb-2">
|
||||
<Card className="h-full">
|
||||
<CardContent className="p-4">
|
||||
<div className="mb-2 flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="rounded text-xs">
|
||||
{log.source}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge
|
||||
className={cn(
|
||||
"rounded text-xs",
|
||||
getStatusColor(log.status),
|
||||
)}
|
||||
>
|
||||
{log.status.charAt(0).toUpperCase() +
|
||||
log.status.slice(1).toLowerCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{new Date(log.time).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LogTextCollapse
|
||||
text={log.ingestText}
|
||||
error={log.error}
|
||||
logData={log.data}
|
||||
id={log.id}
|
||||
episodeUUID={log.episodeUUID}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div key={key} style={style}>
|
||||
<div className="group mx-2 flex cursor-default gap-2">
|
||||
<LogTextCollapse
|
||||
text={log.ingestText}
|
||||
error={log.error}
|
||||
logData={log.data}
|
||||
log={log}
|
||||
id={log.id}
|
||||
episodeUUID={log.episodeUUID}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CellMeasurer>
|
||||
);
|
||||
|
||||
@ -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<string>();
|
||||
|
||||
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;
|
||||
|
||||
|
||||
67
apps/webapp/app/routes/api.v1.activity.contribution.tsx
Normal file
67
apps/webapp/app/routes/api.v1.activity.contribution.tsx
Normal file
@ -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<string, { count: number; status: string }>,
|
||||
);
|
||||
|
||||
// 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -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 };
|
||||
@ -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 };
|
||||
46
apps/webapp/app/routes/api.v1.graph.clustered.tsx
Normal file
46
apps/webapp/app/routes/api.v1.graph.clustered.tsx
Normal file
@ -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 };
|
||||
@ -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({
|
||||
|
||||
@ -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<typeof loader>();
|
||||
const fetcher = useFetcher<any>();
|
||||
const [selectedClusterId, setSelectedClusterId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// State for nodeLinks and loading
|
||||
const [nodeLinks, setNodeLinks] = useState<any[] | null>(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() {
|
||||
</div>
|
||||
) : (
|
||||
typeof window !== "undefined" &&
|
||||
nodeLinks && <GraphVisualizationClient triplets={nodeLinks} />
|
||||
graphData && (
|
||||
<GraphVisualizationClient
|
||||
triplets={graphData.triplets || []}
|
||||
clusters={graphData.clusters || []}
|
||||
selectedClusterId={selectedClusterId}
|
||||
onClusterSelect={setSelectedClusterId}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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<typeof loader>();
|
||||
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: (
|
||||
<div className="space-y-4">
|
||||
<p className="leading-relaxed">
|
||||
Claude is an AI assistant created by Anthropic. It can help with
|
||||
a wide variety of tasks including:
|
||||
</p>
|
||||
<ul className="ml-4 list-inside list-disc space-y-1">
|
||||
<li>Code generation and debugging</li>
|
||||
<li>Writing and editing</li>
|
||||
<li>Analysis and research</li>
|
||||
<li>Problem-solving</li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
For Claude Web, Desktop, and Code - OAuth authentication handled
|
||||
automatically
|
||||
</p>
|
||||
|
||||
<div className="bg-background-3 flex items-center rounded">
|
||||
<Input
|
||||
type="text"
|
||||
id="memoryUrl"
|
||||
value={memoryUrl}
|
||||
readOnly
|
||||
className="bg-background-3 block w-full text-base"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={copyToClipboard}
|
||||
className="px-3"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
case "cursor":
|
||||
return {
|
||||
title: "About Cursor",
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<p className="leading-relaxed">
|
||||
Cursor is an AI-powered code editor that helps developers write
|
||||
code faster and more efficiently.
|
||||
</p>
|
||||
<ul className="ml-4 list-inside list-disc space-y-1">
|
||||
<li>AI-powered code completion</li>
|
||||
<li>Natural language to code conversion</li>
|
||||
<li>Code explanation and debugging</li>
|
||||
<li>Refactoring assistance</li>
|
||||
</ul>
|
||||
<div className="bg-background-3 flex items-center rounded p-2">
|
||||
<pre className="bg-background-3 m-0 block w-full p-0 text-base break-words whitespace-pre-wrap">
|
||||
{JSON.stringify(
|
||||
{
|
||||
memory: {
|
||||
url: memoryUrl,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</pre>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
navigator.clipboard
|
||||
.writeText(
|
||||
JSON.stringify(
|
||||
{
|
||||
memory: {
|
||||
url: memoryUrl,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
.then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
})
|
||||
}
|
||||
className="px-3"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
case "cline":
|
||||
return {
|
||||
title: "About Cline",
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<p className="leading-relaxed">
|
||||
Cline is an AI coding assistant that works directly in your
|
||||
terminal and command line environment.
|
||||
</p>
|
||||
<ul className="ml-4 list-inside list-disc space-y-1">
|
||||
<li>Command line AI assistance</li>
|
||||
<li>Terminal-based code generation</li>
|
||||
<li>Shell script optimization</li>
|
||||
<li>DevOps automation help</li>
|
||||
</ul>
|
||||
<div className="bg-background-3 flex items-center rounded">
|
||||
<Input
|
||||
type="text"
|
||||
id="memoryUrl"
|
||||
value={memoryUrl}
|
||||
readOnly
|
||||
className="bg-background-3 block w-full text-base"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={copyToClipboard}
|
||||
className="px-3"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
case "vscode":
|
||||
return {
|
||||
title: "About Visual Studio Code",
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<p className="leading-relaxed">
|
||||
Visual Studio Code is a lightweight but powerful source code
|
||||
editor with extensive extension support.
|
||||
</p>
|
||||
<ul className="ml-4 list-inside list-disc space-y-1">
|
||||
<li>Intelligent code completion</li>
|
||||
<li>Built-in Git integration</li>
|
||||
<li>Extensive extension marketplace</li>
|
||||
<li>Debugging and testing tools</li>
|
||||
</ul>
|
||||
<p>You need to enable MCP in settings</p>
|
||||
<div className="bg-background-3 flex flex-col items-start gap-2 rounded p-2">
|
||||
<pre>
|
||||
{JSON.stringify(
|
||||
{
|
||||
"chat.mcp.enabled": true,
|
||||
"chat.mcp.discovery.enabled": true,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="bg-background-3 flex items-center rounded p-2">
|
||||
<pre className="bg-background-3 m-0 block w-full p-0 text-base break-words whitespace-pre-wrap">
|
||||
{JSON.stringify(
|
||||
{
|
||||
memory: {
|
||||
type: "http",
|
||||
url: memoryUrl,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</pre>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
navigator.clipboard
|
||||
.writeText(
|
||||
JSON.stringify(
|
||||
{
|
||||
memory: {
|
||||
type: "http",
|
||||
url: memoryUrl,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
.then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
})
|
||||
}
|
||||
className="px-3"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const customContent = getCustomContent();
|
||||
|
||||
if (!customContent) return null;
|
||||
const Component = getIcon(integration.icon as IconType);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<PageHeader
|
||||
title="Integrations"
|
||||
breadcrumbs={[
|
||||
{ label: "Integrations", href: "/home/integrations" },
|
||||
{ label: integration?.name || "Untitled" },
|
||||
]}
|
||||
actions={[
|
||||
{
|
||||
label: "Request New Integration",
|
||||
icon: <Plus size={14} />,
|
||||
onClick: () =>
|
||||
window.open(
|
||||
"https://github.com/redplanethq/core/issues/new",
|
||||
"_blank",
|
||||
),
|
||||
variant: "secondary",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className="flex h-[calc(100vh_-_56px)] flex-col items-center overflow-hidden p-4 px-5">
|
||||
<div className="w-5xl">
|
||||
<Section
|
||||
title={integration.name}
|
||||
description={integration.description}
|
||||
icon={
|
||||
<div className="bg-grayAlpha-100 flex h-12 w-12 items-center justify-center rounded">
|
||||
<Component size={24} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div>{customContent.content}</div>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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() {
|
||||
<div className="space-y-2">
|
||||
{hasApiKey && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-2 text-sm">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Checkbox checked /> API Key authentication
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{hasOAuth2 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-2 text-sm">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Checkbox checked />
|
||||
OAuth 2.0 authentication
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!hasApiKey && !hasOAuth2 && !hasMCPAuth && (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<div className="text-muted-foreground">
|
||||
No authentication method specified
|
||||
</div>
|
||||
)}
|
||||
@ -226,7 +537,7 @@ export default function IntegrationDetail() {
|
||||
)}
|
||||
|
||||
{/* Connected Account Info */}
|
||||
<ConnectedAccountSection activeAccount={activeAccount} />
|
||||
<ConnectedAccountSection activeAccount={activeAccount as any} />
|
||||
|
||||
{/* MCP Authentication Section */}
|
||||
<MCPAuthSection
|
||||
@ -247,3 +558,29 @@ export default function IntegrationDetail() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IntegrationDetailWrapper() {
|
||||
const { integration, integrationAccounts, ingestionRule } =
|
||||
useLoaderData<typeof loader>();
|
||||
|
||||
const { slug } = useParams();
|
||||
// You can now use the `slug` param in your component
|
||||
|
||||
const fixedIntegration = FIXED_INTEGRATIONS.some(
|
||||
(fixedInt) => fixedInt.slug === slug,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{fixedIntegration ? (
|
||||
<CustomIntegrationContent integration={integration} />
|
||||
) : (
|
||||
<IntegrationDetail
|
||||
integration={integration}
|
||||
integrationAccounts={integrationAccounts}
|
||||
ingestionRule={ingestionRule}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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<string | undefined>();
|
||||
const [selectedStatus, setSelectedStatus] = useState<string | undefined>();
|
||||
const contributionFetcher = useFetcher<any>();
|
||||
|
||||
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 (
|
||||
<>
|
||||
<PageHeader title="Logs" />
|
||||
<div className="flex h-[calc(100vh_-_56px)] w-full flex-col items-center space-y-6 p-4 px-5">
|
||||
<div className="flex h-[calc(100vh_-_56px)] w-full flex-col items-center space-y-6 py-4">
|
||||
{/* Contribution Graph */}
|
||||
<div className="mb-0 w-full max-w-5xl px-4">
|
||||
{isContributionLoading ? (
|
||||
<LoaderCircle className="text-primary h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ContributionGraph data={contributionData} />
|
||||
)}
|
||||
</div>
|
||||
{isInitialLoad ? (
|
||||
<>
|
||||
<LoaderCircle className="text-primary h-4 w-4 animate-spin" />{" "}
|
||||
<LoaderCircle className="text-primary h-4 w-4 animate-spin" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{" "}
|
||||
{/* Filters */}
|
||||
{logs.length > 0 && (
|
||||
<LogsFilters
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
import { json, type LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { getNodeLinks } from "~/lib/neo4j.server";
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const url = new URL(request.url);
|
||||
const userId = url.searchParams.get("userId");
|
||||
if (!userId) return json([], { status: 400 });
|
||||
const nodeLinks = await getNodeLinks(userId);
|
||||
return json(nodeLinks);
|
||||
}
|
||||
@ -2,7 +2,6 @@ import { type CoreMessage } from "ai";
|
||||
import { logger } from "./logger.service";
|
||||
import { runQuery } from "~/lib/neo4j.server";
|
||||
import { makeModelCall } from "~/lib/model.server";
|
||||
import crypto from "crypto";
|
||||
|
||||
export interface ClusterNode {
|
||||
uuid: string;
|
||||
|
||||
@ -957,3 +957,291 @@ export function createHybridActionApiRoute<
|
||||
|
||||
return { loader, action };
|
||||
}
|
||||
|
||||
// Hybrid Loader API Route types and builder
|
||||
type HybridLoaderRouteBuilderOptions<
|
||||
TParamsSchema extends AnyZodSchema | undefined = undefined,
|
||||
TSearchParamsSchema extends AnyZodSchema | undefined = undefined,
|
||||
THeadersSchema extends AnyZodSchema | undefined = undefined,
|
||||
TResource = never,
|
||||
> = {
|
||||
params?: TParamsSchema;
|
||||
searchParams?: TSearchParamsSchema;
|
||||
headers?: THeadersSchema;
|
||||
allowJWT?: boolean;
|
||||
corsStrategy?: "all" | "none";
|
||||
findResource: (
|
||||
params: TParamsSchema extends
|
||||
| z.ZodFirstPartySchemaTypes
|
||||
| z.ZodDiscriminatedUnion<any, any>
|
||||
? z.infer<TParamsSchema>
|
||||
: undefined,
|
||||
authentication: HybridAuthenticationResult,
|
||||
searchParams: TSearchParamsSchema extends
|
||||
| z.ZodFirstPartySchemaTypes
|
||||
| z.ZodDiscriminatedUnion<any, any>
|
||||
? z.infer<TSearchParamsSchema>
|
||||
: undefined,
|
||||
) => Promise<TResource | undefined>;
|
||||
shouldRetryNotFound?: boolean;
|
||||
authorization?: {
|
||||
action: AuthorizationAction;
|
||||
resource: (
|
||||
resource: NonNullable<TResource>,
|
||||
params: TParamsSchema extends
|
||||
| z.ZodFirstPartySchemaTypes
|
||||
| z.ZodDiscriminatedUnion<any, any>
|
||||
? z.infer<TParamsSchema>
|
||||
: undefined,
|
||||
searchParams: TSearchParamsSchema extends
|
||||
| z.ZodFirstPartySchemaTypes
|
||||
| z.ZodDiscriminatedUnion<any, any>
|
||||
? z.infer<TSearchParamsSchema>
|
||||
: undefined,
|
||||
headers: THeadersSchema extends
|
||||
| z.ZodFirstPartySchemaTypes
|
||||
| z.ZodDiscriminatedUnion<any, any>
|
||||
? z.infer<THeadersSchema>
|
||||
: 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<any, any>
|
||||
? z.infer<TParamsSchema>
|
||||
: undefined;
|
||||
searchParams: TSearchParamsSchema extends
|
||||
| z.ZodFirstPartySchemaTypes
|
||||
| z.ZodDiscriminatedUnion<any, any>
|
||||
? z.infer<TSearchParamsSchema>
|
||||
: undefined;
|
||||
headers: THeadersSchema extends
|
||||
| z.ZodFirstPartySchemaTypes
|
||||
| z.ZodDiscriminatedUnion<any, any>
|
||||
? z.infer<THeadersSchema>
|
||||
: undefined;
|
||||
authentication: HybridAuthenticationResult;
|
||||
request: Request;
|
||||
resource: NonNullable<TResource>;
|
||||
}) => Promise<Response>;
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -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 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
115
apps/webapp/app/trigger/cluster/index.ts
Normal file
115
apps/webapp/app/trigger/cluster/index.ts
Normal file
@ -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<typeof ClusterPayload>) => {
|
||||
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;
|
||||
}
|
||||
},
|
||||
});
|
||||
@ -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",
|
||||
|
||||
29
pnpm-lock.yaml
generated
29
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user