mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-20 09:18:33 +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 { type GraphVisualizationProps } from "./graph-visualization";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
export function GraphVisualizationClient(props: GraphVisualizationProps) {
|
export function GraphVisualizationClient(
|
||||||
|
props: GraphClusteringVisualizationProps,
|
||||||
|
) {
|
||||||
const [Component, setComponent] = useState<any>(undefined);
|
const [Component, setComponent] = useState<any>(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
import("./graph-visualization").then(({ GraphVisualization }) => {
|
import("./graph-clustering-visualization").then(
|
||||||
setComponent(GraphVisualization);
|
({ GraphClusteringVisualization }) => {
|
||||||
});
|
setComponent(GraphClusteringVisualization);
|
||||||
|
},
|
||||||
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!Component) {
|
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({
|
export function GraphPopovers({
|
||||||
showNodePopup,
|
showNodePopup,
|
||||||
showEdgePopup,
|
|
||||||
nodePopupContent,
|
nodePopupContent,
|
||||||
edgePopupContent,
|
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
labelColorMap,
|
labelColorMap,
|
||||||
}: GraphPopoversProps) {
|
}: GraphPopoversProps) {
|
||||||
@ -52,8 +50,12 @@ export function GraphPopovers({
|
|||||||
|
|
||||||
// Check if node has primaryLabel property (GraphNode)
|
// Check if node has primaryLabel property (GraphNode)
|
||||||
const nodeAny = nodePopupContent.node as any;
|
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
|
// Fall back to original logic with labels
|
||||||
@ -93,7 +95,7 @@ export function GraphPopovers({
|
|||||||
<div className="pointer-events-none h-4 w-4" />
|
<div className="pointer-events-none h-4 w-4" />
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
className="h-60 max-w-80 overflow-auto"
|
className="h-35 max-w-80 overflow-auto"
|
||||||
side="bottom"
|
side="bottom"
|
||||||
align="end"
|
align="end"
|
||||||
sideOffset={5}
|
sideOffset={5}
|
||||||
@ -104,7 +106,7 @@ export function GraphPopovers({
|
|||||||
<h4 className="leading-none font-medium">Node Details</h4>
|
<h4 className="leading-none font-medium">Node Details</h4>
|
||||||
{primaryNodeLabel && (
|
{primaryNodeLabel && (
|
||||||
<span
|
<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 }}
|
style={{ backgroundColor: labelColor }}
|
||||||
>
|
>
|
||||||
{primaryNodeLabel}
|
{primaryNodeLabel}
|
||||||
@ -118,7 +120,9 @@ export function GraphPopovers({
|
|||||||
{attributesToDisplay.map(({ key, value }) => (
|
{attributesToDisplay.map(({ key, value }) => (
|
||||||
<p key={key} className="text-sm">
|
<p key={key} className="text-sm">
|
||||||
<span className="font-medium text-black dark:text-white">
|
<span className="font-medium text-black dark:text-white">
|
||||||
{key}:
|
{key.charAt(0).toUpperCase() +
|
||||||
|
key.slice(1).toLowerCase()}
|
||||||
|
:
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
<span className="text-muted-foreground break-words">
|
<span className="text-muted-foreground break-words">
|
||||||
{typeof value === "object"
|
{typeof value === "object"
|
||||||
@ -134,48 +138,6 @@ export function GraphPopovers({
|
|||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
import colors from "tailwindcss/colors";
|
|
||||||
|
|
||||||
// Define a color palette for node coloring using hex values directly
|
// Define a color palette for node coloring using hex values directly
|
||||||
export const nodeColorPalette = {
|
export const nodeColorPalette = {
|
||||||
light: [
|
light: [
|
||||||
|
|||||||
@ -5,6 +5,7 @@ export interface Node {
|
|||||||
labels?: string[];
|
labels?: string[];
|
||||||
attributes?: Record<string, any>;
|
attributes?: Record<string, any>;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
clusterId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Edge {
|
export interface Edge {
|
||||||
@ -25,6 +26,7 @@ export interface GraphNode extends Node {
|
|||||||
id: string;
|
id: string;
|
||||||
value: string;
|
value: string;
|
||||||
primaryLabel?: string;
|
primaryLabel?: string;
|
||||||
|
clusterId?: string; // Add cluster information
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GraphEdge extends Edge {
|
export interface GraphEdge extends Edge {
|
||||||
|
|||||||
@ -21,6 +21,7 @@ export function toGraphNode(node: Node): GraphNode {
|
|||||||
summary: node.summary,
|
summary: node.summary,
|
||||||
labels: node.labels,
|
labels: node.labels,
|
||||||
primaryLabel,
|
primaryLabel,
|
||||||
|
clusterId: node?.clusterId, // Extract cluster ID from attributes
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,62 +45,109 @@ export function toGraphTriplets(triplets: RawTriplet[]): GraphTriplet[] {
|
|||||||
return triplets.map(toGraphTriplet);
|
return triplets.map(toGraphTriplet);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTriplets(edges: Edge[], nodes: Node[]): RawTriplet[] {
|
export function drawRoundRect(
|
||||||
// Create a Set of node UUIDs that are connected by edges
|
ctx: CanvasRenderingContext2D,
|
||||||
const connectedNodeIds = new Set<string>();
|
x: number,
|
||||||
|
y: number,
|
||||||
// Create triplets from edges
|
width: number,
|
||||||
const edgeTriplets = edges
|
height: number,
|
||||||
.map((edge) => {
|
radius: number,
|
||||||
const sourceNode = nodes.find(
|
): void {
|
||||||
(node) => node.uuid === edge.source_node_uuid,
|
ctx.beginPath();
|
||||||
);
|
ctx.moveTo(x + radius, y);
|
||||||
const targetNode = nodes.find(
|
ctx.lineTo(x + width - radius, y);
|
||||||
(node) => node.uuid === edge.target_node_uuid,
|
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);
|
||||||
if (!sourceNode || !targetNode) return null;
|
ctx.lineTo(x + radius, y + height);
|
||||||
|
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
|
||||||
// Add source and target node IDs to connected set
|
ctx.lineTo(x, y + radius);
|
||||||
connectedNodeIds.add(sourceNode.uuid);
|
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||||
connectedNodeIds.add(targetNode.uuid);
|
ctx.closePath();
|
||||||
|
}
|
||||||
return {
|
|
||||||
sourceNode,
|
const TEXT_COLOR = "#000000";
|
||||||
edge,
|
|
||||||
targetNode,
|
export function drawHover(
|
||||||
};
|
context: CanvasRenderingContext2D,
|
||||||
})
|
data: any,
|
||||||
.filter(
|
settings: any,
|
||||||
(t): t is RawTriplet =>
|
) {
|
||||||
t !== null && t.sourceNode !== undefined && t.targetNode !== undefined,
|
const size = settings.labelSize;
|
||||||
);
|
const font = settings.labelFont;
|
||||||
|
const weight = settings.labelWeight;
|
||||||
// Find isolated nodes (nodes that don't appear in any edge)
|
const subLabelSize = size - 2;
|
||||||
const isolatedNodes = nodes.filter(
|
|
||||||
(node) => !connectedNodeIds.has(node.uuid),
|
const label = data.label;
|
||||||
);
|
const subLabel = data.tag !== "unknown" ? data.tag : "";
|
||||||
|
const entityLabel = data.nodeData.attributes.nodeType;
|
||||||
// For isolated nodes, create special triplets
|
|
||||||
const isolatedTriplets: RawTriplet[] = isolatedNodes.map((node) => {
|
// Simulate the --shadow-1 Tailwind shadow:
|
||||||
// Create a special marker edge for isolated nodes
|
// lch(0 0 0 / 0.022) 0px 3px 6px -2px, lch(0 0 0 / 0.044) 0px 1px 1px;
|
||||||
const virtualEdge: Edge = {
|
// Canvas only supports a single shadow, so we approximate with the stronger one.
|
||||||
uuid: `isolated-node-${node.uuid}`,
|
// lch(0 0 0 / 0.044) is roughly rgba(0,0,0,0.044)
|
||||||
source_node_uuid: node.uuid,
|
context.beginPath();
|
||||||
target_node_uuid: node.uuid,
|
context.fillStyle = "#fff";
|
||||||
// Use a special type that we can filter out in the Graph component
|
context.shadowOffsetX = 0;
|
||||||
type: "_isolated_node_",
|
context.shadowOffsetY = 1;
|
||||||
|
context.shadowBlur = 1;
|
||||||
createdAt: node.createdAt,
|
context.shadowColor = "rgba(0,0,0,0.044)";
|
||||||
};
|
|
||||||
|
context.font = `${weight} ${size}px ${font}`;
|
||||||
return {
|
const labelWidth = context.measureText(label).width;
|
||||||
sourceNode: node,
|
context.font = `${weight} ${subLabelSize}px ${font}`;
|
||||||
edge: virtualEdge,
|
const subLabelWidth = subLabel ? context.measureText(subLabel).width : 0;
|
||||||
targetNode: node,
|
context.font = `${weight} ${subLabelSize}px ${font}`;
|
||||||
};
|
const entityLabelWidth = entityLabel
|
||||||
});
|
? context.measureText(entityLabel).width
|
||||||
|
: 0;
|
||||||
// Combine edge triplets with isolated node triplets
|
|
||||||
return [...edgeTriplets, ...isolatedTriplets];
|
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";
|
} from "@remixicon/react";
|
||||||
import { LayoutGrid } from "lucide-react";
|
import { LayoutGrid } from "lucide-react";
|
||||||
import { LinearIcon, SlackIcon } from "./icons";
|
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 = {
|
export const ICON_MAPPING = {
|
||||||
slack: SlackIcon,
|
slack: SlackIcon,
|
||||||
@ -15,6 +19,10 @@ export const ICON_MAPPING = {
|
|||||||
|
|
||||||
gmail: RiMailFill,
|
gmail: RiMailFill,
|
||||||
linear: LinearIcon,
|
linear: LinearIcon,
|
||||||
|
cursor: Cursor,
|
||||||
|
claude: Claude,
|
||||||
|
cline: Cline,
|
||||||
|
vscode: VSCode,
|
||||||
|
|
||||||
// Default icon
|
// Default icon
|
||||||
integration: LayoutGrid,
|
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 { Link } from "@remix-run/react";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "~/components/ui/card";
|
} from "~/components/ui/card";
|
||||||
@ -35,23 +32,24 @@ export function IntegrationCard({
|
|||||||
>
|
>
|
||||||
<Card className="transition-all">
|
<Card className="transition-all">
|
||||||
<CardHeader className="p-4">
|
<CardHeader className="p-4">
|
||||||
<div className="bg-background-2 mb-2 flex h-6 w-6 items-center justify-center rounded">
|
<div className="flex items-center justify-between">
|
||||||
<Component size={18} />
|
<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>
|
</div>
|
||||||
<CardTitle className="text-base">{integration.name}</CardTitle>
|
<CardTitle className="text-base">{integration.name}</CardTitle>
|
||||||
<CardDescription className="line-clamp-2 text-xs">
|
<CardDescription className="line-clamp-2 text-xs">
|
||||||
{integration.description || `Connect to ${integration.name}`}
|
{integration.description || `Connect to ${integration.name}`}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</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>
|
</Card>
|
||||||
</Link>
|
</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 { cn } from "~/lib/utils";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog";
|
||||||
import { Button } from "../ui";
|
import { Button } from "../ui";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@ -15,21 +16,42 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "../ui/alert-dialog";
|
} from "../ui/alert-dialog";
|
||||||
|
import { Badge } from "../ui/badge";
|
||||||
|
import { LogItem } from "~/hooks/use-logs";
|
||||||
|
|
||||||
interface LogTextCollapseProps {
|
interface LogTextCollapseProps {
|
||||||
text?: string;
|
text?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
logData: any;
|
logData: any;
|
||||||
|
log: LogItem;
|
||||||
id: string;
|
id: string;
|
||||||
episodeUUID?: 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({
|
export function LogTextCollapse({
|
||||||
episodeUUID,
|
episodeUUID,
|
||||||
text,
|
text,
|
||||||
error,
|
error,
|
||||||
id,
|
id,
|
||||||
logData,
|
logData,
|
||||||
|
log,
|
||||||
}: LogTextCollapseProps) {
|
}: LogTextCollapseProps) {
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
@ -75,19 +97,28 @@ export function LogTextCollapse({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex w-full items-center">
|
||||||
<div className="mb-2">
|
<div
|
||||||
<p
|
className={cn(
|
||||||
|
"group-hover:bg-grayAlpha-100 flex min-w-[0px] shrink grow items-start gap-2 rounded-md px-4",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"whitespace-p-wrap pt-2 text-sm break-words",
|
"border-border flex w-full min-w-[0px] shrink flex-col border-b py-2",
|
||||||
isLong ? "max-h-16 overflow-hidden" : "",
|
|
||||||
)}
|
)}
|
||||||
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}>
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
<DialogContent className="max-w-2xl p-4">
|
<DialogContent className="max-w-2xl p-4">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@ -101,66 +132,84 @@ export function LogTextCollapse({
|
|||||||
style={{ lineHeight: "1.5" }}
|
style={{ lineHeight: "1.5" }}
|
||||||
dangerouslySetInnerHTML={{ __html: text }}
|
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>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</>
|
<div className="text-muted-foreground flex items-center justify-end text-xs">
|
||||||
)}
|
<div className="flex items-center">
|
||||||
</div>
|
<Badge
|
||||||
<div
|
className={cn(
|
||||||
className={cn(
|
"mr-3 rounded text-xs",
|
||||||
"text-muted-foreground flex items-center justify-end text-xs",
|
getStatusColor(log.status),
|
||||||
isLong && "justify-between",
|
)}
|
||||||
)}
|
>
|
||||||
>
|
{log.status.charAt(0).toUpperCase() +
|
||||||
{isLong && (
|
log.status.slice(1).toLowerCase()}
|
||||||
<div className="flex items-center">
|
</Badge>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
<div className="text-muted-foreground mr-3">
|
||||||
size="sm"
|
{new Date(log.time).toLocaleString()}
|
||||||
className="-ml-2 rounded px-2"
|
</div>
|
||||||
onClick={() => setDialogOpen(true)}
|
|
||||||
>
|
<Button
|
||||||
<Info size={15} />
|
variant="ghost"
|
||||||
</Button>
|
size="sm"
|
||||||
{episodeUUID && (
|
className="-ml-2 rounded px-2"
|
||||||
<AlertDialog
|
onClick={() => setDialogOpen(true)}
|
||||||
open={deleteDialogOpen}
|
>
|
||||||
onOpenChange={setDeleteDialogOpen}
|
<Info size={15} />
|
||||||
>
|
</Button>
|
||||||
<AlertDialogTrigger asChild>
|
{episodeUUID && (
|
||||||
<Button variant="ghost" size="sm" className="rounded px-2">
|
<AlertDialog
|
||||||
<Trash size={15} />
|
open={deleteDialogOpen}
|
||||||
</Button>
|
onOpenChange={setDeleteDialogOpen}
|
||||||
</AlertDialogTrigger>
|
>
|
||||||
<AlertDialogContent>
|
<AlertDialogTrigger asChild>
|
||||||
<AlertDialogHeader>
|
<Button
|
||||||
<AlertDialogTitle>Delete Episode</AlertDialogTitle>
|
variant="ghost"
|
||||||
<AlertDialogDescription>
|
size="sm"
|
||||||
Are you sure you want to delete this episode? This action
|
className="rounded px-2"
|
||||||
cannot be undone.
|
>
|
||||||
</AlertDialogDescription>
|
<Trash size={15} />
|
||||||
</AlertDialogHeader>
|
</Button>
|
||||||
<AlertDialogFooter>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogContent>
|
||||||
<AlertDialogAction onClick={handleDelete}>
|
<AlertDialogHeader>
|
||||||
Continue
|
<AlertDialogTitle>Delete Episode</AlertDialogTitle>
|
||||||
</AlertDialogAction>
|
<AlertDialogDescription>
|
||||||
</AlertDialogFooter>
|
Are you sure you want to delete this episode? This
|
||||||
</AlertDialogContent>
|
action cannot be undone.
|
||||||
</AlertDialog>
|
</AlertDialogDescription>
|
||||||
)}
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDelete}>
|
||||||
|
Continue
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</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");
|
const handleBack = () => setStep("main");
|
||||||
|
|
||||||
return (
|
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
|
<Popover
|
||||||
open={popoverOpen}
|
open={popoverOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import {
|
|||||||
} from "react-virtualized";
|
} from "react-virtualized";
|
||||||
import { type LogItem } from "~/hooks/use-logs";
|
import { type LogItem } from "~/hooks/use-logs";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Card, CardContent } from "~/components/ui/card";
|
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import { ScrollManagedList } from "../virtualized-list";
|
import { ScrollManagedList } from "../virtualized-list";
|
||||||
import { LogTextCollapse } from "./log-text-collapse";
|
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 (
|
return (
|
||||||
<CellMeasurer
|
<CellMeasurer
|
||||||
key={key}
|
key={key}
|
||||||
@ -71,40 +53,17 @@ function LogItemRenderer(
|
|||||||
parent={parent}
|
parent={parent}
|
||||||
rowIndex={index}
|
rowIndex={index}
|
||||||
>
|
>
|
||||||
<div key={key} style={style} className="pb-2">
|
<div key={key} style={style}>
|
||||||
<Card className="h-full">
|
<div className="group mx-2 flex cursor-default gap-2">
|
||||||
<CardContent className="p-4">
|
<LogTextCollapse
|
||||||
<div className="mb-2 flex items-start justify-between">
|
text={log.ingestText}
|
||||||
<div className="flex items-center gap-2">
|
error={log.error}
|
||||||
<Badge variant="secondary" className="rounded text-xs">
|
logData={log.data}
|
||||||
{log.source}
|
log={log}
|
||||||
</Badge>
|
id={log.id}
|
||||||
<div className="flex items-center gap-1">
|
episodeUUID={log.episodeUUID}
|
||||||
<Badge
|
/>
|
||||||
className={cn(
|
</div>
|
||||||
"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>
|
</div>
|
||||||
</CellMeasurer>
|
</CellMeasurer>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -102,6 +102,153 @@ export const getNodeLinks = async (userId: string) => {
|
|||||||
return triplets;
|
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() {
|
export async function initNeo4jSchemaOnce() {
|
||||||
if (schemaInitialized) return;
|
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
|
// MCP request body schema
|
||||||
const MCPRequestSchema = z.object({}).passthrough();
|
const MCPRequestSchema = z.object({}).passthrough();
|
||||||
const SourceParams = z.object({
|
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 { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||||
import { requireUserId } from "~/services/session.server";
|
import { requireUserId } from "~/services/session.server";
|
||||||
import { useTypedLoaderData } from "remix-typedjson";
|
import { useTypedLoaderData } from "remix-typedjson";
|
||||||
|
|
||||||
import { GraphVisualizationClient } from "~/components/graph/graph-client";
|
|
||||||
import { LoaderCircle } from "lucide-react";
|
import { LoaderCircle } from "lucide-react";
|
||||||
import { PageHeader } from "~/components/common/page-header";
|
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) {
|
export async function loader({ request }: LoaderFunctionArgs) {
|
||||||
// Only return userId, not the heavy nodeLinks
|
// Only return userId, not the heavy nodeLinks
|
||||||
@ -15,33 +17,31 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const { userId } = useTypedLoaderData<typeof loader>();
|
const { userId } = useTypedLoaderData<typeof loader>();
|
||||||
|
const fetcher = useFetcher<any>();
|
||||||
|
const [selectedClusterId, setSelectedClusterId] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
// State for nodeLinks and loading
|
// Kick off the fetcher on mount if not already done
|
||||||
const [nodeLinks, setNodeLinks] = useState<any[] | null>(null);
|
React.useEffect(() => {
|
||||||
const [loading, setLoading] = useState(false);
|
if (userId && fetcher.state === "idle" && !fetcher.data) {
|
||||||
|
fetcher.load("/api/v1/graph/clustered");
|
||||||
useEffect(() => {
|
|
||||||
if (!loading && userId) {
|
|
||||||
fetchNodeLinks();
|
|
||||||
}
|
}
|
||||||
}, [userId]);
|
}, [userId, fetcher]);
|
||||||
|
|
||||||
const fetchNodeLinks = async () => {
|
// Determine loading state
|
||||||
setLoading(true);
|
const loading =
|
||||||
try {
|
fetcher.state === "loading" ||
|
||||||
const res = await fetch(
|
fetcher.state === "submitting" ||
|
||||||
"/node-links?userId=" + encodeURIComponent(userId),
|
!fetcher.data;
|
||||||
);
|
|
||||||
if (!res.ok) throw new Error("Failed to fetch node links");
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
setNodeLinks(data);
|
// Get graph data from fetcher
|
||||||
setLoading(false);
|
let graphData: any = null;
|
||||||
} catch (e) {
|
if (fetcher.data && fetcher.data.success) {
|
||||||
setNodeLinks([]);
|
graphData = fetcher.data.data;
|
||||||
setLoading(false);
|
} else if (fetcher.data && !fetcher.data.success) {
|
||||||
}
|
graphData = { triplets: [], clusters: [] };
|
||||||
};
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -55,7 +55,15 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
typeof window !== "undefined" &&
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import React, { useMemo } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
json,
|
json,
|
||||||
type LoaderFunctionArgs,
|
type LoaderFunctionArgs,
|
||||||
type ActionFunctionArgs,
|
type ActionFunctionArgs,
|
||||||
} from "@remix-run/node";
|
} 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 { requireUserId, requireWorkpace } from "~/services/session.server";
|
||||||
import { getIntegrationDefinitions } from "~/services/integrationDefinition.server";
|
import { getIntegrationDefinitions } from "~/services/integrationDefinition.server";
|
||||||
import { getIntegrationAccounts } from "~/services/integrationAccount.server";
|
import { getIntegrationAccounts } from "~/services/integrationAccount.server";
|
||||||
@ -21,7 +21,15 @@ import {
|
|||||||
} from "~/services/ingestionRule.server";
|
} from "~/services/ingestionRule.server";
|
||||||
import { Section } from "~/components/integrations/section";
|
import { Section } from "~/components/integrations/section";
|
||||||
import { PageHeader } from "~/components/common/page-header";
|
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) {
|
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||||
const userId = await requireUserId(request);
|
const userId = await requireUserId(request);
|
||||||
@ -33,7 +41,10 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
|||||||
getIntegrationAccounts(userId),
|
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,
|
(def) => def.slug === slug || def.id === slug,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -78,7 +89,10 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
|||||||
getIntegrationAccounts(userId),
|
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,
|
(def) => def.slug === slug || def.id === slug,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -119,14 +133,311 @@ function parseSpec(spec: any) {
|
|||||||
return spec;
|
return spec;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function IntegrationDetail() {
|
function CustomIntegrationContent({ integration }: { integration: any }) {
|
||||||
const { integration, integrationAccounts, ingestionRule } =
|
const memoryUrl = `https://core.heysol.ai/api/v1/mcp/memory?source=${integration.slug}`;
|
||||||
useLoaderData<typeof loader>();
|
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(
|
const activeAccount = useMemo(
|
||||||
() =>
|
() =>
|
||||||
integrationAccounts.find(
|
integrationAccounts.find(
|
||||||
(acc) => acc.integrationDefinitionId === integration.id && acc.isActive,
|
(acc: IntegrationAccount) =>
|
||||||
|
acc.integrationDefinitionId === integration.id && acc.isActive,
|
||||||
),
|
),
|
||||||
[integrationAccounts, integration.id],
|
[integrationAccounts, integration.id],
|
||||||
);
|
);
|
||||||
@ -181,21 +492,21 @@ export default function IntegrationDetail() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{hasApiKey && (
|
{hasApiKey && (
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<Checkbox checked /> API Key authentication
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{hasOAuth2 && (
|
{hasOAuth2 && (
|
||||||
<div className="flex items-center gap-2">
|
<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 />
|
<Checkbox checked />
|
||||||
OAuth 2.0 authentication
|
OAuth 2.0 authentication
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!hasApiKey && !hasOAuth2 && !hasMCPAuth && (
|
{!hasApiKey && !hasOAuth2 && !hasMCPAuth && (
|
||||||
<div className="text-muted-foreground text-sm">
|
<div className="text-muted-foreground">
|
||||||
No authentication method specified
|
No authentication method specified
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -226,7 +537,7 @@ export default function IntegrationDetail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Connected Account Info */}
|
{/* Connected Account Info */}
|
||||||
<ConnectedAccountSection activeAccount={activeAccount} />
|
<ConnectedAccountSection activeAccount={activeAccount as any} />
|
||||||
|
|
||||||
{/* MCP Authentication Section */}
|
{/* MCP Authentication Section */}
|
||||||
<MCPAuthSection
|
<MCPAuthSection
|
||||||
@ -247,3 +558,29 @@ export default function IntegrationDetail() {
|
|||||||
</div>
|
</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 { IntegrationGrid } from "~/components/integrations/integration-grid";
|
||||||
import { PageHeader } from "~/components/common/page-header";
|
import { PageHeader } from "~/components/common/page-header";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
|
import { FIXED_INTEGRATIONS } from "~/components/integrations/utils";
|
||||||
|
|
||||||
export async function loader({ request }: LoaderFunctionArgs) {
|
export async function loader({ request }: LoaderFunctionArgs) {
|
||||||
const userId = await requireUserId(request);
|
const userId = await requireUserId(request);
|
||||||
@ -18,8 +19,11 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
getIntegrationAccounts(userId),
|
getIntegrationAccounts(userId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Combine fixed integrations with dynamic ones
|
||||||
|
const allIntegrations = [...FIXED_INTEGRATIONS, ...integrationDefinitions];
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
integrationDefinitions,
|
integrationDefinitions: allIntegrations,
|
||||||
integrationAccounts,
|
integrationAccounts,
|
||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useNavigate } from "@remix-run/react";
|
import { useNavigate, useFetcher } from "@remix-run/react";
|
||||||
import { useLogs } from "~/hooks/use-logs";
|
import { useLogs } from "~/hooks/use-logs";
|
||||||
import { LogsFilters } from "~/components/logs/logs-filters";
|
import { LogsFilters } from "~/components/logs/logs-filters";
|
||||||
import { VirtualLogsList } from "~/components/logs/virtual-logs-list";
|
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 { Card, CardContent } from "~/components/ui/card";
|
||||||
import { Database, LoaderCircle } from "lucide-react";
|
import { Database, LoaderCircle } from "lucide-react";
|
||||||
import { PageHeader } from "~/components/common/page-header";
|
import { PageHeader } from "~/components/common/page-header";
|
||||||
|
import { ContributionGraph } from "~/components/activity/contribution-graph";
|
||||||
|
|
||||||
export default function LogsAll() {
|
export default function LogsAll() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [selectedSource, setSelectedSource] = useState<string | undefined>();
|
const [selectedSource, setSelectedSource] = useState<string | undefined>();
|
||||||
const [selectedStatus, setSelectedStatus] = useState<string | undefined>();
|
const [selectedStatus, setSelectedStatus] = useState<string | undefined>();
|
||||||
|
const contributionFetcher = useFetcher<any>();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
logs,
|
logs,
|
||||||
@ -26,17 +28,41 @@ export default function LogsAll() {
|
|||||||
status: selectedStatus,
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="Logs" />
|
<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 ? (
|
{isInitialLoad ? (
|
||||||
<>
|
<>
|
||||||
<LoaderCircle className="text-primary h-4 w-4 animate-spin" />{" "}
|
<LoaderCircle className="text-primary h-4 w-4 animate-spin" />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{" "}
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
{logs.length > 0 && (
|
{logs.length > 0 && (
|
||||||
<LogsFilters
|
<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 { logger } from "./logger.service";
|
||||||
import { runQuery } from "~/lib/neo4j.server";
|
import { runQuery } from "~/lib/neo4j.server";
|
||||||
import { makeModelCall } from "~/lib/model.server";
|
import { makeModelCall } from "~/lib/model.server";
|
||||||
import crypto from "crypto";
|
|
||||||
|
|
||||||
export interface ClusterNode {
|
export interface ClusterNode {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
|
|||||||
@ -957,3 +957,291 @@ export function createHybridActionApiRoute<
|
|||||||
|
|
||||||
return { loader, action };
|
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 {
|
@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 {
|
.tiptap {
|
||||||
:first-child {
|
:first-child {
|
||||||
margin-top: 0;
|
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",
|
"@tiptap/starter-kit": "2.11.9",
|
||||||
"@trigger.dev/react-hooks": "^4.0.0-v4-beta.22",
|
"@trigger.dev/react-hooks": "^4.0.0-v4-beta.22",
|
||||||
"@trigger.dev/sdk": "^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",
|
"ai": "4.3.14",
|
||||||
"axios": "^1.10.0",
|
"axios": "^1.10.0",
|
||||||
"bullmq": "^5.53.2",
|
"bullmq": "^5.53.2",
|
||||||
@ -111,6 +112,7 @@
|
|||||||
"ollama-ai-provider": "1.2.0",
|
"ollama-ai-provider": "1.2.0",
|
||||||
"posthog-js": "^1.116.6",
|
"posthog-js": "^1.116.6",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
"react-calendar-heatmap": "^1.10.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-resizable-panels": "^1.0.9",
|
"react-resizable-panels": "^1.0.9",
|
||||||
"react-virtualized": "^9.22.6",
|
"react-virtualized": "^9.22.6",
|
||||||
|
|||||||
29
pnpm-lock.yaml
generated
29
pnpm-lock.yaml
generated
@ -463,6 +463,9 @@ importers:
|
|||||||
'@trigger.dev/sdk':
|
'@trigger.dev/sdk':
|
||||||
specifier: ^4.0.0-v4-beta.22
|
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)
|
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:
|
ai:
|
||||||
specifier: 4.3.14
|
specifier: 4.3.14
|
||||||
version: 4.3.14(react@18.3.1)(zod@3.23.8)
|
version: 4.3.14(react@18.3.1)(zod@3.23.8)
|
||||||
@ -571,6 +574,9 @@ importers:
|
|||||||
react:
|
react:
|
||||||
specifier: ^18.2.0
|
specifier: ^18.2.0
|
||||||
version: 18.3.1
|
version: 18.3.1
|
||||||
|
react-calendar-heatmap:
|
||||||
|
specifier: ^1.10.0
|
||||||
|
version: 1.10.0(react@18.3.1)
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^18.2.0
|
specifier: ^18.2.0
|
||||||
version: 18.3.1(react@18.3.1)
|
version: 18.3.1(react@18.3.1)
|
||||||
@ -4971,6 +4977,9 @@ packages:
|
|||||||
'@types/range-parser@1.2.7':
|
'@types/range-parser@1.2.7':
|
||||||
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
|
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':
|
'@types/react-dom@18.2.18':
|
||||||
resolution: {integrity: sha512-TJxDm6OfAX2KJWJdMEVTwWke5Sc/E/RlnPGvGfS0W7+6ocy2xhDVQVh/KvC2Uf7kACs+gDytdusDSdWfWkaNzw==}
|
resolution: {integrity: sha512-TJxDm6OfAX2KJWJdMEVTwWke5Sc/E/RlnPGvGfS0W7+6ocy2xhDVQVh/KvC2Uf7kACs+gDytdusDSdWfWkaNzw==}
|
||||||
|
|
||||||
@ -8272,6 +8281,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
|
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
memoize-one@5.2.1:
|
||||||
|
resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
|
||||||
|
|
||||||
memorystream@0.3.1:
|
memorystream@0.3.1:
|
||||||
resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==}
|
resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==}
|
||||||
engines: {node: '>= 0.10.0'}
|
engines: {node: '>= 0.10.0'}
|
||||||
@ -9655,6 +9667,11 @@ packages:
|
|||||||
rc9@2.1.2:
|
rc9@2.1.2:
|
||||||
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
|
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:
|
react-css-styled@1.1.9:
|
||||||
resolution: {integrity: sha512-M7fJZ3IWFaIHcZEkoFOnkjdiUFmwd8d+gTh2bpqMOcnxy/0Gsykw4dsL4QBiKsxcGow6tETUa4NAUcmJF+/nfw==}
|
resolution: {integrity: sha512-M7fJZ3IWFaIHcZEkoFOnkjdiUFmwd8d+gTh2bpqMOcnxy/0Gsykw4dsL4QBiKsxcGow6tETUa4NAUcmJF+/nfw==}
|
||||||
|
|
||||||
@ -15906,6 +15923,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/range-parser@1.2.7': {}
|
'@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':
|
'@types/react-dom@18.2.18':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/react': 18.2.69
|
'@types/react': 18.2.69
|
||||||
@ -19898,6 +19919,8 @@ snapshots:
|
|||||||
|
|
||||||
media-typer@1.1.0: {}
|
media-typer@1.1.0: {}
|
||||||
|
|
||||||
|
memoize-one@5.2.1: {}
|
||||||
|
|
||||||
memorystream@0.3.1: {}
|
memorystream@0.3.1: {}
|
||||||
|
|
||||||
meow@12.1.1: {}
|
meow@12.1.1: {}
|
||||||
@ -21488,6 +21511,12 @@ snapshots:
|
|||||||
defu: 6.1.4
|
defu: 6.1.4
|
||||||
destr: 2.0.5
|
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:
|
react-css-styled@1.1.9:
|
||||||
dependencies:
|
dependencies:
|
||||||
css-styled: 1.0.8
|
css-styled: 1.0.8
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user