mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-10 08:48:29 +00:00
Feat: clusters (#37)
* Feat: clustering fact statements * Feat: cluster drift * Feat: add recall count and model to search * Feat: Github integration * Fix: clustering UI * Improve graph * Bump: new version --------- Co-authored-by: Manoj K <saimanoj58@gmail.com>
This commit is contained in:
parent
2a6acaf899
commit
4882f227d2
@ -1,4 +1,4 @@
|
||||
VERSION=0.1.13
|
||||
VERSION=0.1.14
|
||||
|
||||
# Nest run in docker, change host to database container name
|
||||
DB_HOST=localhost
|
||||
|
||||
@ -62,7 +62,6 @@
|
||||
"clean": "rimraf dist .tshy .tshy-build .turbo",
|
||||
"typecheck": "tsc -p tsconfig.src.json --noEmit",
|
||||
"build": "tshy",
|
||||
"dev": "tshy --watch",
|
||||
"test": "vitest",
|
||||
"test:e2e": "vitest --run -c ./e2e/vitest.config.ts"
|
||||
},
|
||||
|
||||
@ -2,7 +2,7 @@ import { z } from "zod";
|
||||
|
||||
const EnvironmentSchema = z.object({
|
||||
// Version
|
||||
VERSION: z.string().default("0.1.13"),
|
||||
VERSION: z.string().default("0.1.14"),
|
||||
|
||||
// Database
|
||||
DB_HOST: z.string().default("localhost"),
|
||||
|
||||
71
apps/webapp/app/components/activity/contribution-graph.tsx
Normal file
71
apps/webapp/app/components/activity/contribution-graph.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import React, { useMemo } from "react";
|
||||
import CalendarHeatmap from "react-calendar-heatmap";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface ContributionGraphProps {
|
||||
data: Array<{
|
||||
date: string;
|
||||
count: number;
|
||||
status?: string;
|
||||
}>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ContributionGraph({ data, className }: ContributionGraphProps) {
|
||||
const processedData = useMemo(() => {
|
||||
const endDate = new Date();
|
||||
const startDate = new Date();
|
||||
startDate.setFullYear(endDate.getFullYear() - 1);
|
||||
|
||||
return data.map((item) => ({
|
||||
date: item.date,
|
||||
count: item.count,
|
||||
status: item.status,
|
||||
}));
|
||||
}, [data]);
|
||||
|
||||
const getClassForValue = (value: any) => {
|
||||
if (!value || value.count === 0) {
|
||||
return "fill-background dark:fill-background";
|
||||
}
|
||||
|
||||
const count = value.count;
|
||||
if (count >= 20) return "fill-success";
|
||||
if (count >= 15) return "fill-success/85";
|
||||
if (count >= 10) return "fill-success/70";
|
||||
if (count >= 5) return "fill-success/50";
|
||||
return "fill-success/30";
|
||||
};
|
||||
|
||||
const getTitleForValue = (value: any) => {
|
||||
if (!value || value.count === 0) {
|
||||
return `No activity on ${value?.date || "this date"}`;
|
||||
}
|
||||
|
||||
const count = value.count;
|
||||
const date = new Date(value.date).toLocaleDateString();
|
||||
return `${count} ${count === 1 ? "activity" : "activities"} on ${date}`;
|
||||
};
|
||||
|
||||
const endDate = new Date();
|
||||
const startDate = new Date();
|
||||
startDate.setFullYear(endDate.getFullYear() - 1);
|
||||
|
||||
return (
|
||||
<div className={cn("flex w-full flex-col justify-center", className)}>
|
||||
<div className="overflow-x-auto rounded-lg">
|
||||
<CalendarHeatmap
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
values={processedData}
|
||||
classForValue={getClassForValue}
|
||||
titleForValue={getTitleForValue}
|
||||
showWeekdayLabels={true}
|
||||
showMonthLabels={true}
|
||||
gutterSize={2}
|
||||
horizontal={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,15 +1,21 @@
|
||||
import { GraphClusteringProps } from "./graph-clustering";
|
||||
import { type GraphClusteringVisualizationProps } from "./graph-clustering-visualization";
|
||||
import { type GraphVisualizationProps } from "./graph-visualization";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export function GraphVisualizationClient(props: GraphVisualizationProps) {
|
||||
export function GraphVisualizationClient(
|
||||
props: GraphClusteringVisualizationProps,
|
||||
) {
|
||||
const [Component, setComponent] = useState<any>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
import("./graph-visualization").then(({ GraphVisualization }) => {
|
||||
setComponent(GraphVisualization);
|
||||
});
|
||||
import("./graph-clustering-visualization").then(
|
||||
({ GraphClusteringVisualization }) => {
|
||||
setComponent(GraphClusteringVisualization);
|
||||
},
|
||||
);
|
||||
}, []);
|
||||
|
||||
if (!Component) {
|
||||
|
||||
@ -0,0 +1,271 @@
|
||||
import { useState, useMemo, forwardRef, useEffect } from "react";
|
||||
import { useTheme } from "remix-themes";
|
||||
import { GraphClustering, type GraphClusteringRef } from "./graph-clustering";
|
||||
import { GraphPopovers } from "./graph-popover";
|
||||
import type { RawTriplet, NodePopupContent, EdgePopupContent } from "./type";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
|
||||
import { createLabelColorMap, nodeColorPalette } from "./node-colors";
|
||||
import { toGraphTriplets } from "./utils";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
|
||||
interface ClusterData {
|
||||
uuid: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
size: number;
|
||||
cohesionScore?: number;
|
||||
aspectType?: "thematic" | "social" | "activity";
|
||||
}
|
||||
|
||||
export interface GraphClusteringVisualizationProps {
|
||||
triplets: RawTriplet[];
|
||||
clusters: ClusterData[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
zoomOnMount?: boolean;
|
||||
className?: string;
|
||||
selectedClusterId?: string | null;
|
||||
onClusterSelect?: (clusterId: string | null) => void;
|
||||
}
|
||||
|
||||
export const GraphClusteringVisualization = forwardRef<
|
||||
GraphClusteringRef,
|
||||
GraphClusteringVisualizationProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
triplets,
|
||||
clusters,
|
||||
width = window.innerWidth * 0.85,
|
||||
height = window.innerHeight * 0.85,
|
||||
zoomOnMount = true,
|
||||
className = "rounded-md h-full overflow-hidden relative",
|
||||
selectedClusterId,
|
||||
onClusterSelect,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [themeMode] = useTheme();
|
||||
|
||||
// Graph state for popovers
|
||||
const [showNodePopup, setShowNodePopup] = useState<boolean>(false);
|
||||
const [showEdgePopup, setShowEdgePopup] = useState<boolean>(false);
|
||||
const [nodePopupContent, setNodePopupContent] =
|
||||
useState<NodePopupContent | null>(null);
|
||||
const [edgePopupContent, setEdgePopupContent] =
|
||||
useState<EdgePopupContent | null>(null);
|
||||
|
||||
// Filter triplets based on selected cluster (like Marvel's comic filter)
|
||||
const filteredTriplets = useMemo(() => {
|
||||
if (!selectedClusterId) return triplets;
|
||||
|
||||
// Filter triplets to show only nodes from the selected cluster
|
||||
return triplets.filter(
|
||||
(triplet) =>
|
||||
triplet.sourceNode.attributes?.clusterId === selectedClusterId ||
|
||||
triplet.targetNode.attributes?.clusterId === selectedClusterId,
|
||||
);
|
||||
}, [triplets, selectedClusterId]);
|
||||
|
||||
// Convert filtered triplets to graph triplets
|
||||
const graphTriplets = useMemo(
|
||||
() => toGraphTriplets(filteredTriplets),
|
||||
[filteredTriplets],
|
||||
);
|
||||
|
||||
// Extract all unique labels from triplets
|
||||
const allLabels = useMemo(() => {
|
||||
const labels = new Set<string>();
|
||||
labels.add("Entity"); // Always include Entity as default
|
||||
|
||||
graphTriplets.forEach((triplet) => {
|
||||
if (triplet.source.primaryLabel)
|
||||
labels.add(triplet.source.primaryLabel);
|
||||
if (triplet.target.primaryLabel)
|
||||
labels.add(triplet.target.primaryLabel);
|
||||
});
|
||||
|
||||
return Array.from(labels).sort((a, b) => {
|
||||
// Always put "Entity" first
|
||||
if (a === "Entity") return -1;
|
||||
if (b === "Entity") return 1;
|
||||
// Sort others alphabetically
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
}, [graphTriplets]);
|
||||
|
||||
// Create a shared label color map
|
||||
const sharedLabelColorMap = useMemo(() => {
|
||||
return createLabelColorMap(allLabels);
|
||||
}, [allLabels]);
|
||||
|
||||
// Handle node click
|
||||
const handleNodeClick = (nodeId: string) => {
|
||||
// Find the triplet that contains this node by searching through graphTriplets
|
||||
let foundNode = null;
|
||||
for (const triplet of filteredTriplets) {
|
||||
if (triplet.sourceNode.uuid === nodeId) {
|
||||
foundNode = triplet.sourceNode;
|
||||
break;
|
||||
} else if (triplet.targetNode.uuid === nodeId) {
|
||||
foundNode = triplet.targetNode;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundNode) {
|
||||
// Try to find in the converted graph triplets
|
||||
for (const graphTriplet of graphTriplets) {
|
||||
if (graphTriplet.source.id === nodeId) {
|
||||
foundNode = {
|
||||
uuid: graphTriplet.source.id,
|
||||
value: graphTriplet.source.value,
|
||||
primaryLabel: graphTriplet.source.primaryLabel,
|
||||
attributes: graphTriplet.source,
|
||||
} as any;
|
||||
break;
|
||||
} else if (graphTriplet.target.id === nodeId) {
|
||||
foundNode = {
|
||||
uuid: graphTriplet.target.id,
|
||||
value: graphTriplet.target.value,
|
||||
primaryLabel: graphTriplet.target.primaryLabel,
|
||||
attributes: graphTriplet.target,
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundNode) return;
|
||||
|
||||
// Set popup content and show the popup
|
||||
setNodePopupContent({
|
||||
id: nodeId,
|
||||
node: foundNode,
|
||||
});
|
||||
setShowNodePopup(true);
|
||||
setShowEdgePopup(false);
|
||||
};
|
||||
|
||||
// Handle edge click
|
||||
const handleEdgeClick = (edgeId: string) => {
|
||||
// Find the triplet that contains this edge
|
||||
const triplet = triplets.find((t) => t.edge.uuid === edgeId);
|
||||
|
||||
if (!triplet) return;
|
||||
|
||||
// Set popup content and show the popup
|
||||
setEdgePopupContent({
|
||||
id: edgeId,
|
||||
source: triplet.sourceNode,
|
||||
target: triplet.targetNode,
|
||||
relation: triplet.edge,
|
||||
});
|
||||
setShowEdgePopup(true);
|
||||
setShowNodePopup(false);
|
||||
};
|
||||
|
||||
// Handle cluster click - toggle filter like Marvel
|
||||
const handleClusterClick = (clusterId: string) => {
|
||||
if (onClusterSelect) {
|
||||
const newSelection = selectedClusterId === clusterId ? null : clusterId;
|
||||
onClusterSelect(newSelection);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle popover close
|
||||
const handlePopoverClose = () => {
|
||||
setShowNodePopup(false);
|
||||
setShowEdgePopup(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-4", className)}>
|
||||
{/* Cluster Filter Dropdown - Marvel style */}
|
||||
<div>
|
||||
<Card className="bg-transparent">
|
||||
<CardContent className="bg-transparent p-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={selectedClusterId || ""}
|
||||
onValueChange={(value) =>
|
||||
value === "all_clusters"
|
||||
? onClusterSelect?.("")
|
||||
: onClusterSelect?.(value || null)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="bg-background w-48 rounded px-2 py-1 text-sm"
|
||||
showIcon
|
||||
>
|
||||
<SelectValue placeholder="All Clusters" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all_clusters">All Clusters</SelectItem>
|
||||
{clusters.map((cluster, index) => {
|
||||
// Get cluster color from the same palette used in the graph
|
||||
const palette = themeMode === "dark" ? nodeColorPalette.dark : nodeColorPalette.light;
|
||||
const clusterColor = palette[index % palette.length];
|
||||
|
||||
return (
|
||||
<SelectItem key={cluster.uuid} value={cluster.uuid}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: clusterColor }}
|
||||
/>
|
||||
<span>{cluster.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{filteredTriplets.length > 0 ? (
|
||||
<GraphClustering
|
||||
ref={ref}
|
||||
triplets={graphTriplets}
|
||||
clusters={clusters}
|
||||
width={width}
|
||||
height={height}
|
||||
onNodeClick={handleNodeClick}
|
||||
onEdgeClick={handleEdgeClick}
|
||||
onClusterClick={handleClusterClick}
|
||||
onBlur={handlePopoverClose}
|
||||
zoomOnMount={zoomOnMount}
|
||||
labelColorMap={sharedLabelColorMap}
|
||||
showClusterLabels={!selectedClusterId} // Show cluster labels when not filtering
|
||||
enableClusterColors={true} // Always enable cluster colors
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground">No graph data to visualize.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Standard Graph Popovers */}
|
||||
<GraphPopovers
|
||||
showNodePopup={showNodePopup}
|
||||
showEdgePopup={showEdgePopup}
|
||||
nodePopupContent={nodePopupContent}
|
||||
edgePopupContent={edgePopupContent}
|
||||
onOpenChange={handlePopoverClose}
|
||||
labelColorMap={sharedLabelColorMap}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
851
apps/webapp/app/components/graph/graph-clustering.tsx
Normal file
851
apps/webapp/app/components/graph/graph-clustering.tsx
Normal file
@ -0,0 +1,851 @@
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
forwardRef,
|
||||
} from "react";
|
||||
import Sigma from "sigma";
|
||||
import GraphologyGraph from "graphology";
|
||||
import forceAtlas2 from "graphology-layout-forceatlas2";
|
||||
import FA2Layout from "graphology-layout-forceatlas2/worker";
|
||||
import { EdgeLineProgram } from "sigma/rendering";
|
||||
import colors from "tailwindcss/colors";
|
||||
import type { GraphTriplet, IdValue, GraphNode } from "./type";
|
||||
import {
|
||||
createLabelColorMap,
|
||||
getNodeColor as getNodeColorByLabel,
|
||||
nodeColorPalette,
|
||||
} from "./node-colors";
|
||||
import { useTheme } from "remix-themes";
|
||||
import { drawHover } from "./utils";
|
||||
|
||||
interface ClusterData {
|
||||
uuid: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
size: number;
|
||||
cohesionScore?: number;
|
||||
}
|
||||
|
||||
export interface GraphClusteringProps {
|
||||
triplets: GraphTriplet[];
|
||||
clusters: ClusterData[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
zoomOnMount?: boolean;
|
||||
onNodeClick?: (nodeId: string) => void;
|
||||
onEdgeClick?: (edgeId: string) => void;
|
||||
onClusterClick?: (clusterId: string) => void;
|
||||
onBlur?: () => void;
|
||||
labelColorMap?: Map<string, number>;
|
||||
showClusterLabels?: boolean;
|
||||
enableClusterColors?: boolean;
|
||||
}
|
||||
|
||||
export interface GraphClusteringRef {
|
||||
zoomToLinkById: (linkId: string) => void;
|
||||
zoomToCluster: (clusterId: string) => void;
|
||||
highlightCluster: (clusterId: string) => void;
|
||||
resetHighlights: () => void;
|
||||
}
|
||||
|
||||
// Use node-colors palette for cluster colors
|
||||
const generateClusterColors = (
|
||||
clusterCount: number,
|
||||
isDarkMode: boolean,
|
||||
): string[] => {
|
||||
const palette = isDarkMode ? nodeColorPalette.dark : nodeColorPalette.light;
|
||||
const colors: string[] = [];
|
||||
|
||||
for (let i = 0; i < clusterCount; i++) {
|
||||
colors.push(palette[i % palette.length]);
|
||||
}
|
||||
|
||||
return colors;
|
||||
};
|
||||
|
||||
export const GraphClustering = forwardRef<
|
||||
GraphClusteringRef,
|
||||
GraphClusteringProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
triplets,
|
||||
clusters,
|
||||
width = 1000,
|
||||
height = 800,
|
||||
zoomOnMount = false,
|
||||
onNodeClick,
|
||||
onEdgeClick,
|
||||
onClusterClick,
|
||||
onBlur,
|
||||
labelColorMap: externalLabelColorMap,
|
||||
showClusterLabels = true,
|
||||
enableClusterColors = true,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const sigmaRef = useRef<Sigma | null>(null);
|
||||
const graphRef = useRef<GraphologyGraph | null>(null);
|
||||
const clustersLayerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [themeMode] = useTheme();
|
||||
|
||||
const isInitializedRef = useRef(false);
|
||||
const selectedNodeRef = useRef<string | null>(null);
|
||||
const selectedEdgeRef = useRef<string | null>(null);
|
||||
const selectedClusterRef = useRef<string | null>(null);
|
||||
|
||||
// Create cluster color mapping
|
||||
const clusterColorMap = useMemo(() => {
|
||||
if (!enableClusterColors) return new Map();
|
||||
|
||||
const clusterIds = clusters.map((c) => c.uuid);
|
||||
const clusterColors = generateClusterColors(
|
||||
clusterIds.length,
|
||||
themeMode === "dark",
|
||||
);
|
||||
const colorMap = new Map<string, string>();
|
||||
|
||||
clusterIds.forEach((id, index) => {
|
||||
colorMap.set(id, clusterColors[index]);
|
||||
});
|
||||
|
||||
return colorMap;
|
||||
}, [clusters, enableClusterColors, themeMode]);
|
||||
|
||||
// Memoize theme to prevent unnecessary recreation
|
||||
const theme = useMemo(
|
||||
() => ({
|
||||
node: {
|
||||
fill: colors.pink[500],
|
||||
stroke: themeMode === "dark" ? colors.slate[100] : colors.slate[900],
|
||||
hover: "#646464",
|
||||
text: themeMode === "dark" ? colors.slate[100] : colors.slate[900],
|
||||
selected: "#646464",
|
||||
dimmed: colors.pink[300],
|
||||
},
|
||||
link: {
|
||||
stroke: colors.gray[400],
|
||||
selected: "#646464",
|
||||
dimmed: themeMode === "dark" ? colors.slate[800] : colors.slate[200],
|
||||
},
|
||||
cluster: {
|
||||
labelColor:
|
||||
themeMode === "dark" ? colors.slate[100] : colors.slate[900],
|
||||
labelBg:
|
||||
themeMode === "dark"
|
||||
? colors.slate[800] + "CC"
|
||||
: colors.slate[200] + "CC",
|
||||
},
|
||||
background:
|
||||
themeMode === "dark" ? colors.slate[900] : colors.slate[100],
|
||||
}),
|
||||
[themeMode],
|
||||
);
|
||||
|
||||
// Extract all unique labels from triplets
|
||||
const allLabels = useMemo(() => {
|
||||
if (externalLabelColorMap) return [];
|
||||
const labels = new Set<string>();
|
||||
labels.add("Entity");
|
||||
triplets.forEach((triplet) => {
|
||||
if (triplet.source.primaryLabel)
|
||||
labels.add(triplet.source.primaryLabel);
|
||||
if (triplet.target.primaryLabel)
|
||||
labels.add(triplet.target.primaryLabel);
|
||||
});
|
||||
return Array.from(labels);
|
||||
}, [triplets, externalLabelColorMap]);
|
||||
|
||||
// Create a mapping of label to color
|
||||
const labelColorMap = useMemo(() => {
|
||||
return externalLabelColorMap || createLabelColorMap(allLabels);
|
||||
}, [allLabels, externalLabelColorMap]);
|
||||
|
||||
// Create a mapping of node IDs to their data
|
||||
const nodeDataMap = useMemo(() => {
|
||||
const result = new Map<string, GraphNode>();
|
||||
triplets.forEach((triplet) => {
|
||||
result.set(triplet.source.id, triplet.source);
|
||||
result.set(triplet.target.id, triplet.target);
|
||||
});
|
||||
return result;
|
||||
}, [triplets]);
|
||||
|
||||
// Function to get node color (with cluster coloring support)
|
||||
const getNodeColor = useCallback(
|
||||
(node: any): string => {
|
||||
if (!node) {
|
||||
return getNodeColorByLabel(null, themeMode === "dark", labelColorMap);
|
||||
}
|
||||
|
||||
const nodeData = nodeDataMap.get(node.id) || node;
|
||||
|
||||
// Check if this is a Statement node
|
||||
const isStatementNode =
|
||||
nodeData.attributes.nodeType === "Statement" ||
|
||||
(nodeData.labels && nodeData.labels.includes("Statement"));
|
||||
|
||||
if (isStatementNode) {
|
||||
// Statement nodes with cluster IDs use cluster colors
|
||||
if (
|
||||
enableClusterColors &&
|
||||
nodeData.clusterId &&
|
||||
clusterColorMap.has(nodeData.clusterId)
|
||||
) {
|
||||
return clusterColorMap.get(nodeData.clusterId)!;
|
||||
}
|
||||
|
||||
// Unclustered statement nodes use a specific light color
|
||||
return themeMode === "dark" ? "#2b9684" : "#54935b"; // Teal/Green from palette
|
||||
}
|
||||
|
||||
// Entity nodes use light gray
|
||||
return themeMode === "dark" ? "#6B7280" : "#9CA3AF"; // Tailwind gray-500/gray-400
|
||||
},
|
||||
[
|
||||
labelColorMap,
|
||||
nodeDataMap,
|
||||
themeMode,
|
||||
enableClusterColors,
|
||||
clusterColorMap,
|
||||
],
|
||||
);
|
||||
|
||||
// Process graph data for Sigma
|
||||
const { nodes, edges } = useMemo(() => {
|
||||
const nodeMap = new Map<string, any>();
|
||||
triplets.forEach((triplet) => {
|
||||
if (!nodeMap.has(triplet.source.id)) {
|
||||
const nodeColor = getNodeColor(triplet.source);
|
||||
const isStatementNode =
|
||||
triplet.source.attributes?.nodeType === "Statement" ||
|
||||
(triplet.source.labels &&
|
||||
triplet.source.labels.includes("Statement"));
|
||||
|
||||
nodeMap.set(triplet.source.id, {
|
||||
id: triplet.source.id,
|
||||
label: triplet.source.value
|
||||
? triplet.source.value.split(/\s+/).slice(0, 4).join(" ") +
|
||||
(triplet.source.value.split(/\s+/).length > 4 ? " ..." : "")
|
||||
: "",
|
||||
size: isStatementNode ? 4 : 2, // Statement nodes slightly larger
|
||||
color: nodeColor,
|
||||
x: width,
|
||||
y: height,
|
||||
nodeData: triplet.source,
|
||||
clusterId: triplet.source.clusterId,
|
||||
// Enhanced border for visual appeal, thicker for Statement nodes
|
||||
borderSize: 1,
|
||||
borderColor: nodeColor,
|
||||
});
|
||||
}
|
||||
if (!nodeMap.has(triplet.target.id)) {
|
||||
const nodeColor = getNodeColor(triplet.target);
|
||||
const isStatementNode =
|
||||
triplet.target.attributes?.nodeType === "Statement" ||
|
||||
(triplet.target.labels &&
|
||||
triplet.target.labels.includes("Statement"));
|
||||
|
||||
nodeMap.set(triplet.target.id, {
|
||||
id: triplet.target.id,
|
||||
label: triplet.target.value
|
||||
? triplet.target.value.split(/\s+/).slice(0, 4).join(" ") +
|
||||
(triplet.target.value.split(/\s+/).length > 4 ? " ..." : "")
|
||||
: "",
|
||||
size: isStatementNode ? 4 : 2, // Statement nodes slightly larger
|
||||
color: nodeColor,
|
||||
x: width,
|
||||
y: height,
|
||||
nodeData: triplet.target,
|
||||
clusterId: triplet.target.clusterId,
|
||||
// Enhanced border for visual appeal, thicker for Statement nodes
|
||||
borderSize: 1,
|
||||
borderColor: nodeColor,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const linkGroups = triplets.reduce(
|
||||
(groups, triplet) => {
|
||||
if (triplet.relation.type === "_isolated_node_") {
|
||||
return groups;
|
||||
}
|
||||
let key = `${triplet.source.id}-${triplet.target.id}`;
|
||||
const reverseKey = `${triplet.target.id}-${triplet.source.id}`;
|
||||
if (groups[reverseKey]) {
|
||||
key = reverseKey;
|
||||
}
|
||||
if (!groups[key]) {
|
||||
groups[key] = {
|
||||
id: key,
|
||||
source: triplet.source.id,
|
||||
target: triplet.target.id,
|
||||
relations: [],
|
||||
relationData: [],
|
||||
label: "",
|
||||
color: "#0000001A",
|
||||
labelColor: "#0000001A",
|
||||
size: 1,
|
||||
};
|
||||
}
|
||||
groups[key].relations.push(triplet.relation.value);
|
||||
groups[key].relationData.push(triplet.relation);
|
||||
|
||||
return groups;
|
||||
},
|
||||
{} as Record<string, any>,
|
||||
);
|
||||
|
||||
return {
|
||||
nodes: Array.from(nodeMap.values()),
|
||||
edges: Object.values(linkGroups),
|
||||
};
|
||||
}, [triplets, getNodeColor, width, height]);
|
||||
|
||||
// Helper function to reset highlights without affecting camera
|
||||
const resetHighlights = useCallback(() => {
|
||||
if (!graphRef.current || !sigmaRef.current) return;
|
||||
const graph = graphRef.current;
|
||||
const sigma = sigmaRef.current;
|
||||
|
||||
// Store camera state before making changes
|
||||
const camera = sigma.getCamera();
|
||||
const currentState = camera.getState();
|
||||
|
||||
graph.forEachNode((node) => {
|
||||
const nodeData = graph.getNodeAttribute(node, "nodeData");
|
||||
const originalColor = getNodeColor(nodeData);
|
||||
const isStatementNode =
|
||||
nodeData?.attributes.nodeType === "Statement" ||
|
||||
(nodeData?.labels && nodeData.labels.includes("Statement"));
|
||||
|
||||
graph.setNodeAttribute(node, "highlighted", false);
|
||||
graph.setNodeAttribute(node, "color", originalColor);
|
||||
graph.setNodeAttribute(node, "size", isStatementNode ? 4 : 2);
|
||||
graph.setNodeAttribute(node, "zIndex", 1);
|
||||
});
|
||||
graph.forEachEdge((edge) => {
|
||||
graph.setEdgeAttribute(edge, "highlighted", false);
|
||||
graph.setEdgeAttribute(edge, "color", "#0000001A");
|
||||
graph.setEdgeAttribute(edge, "size", 1);
|
||||
});
|
||||
|
||||
// Restore camera state to prevent unwanted movements
|
||||
camera.setState(currentState);
|
||||
|
||||
selectedNodeRef.current = null;
|
||||
selectedEdgeRef.current = null;
|
||||
selectedClusterRef.current = null;
|
||||
}, [getNodeColor]);
|
||||
|
||||
// Highlight entire cluster
|
||||
const highlightCluster = useCallback(
|
||||
(clusterId: string) => {
|
||||
if (!graphRef.current || !sigmaRef.current) return;
|
||||
|
||||
const graph = graphRef.current;
|
||||
const sigma = sigmaRef.current;
|
||||
|
||||
resetHighlights();
|
||||
selectedClusterRef.current = clusterId;
|
||||
|
||||
const clusterNodes: string[] = [];
|
||||
const clusterColor =
|
||||
clusterColorMap.get(clusterId) || theme.node.selected;
|
||||
|
||||
// Find all nodes in the cluster
|
||||
graph.forEachNode((nodeId, attributes) => {
|
||||
if (attributes.clusterId === clusterId) {
|
||||
clusterNodes.push(nodeId);
|
||||
graph.setNodeAttribute(nodeId, "highlighted", true);
|
||||
graph.setNodeAttribute(nodeId, "color", clusterColor);
|
||||
graph.setNodeAttribute(nodeId, "size", attributes.size * 1.75);
|
||||
graph.setNodeAttribute(nodeId, "zIndex", 2);
|
||||
} else {
|
||||
// Dim other nodes
|
||||
graph.setNodeAttribute(nodeId, "color", theme.node.dimmed);
|
||||
graph.setNodeAttribute(nodeId, "size", attributes.size * 0.7);
|
||||
graph.setNodeAttribute(nodeId, "zIndex", 0);
|
||||
}
|
||||
});
|
||||
|
||||
// Highlight edges within the cluster
|
||||
graph.forEachEdge((edgeId, attributes, source, target) => {
|
||||
const sourceInCluster = clusterNodes.includes(source);
|
||||
const targetInCluster = clusterNodes.includes(target);
|
||||
|
||||
if (sourceInCluster && targetInCluster) {
|
||||
graph.setEdgeAttribute(edgeId, "highlighted", true);
|
||||
graph.setEdgeAttribute(edgeId, "color", clusterColor);
|
||||
graph.setEdgeAttribute(edgeId, "size", 3);
|
||||
} else {
|
||||
graph.setEdgeAttribute(edgeId, "color", theme.link.dimmed);
|
||||
graph.setEdgeAttribute(edgeId, "size", 1);
|
||||
}
|
||||
});
|
||||
},
|
||||
[graphRef, sigmaRef, clusterColorMap, theme, resetHighlights],
|
||||
);
|
||||
|
||||
// Zoom to cluster
|
||||
const zoomToCluster = useCallback(
|
||||
(clusterId: string) => {
|
||||
if (!graphRef.current || !sigmaRef.current) return;
|
||||
|
||||
const graph = graphRef.current;
|
||||
const sigma = sigmaRef.current;
|
||||
const clusterNodes: string[] = [];
|
||||
|
||||
// Find all nodes in the cluster
|
||||
graph.forEachNode((nodeId, attributes) => {
|
||||
if (attributes.clusterId === clusterId) {
|
||||
clusterNodes.push(nodeId);
|
||||
}
|
||||
});
|
||||
|
||||
if (clusterNodes.length === 0) return;
|
||||
|
||||
// Calculate bounding box of cluster nodes
|
||||
let minX = Infinity,
|
||||
maxX = -Infinity;
|
||||
let minY = Infinity,
|
||||
maxY = -Infinity;
|
||||
|
||||
clusterNodes.forEach((nodeId) => {
|
||||
const pos = sigma.getNodeDisplayData(nodeId);
|
||||
if (pos) {
|
||||
minX = Math.min(minX, pos.x);
|
||||
maxX = Math.max(maxX, pos.x);
|
||||
minY = Math.min(minY, pos.y);
|
||||
maxY = Math.max(maxY, pos.y);
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate center and zoom level
|
||||
const centerX = (minX + maxX) / 2;
|
||||
const centerY = (minY + maxY) / 2;
|
||||
const containerRect = containerRef.current?.getBoundingClientRect();
|
||||
|
||||
if (containerRect) {
|
||||
const padding = 100;
|
||||
const clusterWidth = maxX - minX + padding;
|
||||
const clusterHeight = maxY - minY + padding;
|
||||
const ratio = Math.min(
|
||||
containerRect.width / clusterWidth,
|
||||
containerRect.height / clusterHeight,
|
||||
2.0, // Maximum zoom
|
||||
);
|
||||
|
||||
sigma
|
||||
.getCamera()
|
||||
.animate({ x: centerX, y: centerY, ratio }, { duration: 500 });
|
||||
}
|
||||
|
||||
highlightCluster(clusterId);
|
||||
},
|
||||
[highlightCluster],
|
||||
);
|
||||
|
||||
// Expose methods via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
zoomToLinkById: (linkId: string) => {
|
||||
// Implementation similar to original graph component
|
||||
if (!sigmaRef.current || !graphRef.current) return;
|
||||
// ... existing zoomToLinkById logic
|
||||
},
|
||||
zoomToCluster,
|
||||
highlightCluster,
|
||||
resetHighlights,
|
||||
}));
|
||||
|
||||
// Calculate optimal ForceAtlas2 parameters based on graph properties
|
||||
const calculateOptimalParameters = useCallback((graph: GraphologyGraph) => {
|
||||
const nodeCount = graph.order;
|
||||
const edgeCount = graph.size;
|
||||
|
||||
if (nodeCount === 0)
|
||||
return { scalingRatio: 30, gravity: 5, iterations: 600 };
|
||||
|
||||
// Similar logic to original implementation
|
||||
const maxPossibleEdges = (nodeCount * (nodeCount - 1)) / 2;
|
||||
const density = maxPossibleEdges > 0 ? edgeCount / maxPossibleEdges : 0;
|
||||
|
||||
let scalingRatio: number;
|
||||
if (nodeCount < 10) {
|
||||
scalingRatio = 15;
|
||||
} else if (nodeCount < 50) {
|
||||
scalingRatio = 20 + (nodeCount - 10) * 0.5;
|
||||
} else if (nodeCount < 200) {
|
||||
scalingRatio = 40 + (nodeCount - 50) * 0.2;
|
||||
} else {
|
||||
scalingRatio = Math.min(80, 70 + (nodeCount - 200) * 0.05);
|
||||
}
|
||||
|
||||
let gravity: number;
|
||||
if (density > 0.3) {
|
||||
gravity = 1 + density * 2;
|
||||
} else if (density > 0.1) {
|
||||
gravity = 3 + density * 5;
|
||||
} else {
|
||||
gravity = Math.min(8, 5 + (1 - density) * 3);
|
||||
}
|
||||
|
||||
if (nodeCount < 20) {
|
||||
gravity *= 1.5;
|
||||
} else if (nodeCount > 100) {
|
||||
gravity *= 0.8;
|
||||
}
|
||||
|
||||
const complexity = nodeCount + edgeCount;
|
||||
let durationSeconds: number;
|
||||
if (complexity < 50) {
|
||||
durationSeconds = 1.5;
|
||||
} else if (complexity < 200) {
|
||||
durationSeconds = 2.5;
|
||||
} else if (complexity < 500) {
|
||||
durationSeconds = 3.5;
|
||||
} else {
|
||||
durationSeconds = Math.min(6, 4 + (complexity - 500) * 0.004);
|
||||
}
|
||||
|
||||
return {
|
||||
scalingRatio: Math.round(scalingRatio * 10) / 10,
|
||||
gravity: Math.round(gravity * 10) / 10,
|
||||
duration: Math.round(durationSeconds * 100) / 100, // in seconds
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitializedRef.current || !containerRef.current) return;
|
||||
isInitializedRef.current = true;
|
||||
|
||||
// Create graphology graph
|
||||
const graph = new GraphologyGraph();
|
||||
graphRef.current = graph;
|
||||
|
||||
// Add nodes
|
||||
nodes.forEach((node) => {
|
||||
graph.addNode(node.id, node);
|
||||
});
|
||||
|
||||
// Add edges
|
||||
edges.forEach((edge) => {
|
||||
if (graph.hasNode(edge.source) && graph.hasNode(edge.target)) {
|
||||
graph.addEdge(edge.source, edge.target, { ...edge });
|
||||
}
|
||||
});
|
||||
|
||||
// No virtual edges - let the natural graph structure determine layout
|
||||
|
||||
// Apply layout
|
||||
if (graph.order > 0) {
|
||||
// Strong cluster-based positioning for Statement nodes only
|
||||
const clusterNodeMap = new Map<string, string[]>();
|
||||
const entityNodes: string[] = [];
|
||||
|
||||
// Group Statement nodes by their cluster ID, separate Entity nodes
|
||||
graph.forEachNode((nodeId, attributes) => {
|
||||
const isStatementNode =
|
||||
attributes.nodeData?.nodeType === "Statement" ||
|
||||
(attributes.nodeData?.labels &&
|
||||
attributes.nodeData.labels.includes("Statement"));
|
||||
|
||||
if (isStatementNode && attributes.clusterId) {
|
||||
// Statement nodes with cluster IDs go into clusters
|
||||
if (!clusterNodeMap.has(attributes.clusterId)) {
|
||||
clusterNodeMap.set(attributes.clusterId, []);
|
||||
}
|
||||
clusterNodeMap.get(attributes.clusterId)!.push(nodeId);
|
||||
} else {
|
||||
// Entity nodes (or unclustered nodes) positioned separately
|
||||
entityNodes.push(nodeId);
|
||||
}
|
||||
});
|
||||
|
||||
const clusterIds = Array.from(clusterNodeMap.keys());
|
||||
|
||||
if (clusterIds.length > 0) {
|
||||
// Use a more aggressive clustering approach - create distinct regions
|
||||
const padding = Math.min(width, height) * 0.1; // 10% padding
|
||||
const availableWidth = width - 2 * padding;
|
||||
const availableHeight = height - 2 * padding;
|
||||
|
||||
// Calculate optimal grid layout
|
||||
const cols = Math.ceil(Math.sqrt(clusterIds.length));
|
||||
const rows = Math.ceil(clusterIds.length / cols);
|
||||
const cellWidth = availableWidth / cols;
|
||||
const cellHeight = availableHeight / rows;
|
||||
|
||||
clusterIds.forEach((clusterId, index) => {
|
||||
const col = index % cols;
|
||||
const row = Math.floor(index / cols);
|
||||
|
||||
// Calculate cluster region with more separation
|
||||
const regionLeft = padding + col * cellWidth;
|
||||
const regionTop = padding + row * cellHeight;
|
||||
const regionCenterX = regionLeft + cellWidth / 2;
|
||||
const regionCenterY = regionTop + cellHeight / 2;
|
||||
|
||||
// Get nodes in this cluster
|
||||
const nodesInCluster = clusterNodeMap.get(clusterId)!;
|
||||
const clusterSize = nodesInCluster.length;
|
||||
|
||||
// Create cluster radius with Marvel-style spacing - more generous
|
||||
const maxRadius = Math.min(cellWidth, cellHeight) * 0.35;
|
||||
const baseSpacing = 150; // Larger base spacing between nodes
|
||||
const clusterRadius = Math.max(
|
||||
baseSpacing,
|
||||
Math.min(maxRadius, Math.sqrt(clusterSize) * baseSpacing * 1.2),
|
||||
);
|
||||
|
||||
if (clusterSize === 1) {
|
||||
// Single node at region center
|
||||
graph.setNodeAttribute(nodesInCluster[0], "x", regionCenterX);
|
||||
graph.setNodeAttribute(nodesInCluster[0], "y", regionCenterY);
|
||||
} else if (clusterSize <= 6) {
|
||||
// Small clusters - tight circle
|
||||
nodesInCluster.forEach((nodeId, nodeIndex) => {
|
||||
const angle = (nodeIndex / clusterSize) * 2 * Math.PI;
|
||||
const x = regionCenterX + Math.cos(angle) * clusterRadius;
|
||||
const y = regionCenterY + Math.sin(angle) * clusterRadius;
|
||||
graph.setNodeAttribute(nodeId, "x", x);
|
||||
graph.setNodeAttribute(nodeId, "y", y);
|
||||
});
|
||||
} else {
|
||||
// Larger clusters - dense spiral pattern
|
||||
nodesInCluster.forEach((nodeId, nodeIndex) => {
|
||||
const spiralTurns = Math.ceil(clusterSize / 8);
|
||||
const angle =
|
||||
(nodeIndex / clusterSize) * 2 * Math.PI * spiralTurns;
|
||||
const radius = (nodeIndex / clusterSize) * clusterRadius;
|
||||
const x = regionCenterX + Math.cos(angle) * radius;
|
||||
const y = regionCenterY + Math.sin(angle) * radius;
|
||||
graph.setNodeAttribute(nodeId, "x", x);
|
||||
graph.setNodeAttribute(nodeId, "y", y);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Position Entity nodes using ForceAtlas2 natural positioning
|
||||
// They will be positioned by the algorithm based on their connections to Statement nodes
|
||||
entityNodes.forEach((nodeId) => {
|
||||
// Give them initial random positions, ForceAtlas2 will adjust based on connections
|
||||
graph.setNodeAttribute(nodeId, "x", Math.random() * width);
|
||||
graph.setNodeAttribute(nodeId, "y", Math.random() * height);
|
||||
});
|
||||
|
||||
const optimalParams = calculateOptimalParameters(graph);
|
||||
const settings = forceAtlas2.inferSettings(graph);
|
||||
|
||||
console.log(optimalParams);
|
||||
const layout = new FA2Layout(graph, {
|
||||
settings: {
|
||||
...settings,
|
||||
barnesHutOptimize: true,
|
||||
strongGravityMode: false, // Marvel doesn't use strong gravity
|
||||
gravity: Math.max(0.1, optimalParams.gravity * 0.005), // Much weaker gravity like Marvel
|
||||
scalingRatio: optimalParams.scalingRatio * 10, // Higher scaling for more spacing
|
||||
slowDown: 20, // Much slower to preserve cluster positions
|
||||
outboundAttractionDistribution: false, // Use standard distribution
|
||||
linLogMode: false, // Linear mode
|
||||
edgeWeightInfluence: 0, // Disable edge weight influence to maintain positioning
|
||||
},
|
||||
});
|
||||
|
||||
layout.start();
|
||||
setTimeout(() => layout.stop(), (optimalParams.duration ?? 2) * 1000);
|
||||
}
|
||||
|
||||
// Create Sigma instance
|
||||
const sigma = new Sigma(graph, containerRef.current, {
|
||||
renderEdgeLabels: true,
|
||||
defaultEdgeColor: "#0000001A",
|
||||
defaultNodeColor: theme.node.fill,
|
||||
defaultEdgeType: "edges-fast",
|
||||
edgeProgramClasses: {
|
||||
"edges-fast": EdgeLineProgram,
|
||||
},
|
||||
renderLabels: false,
|
||||
enableEdgeEvents: true,
|
||||
minCameraRatio: 0.01,
|
||||
defaultDrawNodeHover: drawHover,
|
||||
|
||||
maxCameraRatio: 2,
|
||||
allowInvalidContainer: false,
|
||||
});
|
||||
|
||||
sigmaRef.current = sigma;
|
||||
|
||||
// Set up camera for zoom on mount
|
||||
if (zoomOnMount) {
|
||||
setTimeout(() => {
|
||||
sigma
|
||||
.getCamera()
|
||||
.animate(sigma.getCamera().getState(), { duration: 750 });
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Update cluster labels after any camera movement
|
||||
sigma.getCamera().on("updated", () => {
|
||||
if (showClusterLabels) {
|
||||
}
|
||||
});
|
||||
|
||||
// Drag and drop implementation (same as original)
|
||||
let draggedNode: string | null = null;
|
||||
let isDragging = false;
|
||||
|
||||
sigma.on("downNode", (e) => {
|
||||
isDragging = true;
|
||||
draggedNode = e.node;
|
||||
graph.setNodeAttribute(draggedNode, "highlighted", true);
|
||||
if (!sigma.getCustomBBox()) sigma.setCustomBBox(sigma.getBBox());
|
||||
});
|
||||
|
||||
sigma.on("moveBody", ({ event }) => {
|
||||
if (!isDragging || !draggedNode) return;
|
||||
const pos = sigma.viewportToGraph(event);
|
||||
graph.setNodeAttribute(draggedNode, "x", pos.x);
|
||||
graph.setNodeAttribute(draggedNode, "y", pos.y);
|
||||
event.preventSigmaDefault?.();
|
||||
event.original?.preventDefault?.();
|
||||
event.original?.stopPropagation?.();
|
||||
});
|
||||
|
||||
const handleUp = () => {
|
||||
if (draggedNode) {
|
||||
graph.removeNodeAttribute(draggedNode, "highlighted");
|
||||
}
|
||||
isDragging = false;
|
||||
draggedNode = null;
|
||||
};
|
||||
sigma.on("upNode", handleUp);
|
||||
sigma.on("upStage", handleUp);
|
||||
|
||||
// Node click handler
|
||||
sigma.on("clickNode", (event) => {
|
||||
const { node } = event;
|
||||
|
||||
// Store current camera state to prevent unwanted movements
|
||||
const camera = sigma.getCamera();
|
||||
const currentState = camera.getState();
|
||||
|
||||
resetHighlights(); // Clear previous highlights first
|
||||
|
||||
// Restore camera state after reset to prevent zoom changes
|
||||
setTimeout(() => {
|
||||
camera.setState(currentState);
|
||||
}, 0);
|
||||
|
||||
if (onNodeClick) {
|
||||
onNodeClick(node);
|
||||
}
|
||||
|
||||
// Highlight the clicked node
|
||||
graph.setNodeAttribute(node, "highlighted", true);
|
||||
graph.setNodeAttribute(node, "color", theme.node.selected);
|
||||
graph.setNodeAttribute(
|
||||
node,
|
||||
"size",
|
||||
graph.getNodeAttribute(node, "size"),
|
||||
);
|
||||
// Enhanced border for selected node
|
||||
graph.setNodeAttribute(node, "borderSize", 3);
|
||||
graph.setNodeAttribute(node, "borderColor", theme.node.selected);
|
||||
graph.setNodeAttribute(node, "zIndex", 3);
|
||||
selectedNodeRef.current = node;
|
||||
|
||||
// Highlight connected edges and nodes
|
||||
graph.forEachEdge(node, (edge, _attributes, source, target) => {
|
||||
graph.setEdgeAttribute(edge, "highlighted", true);
|
||||
graph.setEdgeAttribute(edge, "color", theme.link.selected);
|
||||
graph.setEdgeAttribute(edge, "size", 2);
|
||||
const otherNode = source === node ? target : source;
|
||||
graph.setNodeAttribute(otherNode, "highlighted", true);
|
||||
graph.setNodeAttribute(otherNode, "color", theme.node.hover);
|
||||
graph.setNodeAttribute(
|
||||
otherNode,
|
||||
"size",
|
||||
graph.getNodeAttribute(otherNode, "size"),
|
||||
);
|
||||
graph.setNodeAttribute(otherNode, "zIndex", 2);
|
||||
});
|
||||
});
|
||||
|
||||
// Edge click handler
|
||||
sigma.on("clickEdge", (event) => {
|
||||
const { edge } = event;
|
||||
resetHighlights();
|
||||
const edgeAttrs = graph.getEdgeAttributes(edge);
|
||||
if (edgeAttrs.relationData && edgeAttrs.relationData.length > 0) {
|
||||
const relation = edgeAttrs.relationData[0];
|
||||
if (onEdgeClick) {
|
||||
onEdgeClick(relation.id);
|
||||
}
|
||||
}
|
||||
graph.setEdgeAttribute(edge, "highlighted", true);
|
||||
graph.setEdgeAttribute(edge, "color", theme.link.selected);
|
||||
selectedEdgeRef.current = edge;
|
||||
const source = graph.source(edge);
|
||||
const target = graph.target(edge);
|
||||
graph.setNodeAttribute(source, "highlighted", true);
|
||||
graph.setNodeAttribute(source, "color", theme.node.selected);
|
||||
graph.setNodeAttribute(target, "highlighted", true);
|
||||
graph.setNodeAttribute(target, "color", theme.node.selected);
|
||||
});
|
||||
|
||||
// Background click handler
|
||||
sigma.on("clickStage", (event) => {
|
||||
// Store camera state before reset
|
||||
const camera = sigma.getCamera();
|
||||
const currentState = camera.getState();
|
||||
|
||||
resetHighlights();
|
||||
|
||||
// Restore camera state
|
||||
camera.setState(currentState);
|
||||
|
||||
if (onBlur) {
|
||||
onBlur();
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (sigmaRef.current) {
|
||||
sigmaRef.current.kill();
|
||||
sigmaRef.current = null;
|
||||
}
|
||||
if (graphRef.current) {
|
||||
graphRef.current.clear();
|
||||
graphRef.current = null;
|
||||
}
|
||||
if (clustersLayerRef.current) {
|
||||
clustersLayerRef.current.remove();
|
||||
clustersLayerRef.current = null;
|
||||
}
|
||||
isInitializedRef.current = false;
|
||||
};
|
||||
}, [nodes, edges, clusters, showClusterLabels]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className=""
|
||||
style={{
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
borderRadius: "8px",
|
||||
cursor: "grab",
|
||||
fontSize: "12px",
|
||||
position: "relative",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
@ -36,9 +36,7 @@ interface GraphPopoversProps {
|
||||
|
||||
export function GraphPopovers({
|
||||
showNodePopup,
|
||||
showEdgePopup,
|
||||
nodePopupContent,
|
||||
edgePopupContent,
|
||||
onOpenChange,
|
||||
labelColorMap,
|
||||
}: GraphPopoversProps) {
|
||||
@ -52,8 +50,12 @@ export function GraphPopovers({
|
||||
|
||||
// Check if node has primaryLabel property (GraphNode)
|
||||
const nodeAny = nodePopupContent.node as any;
|
||||
if (nodeAny.primaryLabel && typeof nodeAny.primaryLabel === "string") {
|
||||
return nodeAny.primaryLabel;
|
||||
|
||||
if (
|
||||
nodeAny.attributes.nodeType &&
|
||||
typeof nodeAny.attributes.nodeType === "string"
|
||||
) {
|
||||
return nodeAny.attributes.nodeType;
|
||||
}
|
||||
|
||||
// Fall back to original logic with labels
|
||||
@ -93,7 +95,7 @@ export function GraphPopovers({
|
||||
<div className="pointer-events-none h-4 w-4" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="h-60 max-w-80 overflow-auto"
|
||||
className="h-35 max-w-80 overflow-auto"
|
||||
side="bottom"
|
||||
align="end"
|
||||
sideOffset={5}
|
||||
@ -104,7 +106,7 @@ export function GraphPopovers({
|
||||
<h4 className="leading-none font-medium">Node Details</h4>
|
||||
{primaryNodeLabel && (
|
||||
<span
|
||||
className="rounded-full px-2 py-1 text-xs font-medium text-white"
|
||||
className="rounded-md px-2 py-1 text-xs font-medium text-white"
|
||||
style={{ backgroundColor: labelColor }}
|
||||
>
|
||||
{primaryNodeLabel}
|
||||
@ -118,7 +120,9 @@ export function GraphPopovers({
|
||||
{attributesToDisplay.map(({ key, value }) => (
|
||||
<p key={key} className="text-sm">
|
||||
<span className="font-medium text-black dark:text-white">
|
||||
{key}:
|
||||
{key.charAt(0).toUpperCase() +
|
||||
key.slice(1).toLowerCase()}
|
||||
:
|
||||
</span>{" "}
|
||||
<span className="text-muted-foreground break-words">
|
||||
{typeof value === "object"
|
||||
@ -134,48 +138,6 @@ export function GraphPopovers({
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Popover open={showEdgePopup} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="pointer-events-none h-4 w-4" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-80 overflow-hidden"
|
||||
side="bottom"
|
||||
align="end"
|
||||
sideOffset={5}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="bg-grayAlpha-100 mb-4 rounded-md p-2">
|
||||
<p className="text-sm break-all">
|
||||
Episode → {edgePopupContent?.target.name || "Unknown"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h4 className="leading-none font-medium">Relationship</h4>
|
||||
<div className="grid gap-2">
|
||||
<p className="text-muted-foreground text-sm break-all">
|
||||
<span className="mr-2 text-sm font-medium text-black dark:text-white">
|
||||
UUID:
|
||||
</span>
|
||||
{edgePopupContent?.relation.uuid || "Unknown"}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm break-all">
|
||||
<span className="mr-2 text-sm font-medium text-black dark:text-white">
|
||||
Type:
|
||||
</span>
|
||||
{edgePopupContent?.relation.type || "Unknown"}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm break-all">
|
||||
<span className="mr-2 text-sm font-medium text-black dark:text-white">
|
||||
Created:
|
||||
</span>
|
||||
{formatDate(edgePopupContent?.relation.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
import colors from "tailwindcss/colors";
|
||||
|
||||
// Define a color palette for node coloring using hex values directly
|
||||
export const nodeColorPalette = {
|
||||
light: [
|
||||
|
||||
@ -5,6 +5,7 @@ export interface Node {
|
||||
labels?: string[];
|
||||
attributes?: Record<string, any>;
|
||||
createdAt: string;
|
||||
clusterId?: string;
|
||||
}
|
||||
|
||||
export interface Edge {
|
||||
@ -25,6 +26,7 @@ export interface GraphNode extends Node {
|
||||
id: string;
|
||||
value: string;
|
||||
primaryLabel?: string;
|
||||
clusterId?: string; // Add cluster information
|
||||
}
|
||||
|
||||
export interface GraphEdge extends Edge {
|
||||
|
||||
@ -21,6 +21,7 @@ export function toGraphNode(node: Node): GraphNode {
|
||||
summary: node.summary,
|
||||
labels: node.labels,
|
||||
primaryLabel,
|
||||
clusterId: node?.clusterId, // Extract cluster ID from attributes
|
||||
};
|
||||
}
|
||||
|
||||
@ -44,62 +45,109 @@ export function toGraphTriplets(triplets: RawTriplet[]): GraphTriplet[] {
|
||||
return triplets.map(toGraphTriplet);
|
||||
}
|
||||
|
||||
export function createTriplets(edges: Edge[], nodes: Node[]): RawTriplet[] {
|
||||
// Create a Set of node UUIDs that are connected by edges
|
||||
const connectedNodeIds = new Set<string>();
|
||||
|
||||
// Create triplets from edges
|
||||
const edgeTriplets = edges
|
||||
.map((edge) => {
|
||||
const sourceNode = nodes.find(
|
||||
(node) => node.uuid === edge.source_node_uuid,
|
||||
);
|
||||
const targetNode = nodes.find(
|
||||
(node) => node.uuid === edge.target_node_uuid,
|
||||
);
|
||||
|
||||
if (!sourceNode || !targetNode) return null;
|
||||
|
||||
// Add source and target node IDs to connected set
|
||||
connectedNodeIds.add(sourceNode.uuid);
|
||||
connectedNodeIds.add(targetNode.uuid);
|
||||
|
||||
return {
|
||||
sourceNode,
|
||||
edge,
|
||||
targetNode,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(t): t is RawTriplet =>
|
||||
t !== null && t.sourceNode !== undefined && t.targetNode !== undefined,
|
||||
);
|
||||
|
||||
// Find isolated nodes (nodes that don't appear in any edge)
|
||||
const isolatedNodes = nodes.filter(
|
||||
(node) => !connectedNodeIds.has(node.uuid),
|
||||
);
|
||||
|
||||
// For isolated nodes, create special triplets
|
||||
const isolatedTriplets: RawTriplet[] = isolatedNodes.map((node) => {
|
||||
// Create a special marker edge for isolated nodes
|
||||
const virtualEdge: Edge = {
|
||||
uuid: `isolated-node-${node.uuid}`,
|
||||
source_node_uuid: node.uuid,
|
||||
target_node_uuid: node.uuid,
|
||||
// Use a special type that we can filter out in the Graph component
|
||||
type: "_isolated_node_",
|
||||
|
||||
createdAt: node.createdAt,
|
||||
};
|
||||
|
||||
return {
|
||||
sourceNode: node,
|
||||
edge: virtualEdge,
|
||||
targetNode: node,
|
||||
};
|
||||
});
|
||||
|
||||
// Combine edge triplets with isolated node triplets
|
||||
return [...edgeTriplets, ...isolatedTriplets];
|
||||
export function drawRoundRect(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
radius: number,
|
||||
): void {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + radius, y);
|
||||
ctx.lineTo(x + width - radius, y);
|
||||
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
|
||||
ctx.lineTo(x + width, y + height - radius);
|
||||
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
||||
ctx.lineTo(x + radius, y + height);
|
||||
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
|
||||
ctx.lineTo(x, y + radius);
|
||||
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
const TEXT_COLOR = "#000000";
|
||||
|
||||
export function drawHover(
|
||||
context: CanvasRenderingContext2D,
|
||||
data: any,
|
||||
settings: any,
|
||||
) {
|
||||
const size = settings.labelSize;
|
||||
const font = settings.labelFont;
|
||||
const weight = settings.labelWeight;
|
||||
const subLabelSize = size - 2;
|
||||
|
||||
const label = data.label;
|
||||
const subLabel = data.tag !== "unknown" ? data.tag : "";
|
||||
const entityLabel = data.nodeData.attributes.nodeType;
|
||||
|
||||
// Simulate the --shadow-1 Tailwind shadow:
|
||||
// lch(0 0 0 / 0.022) 0px 3px 6px -2px, lch(0 0 0 / 0.044) 0px 1px 1px;
|
||||
// Canvas only supports a single shadow, so we approximate with the stronger one.
|
||||
// lch(0 0 0 / 0.044) is roughly rgba(0,0,0,0.044)
|
||||
context.beginPath();
|
||||
context.fillStyle = "#fff";
|
||||
context.shadowOffsetX = 0;
|
||||
context.shadowOffsetY = 1;
|
||||
context.shadowBlur = 1;
|
||||
context.shadowColor = "rgba(0,0,0,0.044)";
|
||||
|
||||
context.font = `${weight} ${size}px ${font}`;
|
||||
const labelWidth = context.measureText(label).width;
|
||||
context.font = `${weight} ${subLabelSize}px ${font}`;
|
||||
const subLabelWidth = subLabel ? context.measureText(subLabel).width : 0;
|
||||
context.font = `${weight} ${subLabelSize}px ${font}`;
|
||||
const entityLabelWidth = entityLabel
|
||||
? context.measureText(entityLabel).width
|
||||
: 0;
|
||||
|
||||
const textWidth = Math.max(labelWidth, subLabelWidth, entityLabelWidth);
|
||||
|
||||
const x = Math.round(data.x);
|
||||
const y = Math.round(data.y);
|
||||
const w = Math.round(textWidth + size / 2 + data.size + 3);
|
||||
const hLabel = Math.round(size / 2 + 4);
|
||||
const hSubLabel = subLabel ? Math.round(subLabelSize / 2 + 9) : 0;
|
||||
const hentityLabel = Math.round(subLabelSize / 2 + 9);
|
||||
|
||||
drawRoundRect(
|
||||
context,
|
||||
x,
|
||||
y - hSubLabel - 12,
|
||||
w,
|
||||
hentityLabel + hLabel + hSubLabel + 12,
|
||||
5,
|
||||
);
|
||||
context.closePath();
|
||||
context.fill();
|
||||
|
||||
// Remove shadow for text
|
||||
context.shadowOffsetX = 0;
|
||||
context.shadowOffsetY = 0;
|
||||
context.shadowBlur = 0;
|
||||
context.shadowColor = "transparent";
|
||||
|
||||
// And finally we draw the labels
|
||||
context.fillStyle = TEXT_COLOR;
|
||||
context.font = `${weight} ${size}px ${font}`;
|
||||
context.fillText(label, data.x + data.size + 3, data.y + size / 3);
|
||||
|
||||
if (subLabel) {
|
||||
context.fillStyle = TEXT_COLOR;
|
||||
context.font = `${weight} ${subLabelSize}px ${font}`;
|
||||
context.fillText(
|
||||
subLabel,
|
||||
data.x + data.size + 3,
|
||||
data.y - (2 * size) / 3 - 2,
|
||||
);
|
||||
}
|
||||
|
||||
context.fillStyle = data.color;
|
||||
context.font = `${weight} ${subLabelSize}px ${font}`;
|
||||
context.fillText(
|
||||
entityLabel,
|
||||
data.x + data.size + 3,
|
||||
data.y + size / 3 + 3 + subLabelSize,
|
||||
);
|
||||
}
|
||||
|
||||
@ -6,6 +6,10 @@ import {
|
||||
} from "@remixicon/react";
|
||||
import { LayoutGrid } from "lucide-react";
|
||||
import { LinearIcon, SlackIcon } from "./icons";
|
||||
import { Cursor } from "./icons/cursor";
|
||||
import { Claude } from "./icons/claude";
|
||||
import { Cline } from "./icons/cline";
|
||||
import { VSCode } from "./icons/vscode";
|
||||
|
||||
export const ICON_MAPPING = {
|
||||
slack: SlackIcon,
|
||||
@ -15,6 +19,10 @@ export const ICON_MAPPING = {
|
||||
|
||||
gmail: RiMailFill,
|
||||
linear: LinearIcon,
|
||||
cursor: Cursor,
|
||||
claude: Claude,
|
||||
cline: Cline,
|
||||
vscode: VSCode,
|
||||
|
||||
// Default icon
|
||||
integration: LayoutGrid,
|
||||
|
||||
20
apps/webapp/app/components/icons/claude.tsx
Normal file
20
apps/webapp/app/components/icons/claude.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import type { IconProps } from "./types";
|
||||
|
||||
export function Claude({ size = 18, className }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
height={size}
|
||||
className={className}
|
||||
width={size}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Claude</title>
|
||||
<path
|
||||
d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"
|
||||
fill="#D97757"
|
||||
fill-rule="nonzero"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
19
apps/webapp/app/components/icons/cline.tsx
Normal file
19
apps/webapp/app/components/icons/cline.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import type { IconProps } from "./types";
|
||||
|
||||
export function Cline({ size = 18, className }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
viewBox="0 0 24 24"
|
||||
height={size}
|
||||
className={className}
|
||||
width={size}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Cline</title>
|
||||
<path d="M17.035 3.991c2.75 0 4.98 2.24 4.98 5.003v1.667l1.45 2.896a1.01 1.01 0 01-.002.909l-1.448 2.864v1.668c0 2.762-2.23 5.002-4.98 5.002H7.074c-2.751 0-4.98-2.24-4.98-5.002V17.33l-1.48-2.855a1.01 1.01 0 01-.003-.927l1.482-2.887V8.994c0-2.763 2.23-5.003 4.98-5.003h9.962zM8.265 9.6a2.274 2.274 0 00-2.274 2.274v4.042a2.274 2.274 0 004.547 0v-4.042A2.274 2.274 0 008.265 9.6zm7.326 0a2.274 2.274 0 00-2.274 2.274v4.042a2.274 2.274 0 104.548 0v-4.042A2.274 2.274 0 0015.59 9.6z"></path>
|
||||
<path d="M12.054 5.558a2.779 2.779 0 100-5.558 2.779 2.779 0 000 5.558z"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
64
apps/webapp/app/components/icons/cursor.tsx
Normal file
64
apps/webapp/app/components/icons/cursor.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import type { IconProps } from "./types";
|
||||
|
||||
export function Cursor({ size = 18, className }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
viewBox="0 0 24 24"
|
||||
className={className}
|
||||
height={size}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Cursor</title>
|
||||
<path
|
||||
d="M11.925 24l10.425-6-10.425-6L1.5 18l10.425 6z"
|
||||
fill="url(#lobe-icons-cursorundefined-fill-0)"
|
||||
></path>
|
||||
<path
|
||||
d="M22.35 18V6L11.925 0v12l10.425 6z"
|
||||
fill="url(#lobe-icons-cursorundefined-fill-1)"
|
||||
></path>
|
||||
<path
|
||||
d="M11.925 0L1.5 6v12l10.425-6V0z"
|
||||
fill="url(#lobe-icons-cursorundefined-fill-2)"
|
||||
></path>
|
||||
<path d="M22.35 6L11.925 24V12L22.35 6z" fill="#555"></path>
|
||||
<path d="M22.35 6l-10.425 6L1.5 6h20.85z" fill="#000"></path>
|
||||
<defs>
|
||||
<linearGradient
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="lobe-icons-cursorundefined-fill-0"
|
||||
x1="11.925"
|
||||
x2="11.925"
|
||||
y1="12"
|
||||
y2="24"
|
||||
>
|
||||
<stop offset=".16" stop-color="#000" stop-opacity=".39"></stop>
|
||||
<stop offset=".658" stop-color="#000" stop-opacity=".8"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="lobe-icons-cursorundefined-fill-1"
|
||||
x1="22.35"
|
||||
x2="11.925"
|
||||
y1="6.037"
|
||||
y2="12.15"
|
||||
>
|
||||
<stop offset=".182" stop-color="#000" stop-opacity=".31"></stop>
|
||||
<stop offset=".715" stop-color="#000" stop-opacity="0"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="lobe-icons-cursorundefined-fill-2"
|
||||
x1="11.925"
|
||||
x2="1.5"
|
||||
y1="0"
|
||||
y2="18"
|
||||
>
|
||||
<stop stop-color="#000" stop-opacity=".6"></stop>
|
||||
<stop offset=".667" stop-color="#000" stop-opacity=".22"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
26
apps/webapp/app/components/icons/vscode.tsx
Normal file
26
apps/webapp/app/components/icons/vscode.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import type { IconProps } from "./types";
|
||||
|
||||
export function VSCode({ size = 18, className }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 48 48"
|
||||
width={size}
|
||||
className={className}
|
||||
height={size}
|
||||
>
|
||||
<path
|
||||
fill="#29b6f6"
|
||||
d="M44,11.11v25.78c0,1.27-0.79,2.4-1.98,2.82l-8.82,4.14L34,33V15L33.2,4.15l8.82,4.14 C43.21,8.71,44,9.84,44,11.11z"
|
||||
/>
|
||||
<path
|
||||
fill="#0277bd"
|
||||
d="M9,33.896L34,15V5.353c0-1.198-1.482-1.758-2.275-0.86L4.658,29.239 c-0.9,0.83-0.849,2.267,0.107,3.032c0,0,1.324,1.232,1.803,1.574C7.304,34.37,8.271,34.43,9,33.896z"
|
||||
/>
|
||||
<path
|
||||
fill="#0288d1"
|
||||
d="M9,14.104L34,33v9.647c0,1.198-1.482,1.758-2.275,0.86L4.658,18.761 c-0.9-0.83-0.849-2.267,0.107-3.032c0,0,1.324-1.232,1.803-1.574C7.304,13.63,8.271,13.57,9,14.104z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@ -1,10 +1,7 @@
|
||||
import React from "react";
|
||||
import { Link } from "@remix-run/react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
@ -35,23 +32,24 @@ export function IntegrationCard({
|
||||
>
|
||||
<Card className="transition-all">
|
||||
<CardHeader className="p-4">
|
||||
<div className="bg-background-2 mb-2 flex h-6 w-6 items-center justify-center rounded">
|
||||
<Component size={18} />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="bg-background-2 mb-2 flex h-6 w-6 items-center justify-center rounded">
|
||||
<Component size={18} />
|
||||
</div>
|
||||
|
||||
{isConnected && (
|
||||
<div className="flex w-full items-center justify-end">
|
||||
<Badge className="h-6 rounded bg-green-100 p-2 text-xs text-green-800">
|
||||
Connected
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="text-base">{integration.name}</CardTitle>
|
||||
<CardDescription className="line-clamp-2 text-xs">
|
||||
{integration.description || `Connect to ${integration.name}`}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{isConnected && (
|
||||
<CardFooter className="p-3">
|
||||
<div className="flex w-full items-center justify-end">
|
||||
<Badge className="h-6 rounded bg-green-100 p-2 text-xs text-green-800">
|
||||
Connected
|
||||
</Badge>
|
||||
</div>
|
||||
</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
|
||||
34
apps/webapp/app/components/integrations/utils.tsx
Normal file
34
apps/webapp/app/components/integrations/utils.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
export const FIXED_INTEGRATIONS = [
|
||||
{
|
||||
id: "claude",
|
||||
name: "Claude",
|
||||
description: "AI assistant for coding, writing, and analysis",
|
||||
icon: "claude",
|
||||
slug: "claude",
|
||||
spec: {},
|
||||
},
|
||||
{
|
||||
id: "cursor",
|
||||
name: "Cursor",
|
||||
description: "AI-powered code editor",
|
||||
icon: "cursor",
|
||||
slug: "cursor",
|
||||
spec: {},
|
||||
},
|
||||
{
|
||||
id: "cline",
|
||||
name: "Cline",
|
||||
description: "AI coding assistant for terminal and command line",
|
||||
icon: "cline",
|
||||
slug: "cline",
|
||||
spec: {},
|
||||
},
|
||||
{
|
||||
id: "vscode",
|
||||
name: "Visual Studio Code",
|
||||
description: "Popular code editor with extensive extensions",
|
||||
icon: "vscode",
|
||||
slug: "vscode",
|
||||
spec: {},
|
||||
},
|
||||
];
|
||||
@ -4,6 +4,7 @@ import { AlertCircle, Info, Trash } from "lucide-react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog";
|
||||
import { Button } from "../ui";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@ -15,21 +16,42 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "../ui/alert-dialog";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { type LogItem } from "~/hooks/use-logs";
|
||||
|
||||
interface LogTextCollapseProps {
|
||||
text?: string;
|
||||
error?: string;
|
||||
logData: any;
|
||||
log: LogItem;
|
||||
id: string;
|
||||
episodeUUID?: string;
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "PROCESSING":
|
||||
return "bg-blue-100 text-blue-800 hover:bg-blue-100 hover:text-blue-800";
|
||||
case "PENDING":
|
||||
return "bg-yellow-100 text-yellow-800 hover:bg-yellow-100 hover:text-yellow-800";
|
||||
case "COMPLETED":
|
||||
return "bg-success/10 text-success hover:bg-success/10 hover:text-success";
|
||||
case "FAILED":
|
||||
return "bg-destructive/10 text-destructive hover:bg-destructive/10 hover:text-destructive";
|
||||
case "CANCELLED":
|
||||
return "bg-gray-100 text-gray-800 hover:bg-gray-100 hover:text-gray-800";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800 hover:bg-gray-100 hover:text-gray-800";
|
||||
}
|
||||
};
|
||||
|
||||
export function LogTextCollapse({
|
||||
episodeUUID,
|
||||
text,
|
||||
error,
|
||||
id,
|
||||
logData,
|
||||
log,
|
||||
}: LogTextCollapseProps) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
@ -75,19 +97,28 @@ export function LogTextCollapse({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<p
|
||||
<div className="flex w-full items-center">
|
||||
<div
|
||||
className={cn(
|
||||
"group-hover:bg-grayAlpha-100 flex min-w-[0px] shrink grow items-start gap-2 rounded-md px-4",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"whitespace-p-wrap pt-2 text-sm break-words",
|
||||
isLong ? "max-h-16 overflow-hidden" : "",
|
||||
"border-border flex w-full min-w-[0px] shrink flex-col border-b py-2",
|
||||
)}
|
||||
style={{ lineHeight: "1.5" }}
|
||||
dangerouslySetInnerHTML={{ __html: text }}
|
||||
/>
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<div
|
||||
className="inline-flex min-h-[24px] min-w-[0px] shrink cursor-pointer items-center justify-start"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
>
|
||||
<div
|
||||
className={cn("truncate text-left")}
|
||||
dangerouslySetInnerHTML={{ __html: text }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
{isLong && (
|
||||
<>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-2xl p-4">
|
||||
<DialogHeader>
|
||||
@ -101,66 +132,84 @@ export function LogTextCollapse({
|
||||
style={{ lineHeight: "1.5" }}
|
||||
dangerouslySetInnerHTML={{ __html: text }}
|
||||
/>
|
||||
{error && (
|
||||
<div className="mt-4 border-t px-3 py-2">
|
||||
<div className="flex items-start gap-2 text-red-600">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="mb-1 text-sm font-medium">
|
||||
Error Details
|
||||
</p>
|
||||
<p className="text-sm break-words whitespace-pre-wrap">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"text-muted-foreground flex items-center justify-end text-xs",
|
||||
isLong && "justify-between",
|
||||
)}
|
||||
>
|
||||
{isLong && (
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-2 rounded px-2"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
>
|
||||
<Info size={15} />
|
||||
</Button>
|
||||
{episodeUUID && (
|
||||
<AlertDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="rounded px-2">
|
||||
<Trash size={15} />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Episode</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this episode? This action
|
||||
cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete}>
|
||||
Continue
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
<div className="text-muted-foreground flex items-center justify-end text-xs">
|
||||
<div className="flex items-center">
|
||||
<Badge
|
||||
className={cn(
|
||||
"mr-3 rounded text-xs",
|
||||
getStatusColor(log.status),
|
||||
)}
|
||||
>
|
||||
{log.status.charAt(0).toUpperCase() +
|
||||
log.status.slice(1).toLowerCase()}
|
||||
</Badge>
|
||||
|
||||
<div className="text-muted-foreground mr-3">
|
||||
{new Date(log.time).toLocaleString()}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-2 rounded px-2"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
>
|
||||
<Info size={15} />
|
||||
</Button>
|
||||
{episodeUUID && (
|
||||
<AlertDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="rounded px-2"
|
||||
>
|
||||
<Trash size={15} />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Episode</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this episode? This
|
||||
action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete}>
|
||||
Continue
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="flex items-center gap-1 text-red-600">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
<span className="max-w-[200px] truncate" title={error}>
|
||||
{error}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -51,7 +51,7 @@ export function LogsFilters({
|
||||
const handleBack = () => setStep("main");
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex w-full items-center justify-start gap-2">
|
||||
<div className="mb-4 flex w-full items-center justify-start gap-2 px-5">
|
||||
<Popover
|
||||
open={popoverOpen}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
@ -9,7 +9,6 @@ import {
|
||||
} from "react-virtualized";
|
||||
import { type LogItem } from "~/hooks/use-logs";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { ScrollManagedList } from "../virtualized-list";
|
||||
import { LogTextCollapse } from "./log-text-collapse";
|
||||
@ -46,23 +45,6 @@ function LogItemRenderer(
|
||||
);
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "PROCESSING":
|
||||
return "bg-blue-100 text-blue-800 hover:bg-blue-100 hover:text-blue-800";
|
||||
case "PENDING":
|
||||
return "bg-yellow-100 text-yellow-800 hover:bg-yellow-100 hover:text-yellow-800";
|
||||
case "COMPLETED":
|
||||
return "bg-green-100 text-green-800 hover:bg-green-100 hover:text-green-800";
|
||||
case "FAILED":
|
||||
return "bg-red-100 text-red-800 hover:bg-red-100 hover:text-red-800";
|
||||
case "CANCELLED":
|
||||
return "bg-gray-100 text-gray-800 hover:bg-gray-100 hover:text-gray-800";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800 hover:bg-gray-100 hover:text-gray-800";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CellMeasurer
|
||||
key={key}
|
||||
@ -71,40 +53,17 @@ function LogItemRenderer(
|
||||
parent={parent}
|
||||
rowIndex={index}
|
||||
>
|
||||
<div key={key} style={style} className="pb-2">
|
||||
<Card className="h-full">
|
||||
<CardContent className="p-4">
|
||||
<div className="mb-2 flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="rounded text-xs">
|
||||
{log.source}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge
|
||||
className={cn(
|
||||
"rounded text-xs",
|
||||
getStatusColor(log.status),
|
||||
)}
|
||||
>
|
||||
{log.status.charAt(0).toUpperCase() +
|
||||
log.status.slice(1).toLowerCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{new Date(log.time).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LogTextCollapse
|
||||
text={log.ingestText}
|
||||
error={log.error}
|
||||
logData={log.data}
|
||||
id={log.id}
|
||||
episodeUUID={log.episodeUUID}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div key={key} style={style}>
|
||||
<div className="group mx-2 flex cursor-default gap-2">
|
||||
<LogTextCollapse
|
||||
text={log.ingestText}
|
||||
error={log.error}
|
||||
logData={log.data}
|
||||
log={log}
|
||||
id={log.id}
|
||||
episodeUUID={log.episodeUUID}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CellMeasurer>
|
||||
);
|
||||
|
||||
@ -9,6 +9,7 @@ const EnvironmentSchema = z.object({
|
||||
z.literal("production"),
|
||||
z.literal("test"),
|
||||
]),
|
||||
POSTGRES_DB: z.string(),
|
||||
DATABASE_URL: z
|
||||
.string()
|
||||
.refine(
|
||||
|
||||
@ -102,6 +102,153 @@ export const getNodeLinks = async (userId: string) => {
|
||||
return triplets;
|
||||
};
|
||||
|
||||
// Get graph data with cluster information for reified graph
|
||||
export const getClusteredGraphData = async (userId: string) => {
|
||||
const session = driver.session();
|
||||
try {
|
||||
// Get the proper reified graph structure: Entity -> Statement -> Entity
|
||||
const result = await session.run(
|
||||
`// Get all statements and their entity connections for reified graph
|
||||
MATCH (s:Statement)
|
||||
WHERE s.userId = $userId AND s.invalidAt IS NULL
|
||||
|
||||
// Get all entities connected to each statement
|
||||
MATCH (s)-[:HAS_SUBJECT]->(subj:Entity)
|
||||
MATCH (s)-[:HAS_PREDICATE]->(pred:Entity)
|
||||
MATCH (s)-[:HAS_OBJECT]->(obj:Entity)
|
||||
|
||||
// Return both Entity->Statement and Statement->Entity relationships
|
||||
WITH s, subj, pred, obj
|
||||
UNWIND [
|
||||
// Subject Entity -> Statement
|
||||
{source: subj, target: s, type: 'HAS_SUBJECT', isEntityToStatement: true},
|
||||
// Statement -> Predicate Entity
|
||||
{source: s, target: pred, type: 'HAS_PREDICATE', isStatementToEntity: true},
|
||||
// Statement -> Object Entity
|
||||
{source: s, target: obj, type: 'HAS_OBJECT', isStatementToEntity: true}
|
||||
] AS rel
|
||||
|
||||
RETURN DISTINCT
|
||||
rel.source.uuid as sourceUuid,
|
||||
rel.source.name as sourceName,
|
||||
rel.source.labels as sourceLabels,
|
||||
rel.source.type as sourceType,
|
||||
rel.source.properties as sourceProperties,
|
||||
rel.target.uuid as targetUuid,
|
||||
rel.target.name as targetName,
|
||||
rel.target.type as targetType,
|
||||
rel.target.labels as targetLabels,
|
||||
rel.target.properties as targetProperties,
|
||||
rel.type as relationshipType,
|
||||
s.uuid as statementUuid,
|
||||
s.clusterId as clusterId,
|
||||
s.fact as fact,
|
||||
s.createdAt as createdAt,
|
||||
rel.isEntityToStatement as isEntityToStatement,
|
||||
rel.isStatementToEntity as isStatementToEntity`,
|
||||
{ userId },
|
||||
);
|
||||
|
||||
const triplets: RawTriplet[] = [];
|
||||
const processedEdges = new Set<string>();
|
||||
|
||||
result.records.forEach((record) => {
|
||||
const sourceUuid = record.get("sourceUuid");
|
||||
const sourceName = record.get("sourceName");
|
||||
const sourceType = record.get("sourceType");
|
||||
const sourceLabels = record.get("sourceLabels") || [];
|
||||
const sourceProperties = record.get("sourceProperties") || {};
|
||||
|
||||
const targetUuid = record.get("targetUuid");
|
||||
const targetName = record.get("targetName");
|
||||
const targetLabels = record.get("targetLabels") || [];
|
||||
const targetProperties = record.get("targetProperties") || {};
|
||||
const targetType = record.get("targetType");
|
||||
|
||||
const relationshipType = record.get("relationshipType");
|
||||
const statementUuid = record.get("statementUuid");
|
||||
const clusterId = record.get("clusterId");
|
||||
const fact = record.get("fact");
|
||||
const createdAt = record.get("createdAt");
|
||||
|
||||
// Create unique edge identifier to avoid duplicates
|
||||
const edgeKey = `${sourceUuid}-${targetUuid}-${relationshipType}`;
|
||||
if (processedEdges.has(edgeKey)) return;
|
||||
processedEdges.add(edgeKey);
|
||||
|
||||
// Determine node types and add appropriate cluster information
|
||||
const isSourceStatement =
|
||||
sourceLabels.includes("Statement") || sourceUuid === statementUuid;
|
||||
const isTargetStatement =
|
||||
targetLabels.includes("Statement") || targetUuid === statementUuid;
|
||||
|
||||
// Statement nodes get cluster info, Entity nodes get default attributes
|
||||
const sourceAttributes = isSourceStatement
|
||||
? {
|
||||
...sourceProperties,
|
||||
clusterId,
|
||||
nodeType: "Statement",
|
||||
fact,
|
||||
}
|
||||
: {
|
||||
...sourceProperties,
|
||||
nodeType: "Entity",
|
||||
type: sourceType,
|
||||
name: sourceName,
|
||||
};
|
||||
|
||||
const targetAttributes = isTargetStatement
|
||||
? {
|
||||
...targetProperties,
|
||||
clusterId,
|
||||
nodeType: "Statement",
|
||||
fact,
|
||||
}
|
||||
: {
|
||||
...targetProperties,
|
||||
nodeType: "Entity",
|
||||
type: targetType,
|
||||
name: targetName,
|
||||
};
|
||||
|
||||
triplets.push({
|
||||
sourceNode: {
|
||||
uuid: sourceUuid,
|
||||
labels: sourceLabels,
|
||||
attributes: sourceAttributes,
|
||||
name: isSourceStatement ? fact : sourceName || sourceUuid,
|
||||
clusterId,
|
||||
createdAt: createdAt || "",
|
||||
},
|
||||
edge: {
|
||||
uuid: `${sourceUuid}-${targetUuid}-${relationshipType}`,
|
||||
type: relationshipType,
|
||||
source_node_uuid: sourceUuid,
|
||||
target_node_uuid: targetUuid,
|
||||
createdAt: createdAt || "",
|
||||
},
|
||||
targetNode: {
|
||||
uuid: targetUuid,
|
||||
labels: targetLabels,
|
||||
attributes: targetAttributes,
|
||||
clusterId,
|
||||
name: isTargetStatement ? fact : targetName || targetUuid,
|
||||
createdAt: createdAt || "",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return triplets;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error getting clustered graph data for user ${userId}: ${error}`,
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
};
|
||||
|
||||
export async function initNeo4jSchemaOnce() {
|
||||
if (schemaInitialized) return;
|
||||
|
||||
@ -141,6 +288,9 @@ const initializeSchema = async () => {
|
||||
await runQuery(
|
||||
"CREATE CONSTRAINT statement_uuid IF NOT EXISTS FOR (n:Statement) REQUIRE n.uuid IS UNIQUE",
|
||||
);
|
||||
await runQuery(
|
||||
"CREATE CONSTRAINT cluster_uuid IF NOT EXISTS FOR (n:Cluster) REQUIRE n.uuid IS UNIQUE",
|
||||
);
|
||||
|
||||
// Create indexes for better query performance
|
||||
await runQuery(
|
||||
@ -152,9 +302,18 @@ const initializeSchema = async () => {
|
||||
await runQuery(
|
||||
"CREATE INDEX statement_invalid_at IF NOT EXISTS FOR (n:Statement) ON (n.invalidAt)",
|
||||
);
|
||||
await runQuery(
|
||||
"CREATE INDEX statement_cluster_id IF NOT EXISTS FOR (n:Statement) ON (n.clusterId)",
|
||||
);
|
||||
await runQuery(
|
||||
"CREATE INDEX entity_name IF NOT EXISTS FOR (n:Entity) ON (n.name)",
|
||||
);
|
||||
await runQuery(
|
||||
"CREATE INDEX cluster_user_id IF NOT EXISTS FOR (n:Cluster) ON (n.userId)",
|
||||
);
|
||||
await runQuery(
|
||||
"CREATE INDEX cluster_aspect_type IF NOT EXISTS FOR (n:Cluster) ON (n.aspectType)",
|
||||
);
|
||||
|
||||
// Create vector indexes for semantic search (if using Neo4j 5.0+)
|
||||
await runQuery(`
|
||||
|
||||
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 };
|
||||
40
apps/webapp/app/routes/api.v1.clusters.$clusterId.tsx
Normal file
40
apps/webapp/app/routes/api.v1.clusters.$clusterId.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { json, type LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { ClusteringService } from "~/services/clustering.server";
|
||||
import { logger } from "~/services/logger.service";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
|
||||
const clusteringService = new ClusteringService();
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
try {
|
||||
const user = await requireUser(request);
|
||||
const { clusterId } = params;
|
||||
|
||||
if (!clusterId) {
|
||||
return json(
|
||||
{ success: false, error: "Cluster ID is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const statements = await clusteringService.getClusterStatements(clusterId, user.id);
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
data: {
|
||||
clusterId,
|
||||
statements
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error("Error fetching cluster statements:", { error });
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error"
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
29
apps/webapp/app/routes/api.v1.clusters.drift.tsx
Normal file
29
apps/webapp/app/routes/api.v1.clusters.drift.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { json, type LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { ClusteringService } from "~/services/clustering.server";
|
||||
import { logger } from "~/services/logger.service";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
|
||||
const clusteringService = new ClusteringService();
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
try {
|
||||
const user = await requireUser(request);
|
||||
|
||||
const driftMetrics = await clusteringService.detectClusterDrift(user.id);
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
data: driftMetrics
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error("Error checking cluster drift:", { error });
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error"
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
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 };
|
||||
@ -4,6 +4,8 @@ import { requireUserId } from "~/services/session.server";
|
||||
import { logger } from "~/services/logger.service";
|
||||
import { prisma } from "~/db.server";
|
||||
import { triggerIntegrationWebhook } from "~/trigger/webhooks/integration-webhook-delivery";
|
||||
import { scheduler } from "~/trigger/integrations/scheduler";
|
||||
import { schedules } from "@trigger.dev/sdk";
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
if (request.method !== "POST") {
|
||||
@ -28,6 +30,10 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
},
|
||||
});
|
||||
|
||||
const integrationAccountSettings = updatedAccount.settings as any;
|
||||
|
||||
await schedules.del(integrationAccountSettings.scheduleId);
|
||||
|
||||
await triggerIntegrationWebhook(
|
||||
integrationAccountId,
|
||||
userId,
|
||||
|
||||
@ -17,22 +17,6 @@ const transports: {
|
||||
};
|
||||
} = {};
|
||||
|
||||
// Cleanup old sessions every 5 minutes
|
||||
setInterval(
|
||||
() => {
|
||||
const now = Date.now();
|
||||
const maxAge = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
Object.keys(transports).forEach((sessionId) => {
|
||||
if (now - transports[sessionId].createdAt > maxAge) {
|
||||
transports[sessionId].transport.close();
|
||||
delete transports[sessionId];
|
||||
}
|
||||
});
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
);
|
||||
|
||||
// MCP request body schema
|
||||
const MCPRequestSchema = z.object({}).passthrough();
|
||||
const SourceParams = z.object({
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { useTypedLoaderData } from "remix-typedjson";
|
||||
|
||||
import { GraphVisualizationClient } from "~/components/graph/graph-client";
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
import { PageHeader } from "~/components/common/page-header";
|
||||
import { GraphVisualizationClient } from "~/components/graph/graph-client";
|
||||
import { GraphNode } from "~/components/graph/type";
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
// Only return userId, not the heavy nodeLinks
|
||||
@ -15,38 +17,31 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
|
||||
export default function Dashboard() {
|
||||
const { userId } = useTypedLoaderData<typeof loader>();
|
||||
const fetcher = useFetcher<any>();
|
||||
const [selectedClusterId, setSelectedClusterId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// State for nodeLinks and loading
|
||||
const [nodeLinks, setNodeLinks] = useState<any[] | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function fetchNodeLinks() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(
|
||||
"/node-links?userId=" +
|
||||
encodeURIComponent("cmc0x85jv0000nu1wiu1yla73"),
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to fetch node links");
|
||||
const data = await res.json();
|
||||
if (!cancelled) {
|
||||
setNodeLinks(data);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setNodeLinks([]);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
// Kick off the fetcher on mount if not already done
|
||||
React.useEffect(() => {
|
||||
if (userId && fetcher.state === "idle" && !fetcher.data) {
|
||||
fetcher.load("/api/v1/graph/clustered");
|
||||
}
|
||||
fetchNodeLinks();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [userId]);
|
||||
}, [userId, fetcher]);
|
||||
|
||||
// Determine loading state
|
||||
const loading =
|
||||
fetcher.state === "loading" ||
|
||||
fetcher.state === "submitting" ||
|
||||
!fetcher.data;
|
||||
|
||||
// Get graph data from fetcher
|
||||
let graphData: any = null;
|
||||
if (fetcher.data && fetcher.data.success) {
|
||||
graphData = fetcher.data.data;
|
||||
} else if (fetcher.data && !fetcher.data.success) {
|
||||
graphData = { triplets: [], clusters: [] };
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -60,7 +55,15 @@ export default function Dashboard() {
|
||||
</div>
|
||||
) : (
|
||||
typeof window !== "undefined" &&
|
||||
nodeLinks && <GraphVisualizationClient triplets={nodeLinks} />
|
||||
graphData && (
|
||||
<GraphVisualizationClient
|
||||
triplets={graphData.triplets || []}
|
||||
clusters={graphData.clusters || []}
|
||||
selectedClusterId={selectedClusterId}
|
||||
onClusterSelect={setSelectedClusterId}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import React, { useMemo } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import {
|
||||
json,
|
||||
type LoaderFunctionArgs,
|
||||
type ActionFunctionArgs,
|
||||
} from "@remix-run/node";
|
||||
import { useLoaderData } from "@remix-run/react";
|
||||
import { useLoaderData, useParams } from "@remix-run/react";
|
||||
import { requireUserId, requireWorkpace } from "~/services/session.server";
|
||||
import { getIntegrationDefinitions } from "~/services/integrationDefinition.server";
|
||||
import { getIntegrationAccounts } from "~/services/integrationAccount.server";
|
||||
@ -21,7 +21,15 @@ import {
|
||||
} from "~/services/ingestionRule.server";
|
||||
import { Section } from "~/components/integrations/section";
|
||||
import { PageHeader } from "~/components/common/page-header";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Check, Copy, Plus } from "lucide-react";
|
||||
import { FIXED_INTEGRATIONS } from "~/components/integrations/utils";
|
||||
import {
|
||||
IngestionRule,
|
||||
type IntegrationAccount,
|
||||
IntegrationDefinitionV2,
|
||||
} from "@prisma/client";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Button } from "~/components/ui";
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
@ -33,7 +41,10 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
getIntegrationAccounts(userId),
|
||||
]);
|
||||
|
||||
const integration = integrationDefinitions.find(
|
||||
// Combine fixed integrations with dynamic ones
|
||||
const allIntegrations = [...FIXED_INTEGRATIONS, ...integrationDefinitions];
|
||||
|
||||
const integration = allIntegrations.find(
|
||||
(def) => def.slug === slug || def.id === slug,
|
||||
);
|
||||
|
||||
@ -78,7 +89,10 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
getIntegrationAccounts(userId),
|
||||
]);
|
||||
|
||||
const integration = integrationDefinitions.find(
|
||||
// Combine fixed integrations with dynamic ones
|
||||
const allIntegrations = [...FIXED_INTEGRATIONS, ...integrationDefinitions];
|
||||
|
||||
const integration = allIntegrations.find(
|
||||
(def) => def.slug === slug || def.id === slug,
|
||||
);
|
||||
|
||||
@ -119,14 +133,311 @@ function parseSpec(spec: any) {
|
||||
return spec;
|
||||
}
|
||||
|
||||
export default function IntegrationDetail() {
|
||||
const { integration, integrationAccounts, ingestionRule } =
|
||||
useLoaderData<typeof loader>();
|
||||
function CustomIntegrationContent({ integration }: { integration: any }) {
|
||||
const memoryUrl = `https://core.heysol.ai/api/v1/mcp/memory?source=${integration.slug}`;
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(memoryUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const getCustomContent = () => {
|
||||
switch (integration.id) {
|
||||
case "claude":
|
||||
return {
|
||||
title: "About Claude",
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<p className="leading-relaxed">
|
||||
Claude is an AI assistant created by Anthropic. It can help with
|
||||
a wide variety of tasks including:
|
||||
</p>
|
||||
<ul className="ml-4 list-inside list-disc space-y-1">
|
||||
<li>Code generation and debugging</li>
|
||||
<li>Writing and editing</li>
|
||||
<li>Analysis and research</li>
|
||||
<li>Problem-solving</li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
For Claude Web, Desktop, and Code - OAuth authentication handled
|
||||
automatically
|
||||
</p>
|
||||
|
||||
<div className="bg-background-3 flex items-center rounded">
|
||||
<Input
|
||||
type="text"
|
||||
id="memoryUrl"
|
||||
value={memoryUrl}
|
||||
readOnly
|
||||
className="bg-background-3 block w-full text-base"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={copyToClipboard}
|
||||
className="px-3"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
case "cursor":
|
||||
return {
|
||||
title: "About Cursor",
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<p className="leading-relaxed">
|
||||
Cursor is an AI-powered code editor that helps developers write
|
||||
code faster and more efficiently.
|
||||
</p>
|
||||
<ul className="ml-4 list-inside list-disc space-y-1">
|
||||
<li>AI-powered code completion</li>
|
||||
<li>Natural language to code conversion</li>
|
||||
<li>Code explanation and debugging</li>
|
||||
<li>Refactoring assistance</li>
|
||||
</ul>
|
||||
<div className="bg-background-3 flex items-center rounded p-2">
|
||||
<pre className="bg-background-3 m-0 block w-full p-0 text-base break-words whitespace-pre-wrap">
|
||||
{JSON.stringify(
|
||||
{
|
||||
memory: {
|
||||
url: memoryUrl,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</pre>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
navigator.clipboard
|
||||
.writeText(
|
||||
JSON.stringify(
|
||||
{
|
||||
memory: {
|
||||
url: memoryUrl,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
.then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
})
|
||||
}
|
||||
className="px-3"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
case "cline":
|
||||
return {
|
||||
title: "About Cline",
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<p className="leading-relaxed">
|
||||
Cline is an AI coding assistant that works directly in your
|
||||
terminal and command line environment.
|
||||
</p>
|
||||
<ul className="ml-4 list-inside list-disc space-y-1">
|
||||
<li>Command line AI assistance</li>
|
||||
<li>Terminal-based code generation</li>
|
||||
<li>Shell script optimization</li>
|
||||
<li>DevOps automation help</li>
|
||||
</ul>
|
||||
<div className="bg-background-3 flex items-center rounded">
|
||||
<Input
|
||||
type="text"
|
||||
id="memoryUrl"
|
||||
value={memoryUrl}
|
||||
readOnly
|
||||
className="bg-background-3 block w-full text-base"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={copyToClipboard}
|
||||
className="px-3"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
case "vscode":
|
||||
return {
|
||||
title: "About Visual Studio Code",
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<p className="leading-relaxed">
|
||||
Visual Studio Code is a lightweight but powerful source code
|
||||
editor with extensive extension support.
|
||||
</p>
|
||||
<ul className="ml-4 list-inside list-disc space-y-1">
|
||||
<li>Intelligent code completion</li>
|
||||
<li>Built-in Git integration</li>
|
||||
<li>Extensive extension marketplace</li>
|
||||
<li>Debugging and testing tools</li>
|
||||
</ul>
|
||||
<p>You need to enable MCP in settings</p>
|
||||
<div className="bg-background-3 flex flex-col items-start gap-2 rounded p-2">
|
||||
<pre>
|
||||
{JSON.stringify(
|
||||
{
|
||||
"chat.mcp.enabled": true,
|
||||
"chat.mcp.discovery.enabled": true,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="bg-background-3 flex items-center rounded p-2">
|
||||
<pre className="bg-background-3 m-0 block w-full p-0 text-base break-words whitespace-pre-wrap">
|
||||
{JSON.stringify(
|
||||
{
|
||||
memory: {
|
||||
type: "http",
|
||||
url: memoryUrl,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</pre>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
navigator.clipboard
|
||||
.writeText(
|
||||
JSON.stringify(
|
||||
{
|
||||
memory: {
|
||||
type: "http",
|
||||
url: memoryUrl,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
.then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
})
|
||||
}
|
||||
className="px-3"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const customContent = getCustomContent();
|
||||
|
||||
if (!customContent) return null;
|
||||
const Component = getIcon(integration.icon as IconType);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<PageHeader
|
||||
title="Integrations"
|
||||
breadcrumbs={[
|
||||
{ label: "Integrations", href: "/home/integrations" },
|
||||
{ label: integration?.name || "Untitled" },
|
||||
]}
|
||||
actions={[
|
||||
{
|
||||
label: "Request New Integration",
|
||||
icon: <Plus size={14} />,
|
||||
onClick: () =>
|
||||
window.open(
|
||||
"https://github.com/redplanethq/core/issues/new",
|
||||
"_blank",
|
||||
),
|
||||
variant: "secondary",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className="flex h-[calc(100vh_-_56px)] flex-col items-center overflow-hidden p-4 px-5">
|
||||
<div className="w-5xl">
|
||||
<Section
|
||||
title={integration.name}
|
||||
description={integration.description}
|
||||
icon={
|
||||
<div className="bg-grayAlpha-100 flex h-12 w-12 items-center justify-center rounded">
|
||||
<Component size={24} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div>{customContent.content}</div>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface IntegrationDetailProps {
|
||||
integration: any;
|
||||
integrationAccounts: any;
|
||||
ingestionRule: any;
|
||||
}
|
||||
|
||||
export function IntegrationDetail({
|
||||
integration,
|
||||
integrationAccounts,
|
||||
ingestionRule,
|
||||
}: IntegrationDetailProps) {
|
||||
const activeAccount = useMemo(
|
||||
() =>
|
||||
integrationAccounts.find(
|
||||
(acc) => acc.integrationDefinitionId === integration.id && acc.isActive,
|
||||
(acc: IntegrationAccount) =>
|
||||
acc.integrationDefinitionId === integration.id && acc.isActive,
|
||||
),
|
||||
[integrationAccounts, integration.id],
|
||||
);
|
||||
@ -181,21 +492,21 @@ export default function IntegrationDetail() {
|
||||
<div className="space-y-2">
|
||||
{hasApiKey && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-2 text-sm">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Checkbox checked /> API Key authentication
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{hasOAuth2 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-2 text-sm">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Checkbox checked />
|
||||
OAuth 2.0 authentication
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!hasApiKey && !hasOAuth2 && !hasMCPAuth && (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<div className="text-muted-foreground">
|
||||
No authentication method specified
|
||||
</div>
|
||||
)}
|
||||
@ -226,7 +537,7 @@ export default function IntegrationDetail() {
|
||||
)}
|
||||
|
||||
{/* Connected Account Info */}
|
||||
<ConnectedAccountSection activeAccount={activeAccount} />
|
||||
<ConnectedAccountSection activeAccount={activeAccount as any} />
|
||||
|
||||
{/* MCP Authentication Section */}
|
||||
<MCPAuthSection
|
||||
@ -247,3 +558,29 @@ export default function IntegrationDetail() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IntegrationDetailWrapper() {
|
||||
const { integration, integrationAccounts, ingestionRule } =
|
||||
useLoaderData<typeof loader>();
|
||||
|
||||
const { slug } = useParams();
|
||||
// You can now use the `slug` param in your component
|
||||
|
||||
const fixedIntegration = FIXED_INTEGRATIONS.some(
|
||||
(fixedInt) => fixedInt.slug === slug,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{fixedIntegration ? (
|
||||
<CustomIntegrationContent integration={integration} />
|
||||
) : (
|
||||
<IntegrationDetail
|
||||
integration={integration}
|
||||
integrationAccounts={integrationAccounts}
|
||||
ingestionRule={ingestionRule}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ import { getIntegrationAccounts } from "~/services/integrationAccount.server";
|
||||
import { IntegrationGrid } from "~/components/integrations/integration-grid";
|
||||
import { PageHeader } from "~/components/common/page-header";
|
||||
import { Plus } from "lucide-react";
|
||||
import { FIXED_INTEGRATIONS } from "~/components/integrations/utils";
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
@ -18,8 +19,11 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
getIntegrationAccounts(userId),
|
||||
]);
|
||||
|
||||
// Combine fixed integrations with dynamic ones
|
||||
const allIntegrations = [...FIXED_INTEGRATIONS, ...integrationDefinitions];
|
||||
|
||||
return json({
|
||||
integrationDefinitions,
|
||||
integrationDefinitions: allIntegrations,
|
||||
integrationAccounts,
|
||||
userId,
|
||||
});
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useFetcher } from "@remix-run/react";
|
||||
import { useLogs } from "~/hooks/use-logs";
|
||||
import { LogsFilters } from "~/components/logs/logs-filters";
|
||||
import { VirtualLogsList } from "~/components/logs/virtual-logs-list";
|
||||
@ -7,11 +7,13 @@ import { AppContainer, PageContainer } from "~/components/layout/app-layout";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import { Database, LoaderCircle } from "lucide-react";
|
||||
import { PageHeader } from "~/components/common/page-header";
|
||||
import { ContributionGraph } from "~/components/activity/contribution-graph";
|
||||
|
||||
export default function LogsAll() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedSource, setSelectedSource] = useState<string | undefined>();
|
||||
const [selectedStatus, setSelectedStatus] = useState<string | undefined>();
|
||||
const contributionFetcher = useFetcher<any>();
|
||||
|
||||
const {
|
||||
logs,
|
||||
@ -26,17 +28,41 @@ export default function LogsAll() {
|
||||
status: selectedStatus,
|
||||
});
|
||||
|
||||
// Fetch contribution data on mount
|
||||
useEffect(() => {
|
||||
if (contributionFetcher.state === "idle" && !contributionFetcher.data) {
|
||||
contributionFetcher.load("/api/v1/activity/contribution");
|
||||
}
|
||||
}, [contributionFetcher]);
|
||||
|
||||
// Get contribution data from fetcher
|
||||
const contributionData = contributionFetcher.data?.success
|
||||
? contributionFetcher.data.data.contributionData
|
||||
: [];
|
||||
const totalActivities = contributionFetcher.data?.success
|
||||
? contributionFetcher.data.data.totalActivities
|
||||
: 0;
|
||||
const isContributionLoading =
|
||||
contributionFetcher.state === "loading" || !contributionFetcher.data;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Logs" />
|
||||
<div className="flex h-[calc(100vh_-_56px)] w-full flex-col items-center space-y-6 p-4 px-5">
|
||||
<div className="flex h-[calc(100vh_-_56px)] w-full flex-col items-center space-y-6 py-4">
|
||||
{/* Contribution Graph */}
|
||||
<div className="mb-0 w-full max-w-5xl px-4">
|
||||
{isContributionLoading ? (
|
||||
<LoaderCircle className="text-primary h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ContributionGraph data={contributionData} />
|
||||
)}
|
||||
</div>
|
||||
{isInitialLoad ? (
|
||||
<>
|
||||
<LoaderCircle className="text-primary h-4 w-4 animate-spin" />{" "}
|
||||
<LoaderCircle className="text-primary h-4 w-4 animate-spin" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{" "}
|
||||
{/* Filters */}
|
||||
{logs.length > 0 && (
|
||||
<LogsFilters
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
import { json, type LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { getNodeLinks } from "~/lib/neo4j.server";
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const url = new URL(request.url);
|
||||
const userId = url.searchParams.get("userId");
|
||||
if (!userId) return json([], { status: 400 });
|
||||
const nodeLinks = await getNodeLinks(userId);
|
||||
return json(nodeLinks);
|
||||
}
|
||||
@ -25,6 +25,7 @@ import { requireUserId } from "~/services/session.server";
|
||||
import { updateUser } from "~/models/user.server";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
import { addToQueue } from "~/lib/ingest.server";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const ONBOARDING_STEP_COOKIE = "onboardingStep";
|
||||
const onboardingStepCookie = createCookie(ONBOARDING_STEP_COOKIE, {
|
||||
@ -108,6 +109,9 @@ export default function Onboarding() {
|
||||
|
||||
const navigate = useNavigate();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [selectedSource, setSelectedSource] = useState<
|
||||
"Claude" | "Cursor" | "Other"
|
||||
>("Claude");
|
||||
|
||||
const [form, fields] = useForm({
|
||||
lastSubmission: lastSubmission as any,
|
||||
@ -117,7 +121,12 @@ export default function Onboarding() {
|
||||
},
|
||||
});
|
||||
|
||||
const memoryUrl = "https://core.heysol.ai/api/v1/mcp/memory";
|
||||
const getMemoryUrl = (source: "Claude" | "Cursor" | "Other") => {
|
||||
const baseUrl = "https://core.heysol.ai/api/v1/mcp/memory";
|
||||
return `${baseUrl}?Source=${source}`;
|
||||
};
|
||||
|
||||
const memoryUrl = getMemoryUrl(selectedSource);
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
try {
|
||||
@ -144,7 +153,25 @@ export default function Onboarding() {
|
||||
|
||||
<CardContent className="pt-2 text-base">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="space-y-3">
|
||||
<div className="bg-grayAlpha-100 flex space-x-1 rounded-lg p-1">
|
||||
{(["Claude", "Cursor", "Other"] as const).map((source) => (
|
||||
<Button
|
||||
key={source}
|
||||
onClick={() => setSelectedSource(source)}
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"flex-1 rounded-md px-3 py-1.5 transition-all",
|
||||
selectedSource === source
|
||||
? "bg-accent text-accent-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{source}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-background-3 flex items-center rounded">
|
||||
<Input
|
||||
type="text"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
export type AuthorizationAction = "read" | "write" | string; // Add more actions as needed
|
||||
|
||||
const ResourceTypes = ["spaces"] as const;
|
||||
const ResourceTypes = ["clusters"] as const;
|
||||
|
||||
export type AuthorizationResources = {
|
||||
[key in (typeof ResourceTypes)[number]]?: string | string[];
|
||||
|
||||
1261
apps/webapp/app/services/clustering.server.ts
Normal file
1261
apps/webapp/app/services/clustering.server.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -8,6 +8,7 @@ import {
|
||||
type Triple,
|
||||
} from "@core/types";
|
||||
import { logger } from "./logger.service";
|
||||
import { ClusteringService } from "./clustering.server";
|
||||
import crypto from "crypto";
|
||||
import {
|
||||
dedupeNodes,
|
||||
@ -53,6 +54,12 @@ import { type PrismaClient } from "@prisma/client";
|
||||
const DEFAULT_EPISODE_WINDOW = 5;
|
||||
|
||||
export class KnowledgeGraphService {
|
||||
private clusteringService: ClusteringService;
|
||||
|
||||
constructor() {
|
||||
this.clusteringService = new ClusteringService();
|
||||
}
|
||||
|
||||
async getEmbedding(text: string) {
|
||||
return getEmbedding(text);
|
||||
}
|
||||
@ -188,6 +195,26 @@ export class KnowledgeGraphService {
|
||||
// Invalidate invalidated statements
|
||||
await invalidateStatements({ statementIds: invalidatedStatements });
|
||||
|
||||
// Trigger incremental clustering process after successful ingestion
|
||||
if (resolvedStatements.length > 0) {
|
||||
try {
|
||||
logger.info(
|
||||
"Triggering incremental clustering process after episode ingestion",
|
||||
);
|
||||
const clusteringResult =
|
||||
await this.clusteringService.performClustering(
|
||||
params.userId,
|
||||
false,
|
||||
);
|
||||
logger.info(
|
||||
`Incremental clustering completed: ${clusteringResult.clustersCreated} clusters created, ${clusteringResult.statementsProcessed} statements processed`,
|
||||
);
|
||||
} catch (clusteringError) {
|
||||
logger.error("Error in incremental clustering process:");
|
||||
// Don't fail the entire ingestion if clustering fails
|
||||
}
|
||||
}
|
||||
|
||||
const endTime = Date.now();
|
||||
const processingTimeMs = endTime - startTime;
|
||||
logger.log(`Processing time: ${processingTimeMs} ms`);
|
||||
|
||||
@ -957,3 +957,291 @@ export function createHybridActionApiRoute<
|
||||
|
||||
return { loader, action };
|
||||
}
|
||||
|
||||
// Hybrid Loader API Route types and builder
|
||||
type HybridLoaderRouteBuilderOptions<
|
||||
TParamsSchema extends AnyZodSchema | undefined = undefined,
|
||||
TSearchParamsSchema extends AnyZodSchema | undefined = undefined,
|
||||
THeadersSchema extends AnyZodSchema | undefined = undefined,
|
||||
TResource = never,
|
||||
> = {
|
||||
params?: TParamsSchema;
|
||||
searchParams?: TSearchParamsSchema;
|
||||
headers?: THeadersSchema;
|
||||
allowJWT?: boolean;
|
||||
corsStrategy?: "all" | "none";
|
||||
findResource: (
|
||||
params: TParamsSchema extends
|
||||
| z.ZodFirstPartySchemaTypes
|
||||
| z.ZodDiscriminatedUnion<any, any>
|
||||
? z.infer<TParamsSchema>
|
||||
: undefined,
|
||||
authentication: HybridAuthenticationResult,
|
||||
searchParams: TSearchParamsSchema extends
|
||||
| z.ZodFirstPartySchemaTypes
|
||||
| z.ZodDiscriminatedUnion<any, any>
|
||||
? z.infer<TSearchParamsSchema>
|
||||
: undefined,
|
||||
) => Promise<TResource | undefined>;
|
||||
shouldRetryNotFound?: boolean;
|
||||
authorization?: {
|
||||
action: AuthorizationAction;
|
||||
resource: (
|
||||
resource: NonNullable<TResource>,
|
||||
params: TParamsSchema extends
|
||||
| z.ZodFirstPartySchemaTypes
|
||||
| z.ZodDiscriminatedUnion<any, any>
|
||||
? z.infer<TParamsSchema>
|
||||
: undefined,
|
||||
searchParams: TSearchParamsSchema extends
|
||||
| z.ZodFirstPartySchemaTypes
|
||||
| z.ZodDiscriminatedUnion<any, any>
|
||||
? z.infer<TSearchParamsSchema>
|
||||
: undefined,
|
||||
headers: THeadersSchema extends
|
||||
| z.ZodFirstPartySchemaTypes
|
||||
| z.ZodDiscriminatedUnion<any, any>
|
||||
? z.infer<THeadersSchema>
|
||||
: undefined,
|
||||
) => AuthorizationResources;
|
||||
superScopes?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
type HybridLoaderHandlerFunction<
|
||||
TParamsSchema extends AnyZodSchema | undefined,
|
||||
TSearchParamsSchema extends AnyZodSchema | undefined,
|
||||
THeadersSchema extends AnyZodSchema | undefined = undefined,
|
||||
TResource = never,
|
||||
> = (args: {
|
||||
params: TParamsSchema extends
|
||||
| z.ZodFirstPartySchemaTypes
|
||||
| z.ZodDiscriminatedUnion<any, any>
|
||||
? z.infer<TParamsSchema>
|
||||
: undefined;
|
||||
searchParams: TSearchParamsSchema extends
|
||||
| z.ZodFirstPartySchemaTypes
|
||||
| z.ZodDiscriminatedUnion<any, any>
|
||||
? z.infer<TSearchParamsSchema>
|
||||
: undefined;
|
||||
headers: THeadersSchema extends
|
||||
| z.ZodFirstPartySchemaTypes
|
||||
| z.ZodDiscriminatedUnion<any, any>
|
||||
? z.infer<THeadersSchema>
|
||||
: undefined;
|
||||
authentication: HybridAuthenticationResult;
|
||||
request: Request;
|
||||
resource: NonNullable<TResource>;
|
||||
}) => Promise<Response>;
|
||||
|
||||
export function createHybridLoaderApiRoute<
|
||||
TParamsSchema extends AnyZodSchema | undefined = undefined,
|
||||
TSearchParamsSchema extends AnyZodSchema | undefined = undefined,
|
||||
THeadersSchema extends AnyZodSchema | undefined = undefined,
|
||||
TResource = never,
|
||||
>(
|
||||
options: HybridLoaderRouteBuilderOptions<
|
||||
TParamsSchema,
|
||||
TSearchParamsSchema,
|
||||
THeadersSchema,
|
||||
TResource
|
||||
>,
|
||||
handler: HybridLoaderHandlerFunction<
|
||||
TParamsSchema,
|
||||
TSearchParamsSchema,
|
||||
THeadersSchema,
|
||||
TResource
|
||||
>,
|
||||
) {
|
||||
return async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const {
|
||||
params: paramsSchema,
|
||||
searchParams: searchParamsSchema,
|
||||
headers: headersSchema,
|
||||
allowJWT = false,
|
||||
corsStrategy = "none",
|
||||
authorization,
|
||||
findResource,
|
||||
shouldRetryNotFound,
|
||||
} = options;
|
||||
|
||||
if (corsStrategy !== "none" && request.method.toUpperCase() === "OPTIONS") {
|
||||
return apiCors(request, json({}));
|
||||
}
|
||||
|
||||
try {
|
||||
const authenticationResult = await authenticateHybridRequest(request, {
|
||||
allowJWT,
|
||||
});
|
||||
|
||||
if (!authenticationResult) {
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json({ error: "Authentication required" }, { status: 401 }),
|
||||
corsStrategy !== "none",
|
||||
);
|
||||
}
|
||||
|
||||
let parsedParams: any = undefined;
|
||||
if (paramsSchema) {
|
||||
const parsed = paramsSchema.safeParse(params);
|
||||
if (!parsed.success) {
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json(
|
||||
{
|
||||
error: "Params Error",
|
||||
details: fromZodError(parsed.error).details,
|
||||
},
|
||||
{ status: 400 },
|
||||
),
|
||||
corsStrategy !== "none",
|
||||
);
|
||||
}
|
||||
parsedParams = parsed.data;
|
||||
}
|
||||
|
||||
let parsedSearchParams: any = undefined;
|
||||
if (searchParamsSchema) {
|
||||
const searchParams = Object.fromEntries(
|
||||
new URL(request.url).searchParams,
|
||||
);
|
||||
const parsed = searchParamsSchema.safeParse(searchParams);
|
||||
if (!parsed.success) {
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json(
|
||||
{
|
||||
error: "Query Error",
|
||||
details: fromZodError(parsed.error).details,
|
||||
},
|
||||
{ status: 400 },
|
||||
),
|
||||
corsStrategy !== "none",
|
||||
);
|
||||
}
|
||||
parsedSearchParams = parsed.data;
|
||||
}
|
||||
|
||||
let parsedHeaders: any = undefined;
|
||||
if (headersSchema) {
|
||||
const rawHeaders = Object.fromEntries(request.headers);
|
||||
const headers = headersSchema.safeParse(rawHeaders);
|
||||
if (!headers.success) {
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json(
|
||||
{
|
||||
error: "Headers Error",
|
||||
details: fromZodError(headers.error).details,
|
||||
},
|
||||
{ status: 400 },
|
||||
),
|
||||
corsStrategy !== "none",
|
||||
);
|
||||
}
|
||||
parsedHeaders = headers.data;
|
||||
}
|
||||
|
||||
// Find the resource
|
||||
const resource = await findResource(
|
||||
parsedParams,
|
||||
authenticationResult,
|
||||
parsedSearchParams,
|
||||
);
|
||||
|
||||
if (!resource) {
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json(
|
||||
{ error: "Not found" },
|
||||
{
|
||||
status: 404,
|
||||
headers: {
|
||||
"x-should-retry": shouldRetryNotFound ? "true" : "false",
|
||||
},
|
||||
},
|
||||
),
|
||||
corsStrategy !== "none",
|
||||
);
|
||||
}
|
||||
|
||||
// Authorization check - only applies to API key authentication
|
||||
if (authorization && authenticationResult.type === "PRIVATE") {
|
||||
const { action, resource: authResource, superScopes } = authorization;
|
||||
const $authResource = authResource(
|
||||
resource,
|
||||
parsedParams,
|
||||
parsedSearchParams,
|
||||
parsedHeaders,
|
||||
);
|
||||
|
||||
logger.debug("Checking authorization", {
|
||||
action,
|
||||
resource: $authResource,
|
||||
superScopes,
|
||||
scopes: authenticationResult.scopes,
|
||||
});
|
||||
|
||||
const authorizationResult = checkAuthorization(authenticationResult);
|
||||
|
||||
if (!authorizationResult.authorized) {
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json(
|
||||
{
|
||||
error: `Unauthorized: ${authorizationResult.reason}`,
|
||||
code: "unauthorized",
|
||||
param: "access_token",
|
||||
type: "authorization",
|
||||
},
|
||||
{ status: 403 },
|
||||
),
|
||||
corsStrategy !== "none",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await handler({
|
||||
params: parsedParams,
|
||||
searchParams: parsedSearchParams,
|
||||
headers: parsedHeaders,
|
||||
authentication: authenticationResult,
|
||||
request,
|
||||
resource,
|
||||
});
|
||||
return await wrapResponse(request, result, corsStrategy !== "none");
|
||||
} catch (error) {
|
||||
try {
|
||||
if (error instanceof Response) {
|
||||
return await wrapResponse(request, error, corsStrategy !== "none");
|
||||
}
|
||||
|
||||
logger.error("Error in hybrid loader", {
|
||||
error:
|
||||
error instanceof Error
|
||||
? {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
}
|
||||
: String(error),
|
||||
url: request.url,
|
||||
});
|
||||
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json({ error: "Internal Server Error" }, { status: 500 }),
|
||||
corsStrategy !== "none",
|
||||
);
|
||||
} catch (innerError) {
|
||||
logger.error("[apiBuilder] Failed to handle error", {
|
||||
error,
|
||||
innerError,
|
||||
});
|
||||
|
||||
return json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { StatementNode } from "@core/types";
|
||||
import type { EpisodicNode, StatementNode } from "@core/types";
|
||||
import { logger } from "./logger.service";
|
||||
import { applyCrossEncoderReranking, applyWeightedRRF } from "./search/rerank";
|
||||
import {
|
||||
@ -8,6 +8,8 @@ import {
|
||||
performVectorSearch,
|
||||
} from "./search/utils";
|
||||
import { getEmbedding } from "~/lib/model.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import { runQuery } from "~/lib/neo4j.server";
|
||||
|
||||
/**
|
||||
* SearchService provides methods to search the reified + temporal knowledge graph
|
||||
@ -30,6 +32,7 @@ export class SearchService {
|
||||
userId: string,
|
||||
options: SearchOptions = {},
|
||||
): Promise<{ episodes: string[]; facts: string[] }> {
|
||||
const startTime = Date.now();
|
||||
// Default options
|
||||
|
||||
const opts: Required<SearchOptions> = {
|
||||
@ -70,6 +73,21 @@ export class SearchService {
|
||||
|
||||
// 3. Return top results
|
||||
const episodes = await getEpisodesByStatements(filteredResults);
|
||||
|
||||
// Log recall asynchronously (don't await to avoid blocking response)
|
||||
const responseTime = Date.now() - startTime;
|
||||
this.logRecallAsync(
|
||||
query,
|
||||
userId,
|
||||
filteredResults,
|
||||
opts,
|
||||
responseTime,
|
||||
).catch((error) => {
|
||||
logger.error("Failed to log recall event:", error);
|
||||
});
|
||||
|
||||
this.updateRecallCount(userId, episodes, filteredResults);
|
||||
|
||||
return {
|
||||
episodes: episodes.map((episode) => episode.content),
|
||||
facts: filteredResults.map((statement) => statement.fact),
|
||||
@ -201,6 +219,100 @@ export class SearchService {
|
||||
// Otherwise use weighted RRF for multiple sources
|
||||
return applyWeightedRRF(results);
|
||||
}
|
||||
|
||||
private async logRecallAsync(
|
||||
query: string,
|
||||
userId: string,
|
||||
results: StatementNode[],
|
||||
options: Required<SearchOptions>,
|
||||
responseTime: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Determine target type based on results
|
||||
let targetType = "mixed_results";
|
||||
if (results.length === 1) {
|
||||
targetType = "statement";
|
||||
} else if (results.length === 0) {
|
||||
targetType = "no_results";
|
||||
}
|
||||
|
||||
// Calculate average similarity score if available
|
||||
let averageSimilarityScore: number | null = null;
|
||||
const scoresWithValues = results
|
||||
.map((result) => {
|
||||
// Try to extract score from various possible score fields
|
||||
const score =
|
||||
(result as any).rrfScore ||
|
||||
(result as any).mmrScore ||
|
||||
(result as any).crossEncoderScore ||
|
||||
(result as any).finalScore ||
|
||||
(result as any).score;
|
||||
return score && typeof score === "number" ? score : null;
|
||||
})
|
||||
.filter((score): score is number => score !== null);
|
||||
|
||||
if (scoresWithValues.length > 0) {
|
||||
averageSimilarityScore =
|
||||
scoresWithValues.reduce((sum, score) => sum + score, 0) /
|
||||
scoresWithValues.length;
|
||||
}
|
||||
|
||||
await prisma.recallLog.create({
|
||||
data: {
|
||||
accessType: "search",
|
||||
query,
|
||||
targetType,
|
||||
searchMethod: "hybrid", // BM25 + Vector + BFS
|
||||
minSimilarity: options.scoreThreshold,
|
||||
maxResults: options.limit,
|
||||
resultCount: results.length,
|
||||
similarityScore: averageSimilarityScore,
|
||||
context: JSON.stringify({
|
||||
entityTypes: options.entityTypes,
|
||||
predicateTypes: options.predicateTypes,
|
||||
maxBfsDepth: options.maxBfsDepth,
|
||||
includeInvalidated: options.includeInvalidated,
|
||||
validAt: options.validAt.toISOString(),
|
||||
startTime: options.startTime?.toISOString() || null,
|
||||
endTime: options.endTime.toISOString(),
|
||||
}),
|
||||
source: "search_api",
|
||||
responseTimeMs: responseTime,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug(
|
||||
`Logged recall event for user ${userId}: ${results.length} results in ${responseTime}ms`,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Error creating recall log entry:", { error });
|
||||
// Don't throw - we don't want logging failures to affect the search response
|
||||
}
|
||||
}
|
||||
|
||||
private async updateRecallCount(
|
||||
userId: string,
|
||||
episodes: EpisodicNode[],
|
||||
statements: StatementNode[],
|
||||
) {
|
||||
const episodeIds = episodes.map((episode) => episode.uuid);
|
||||
const statementIds = statements.map((statement) => statement.uuid);
|
||||
|
||||
const cypher = `
|
||||
MATCH (e:Episode)
|
||||
WHERE e.uuid IN $episodeUuids and e.userId = $userId
|
||||
SET e.recallCount = coalesce(e.recallCount, 0) + 1
|
||||
`;
|
||||
await runQuery(cypher, { episodeUuids: episodeIds, userId });
|
||||
|
||||
const cypher2 = `
|
||||
MATCH (s:Statement)
|
||||
WHERE s.uuid IN $statementUuids and s.userId = $userId
|
||||
SET s.recallCount = coalesce(s.recallCount, 0) + 1
|
||||
`;
|
||||
await runQuery(cypher2, { statementUuids: statementIds, userId });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -465,6 +465,27 @@
|
||||
}
|
||||
|
||||
@layer base {
|
||||
.react-calendar-heatmap {
|
||||
font-size: 9px;
|
||||
}
|
||||
.react-calendar-heatmap .react-calendar-heatmap-month-label {
|
||||
font-size: 9px;
|
||||
fill: hsl(var(--muted-foreground));
|
||||
}
|
||||
.react-calendar-heatmap .react-calendar-heatmap-weekday-label {
|
||||
font-size: 9px;
|
||||
fill: hsl(var(--muted-foreground));
|
||||
}
|
||||
.react-calendar-heatmap rect {
|
||||
|
||||
rx: 2;
|
||||
}
|
||||
.react-calendar-heatmap rect:hover {
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.tiptap {
|
||||
:first-child {
|
||||
margin-top: 0;
|
||||
@ -535,3 +556,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
115
apps/webapp/app/trigger/cluster/index.ts
Normal file
115
apps/webapp/app/trigger/cluster/index.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import { queue, task } from "@trigger.dev/sdk";
|
||||
import { z } from "zod";
|
||||
import { ClusteringService } from "~/services/clustering.server";
|
||||
import { logger } from "~/services/logger.service";
|
||||
|
||||
const clusteringService = new ClusteringService();
|
||||
|
||||
// Define the payload schema for cluster tasks
|
||||
export const ClusterPayload = z.object({
|
||||
userId: z.string(),
|
||||
mode: z.enum(["auto", "incremental", "complete", "drift"]).default("auto"),
|
||||
forceComplete: z.boolean().default(false),
|
||||
});
|
||||
|
||||
const clusterQueue = queue({
|
||||
name: "cluster-queue",
|
||||
concurrencyLimit: 10,
|
||||
});
|
||||
|
||||
/**
|
||||
* Single clustering task that handles all clustering operations based on payload mode
|
||||
*/
|
||||
export const clusterTask = task({
|
||||
id: "cluster",
|
||||
queue: clusterQueue,
|
||||
maxDuration: 1800, // 30 minutes max
|
||||
run: async (payload: z.infer<typeof ClusterPayload>) => {
|
||||
logger.info(`Starting ${payload.mode} clustering task for user ${payload.userId}`);
|
||||
|
||||
try {
|
||||
let result;
|
||||
|
||||
switch (payload.mode) {
|
||||
case "incremental":
|
||||
result = await clusteringService.performIncrementalClustering(
|
||||
payload.userId,
|
||||
);
|
||||
logger.info(`Incremental clustering completed for user ${payload.userId}:`, {
|
||||
newStatementsProcessed: result.newStatementsProcessed,
|
||||
newClustersCreated: result.newClustersCreated,
|
||||
});
|
||||
break;
|
||||
|
||||
case "complete":
|
||||
result = await clusteringService.performCompleteClustering(
|
||||
payload.userId,
|
||||
);
|
||||
logger.info(`Complete clustering completed for user ${payload.userId}:`, {
|
||||
clustersCreated: result.clustersCreated,
|
||||
statementsProcessed: result.statementsProcessed,
|
||||
});
|
||||
break;
|
||||
|
||||
case "drift":
|
||||
// First detect drift
|
||||
const driftMetrics = await clusteringService.detectClusterDrift(
|
||||
payload.userId,
|
||||
);
|
||||
|
||||
if (driftMetrics.driftDetected) {
|
||||
// Handle drift by splitting low-cohesion clusters
|
||||
const driftResult = await clusteringService.handleClusterDrift(
|
||||
payload.userId,
|
||||
);
|
||||
|
||||
logger.info(`Cluster drift handling completed for user ${payload.userId}:`, {
|
||||
driftDetected: true,
|
||||
clustersProcessed: driftResult.clustersProcessed,
|
||||
newClustersCreated: driftResult.newClustersCreated,
|
||||
splitClusters: driftResult.splitClusters,
|
||||
});
|
||||
|
||||
result = {
|
||||
driftDetected: true,
|
||||
...driftResult,
|
||||
driftMetrics,
|
||||
};
|
||||
} else {
|
||||
logger.info(`No cluster drift detected for user ${payload.userId}`);
|
||||
result = {
|
||||
driftDetected: false,
|
||||
clustersProcessed: 0,
|
||||
newClustersCreated: 0,
|
||||
splitClusters: [],
|
||||
driftMetrics,
|
||||
};
|
||||
}
|
||||
break;
|
||||
|
||||
case "auto":
|
||||
default:
|
||||
result = await clusteringService.performClustering(
|
||||
payload.userId,
|
||||
payload.forceComplete,
|
||||
);
|
||||
logger.info(`Auto clustering completed for user ${payload.userId}:`, {
|
||||
clustersCreated: result.clustersCreated,
|
||||
statementsProcessed: result.statementsProcessed,
|
||||
approach: result.approach,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`${payload.mode} clustering failed for user ${payload.userId}:`, {
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
@ -108,7 +108,7 @@ const Keys = [
|
||||
export async function addEnvVariablesInTrigger() {
|
||||
const {
|
||||
APP_ORIGIN,
|
||||
TRIGGER_DB,
|
||||
POSTGRES_DB,
|
||||
EMBEDDING_MODEL,
|
||||
MODEL,
|
||||
ENCRYPTION_KEY,
|
||||
@ -121,7 +121,7 @@ export async function addEnvVariablesInTrigger() {
|
||||
TRIGGER_SECRET_KEY,
|
||||
} = env;
|
||||
|
||||
const DATABASE_URL = getDatabaseUrl(TRIGGER_DB);
|
||||
const DATABASE_URL = getDatabaseUrl(POSTGRES_DB);
|
||||
|
||||
// Helper to replace 'localhost' with 'host.docker.internal'
|
||||
function replaceLocalhost(val: string | undefined): string | undefined {
|
||||
|
||||
BIN
apps/webapp/integrations/slack/main
Executable file
BIN
apps/webapp/integrations/slack/main
Executable file
Binary file not shown.
@ -75,6 +75,7 @@
|
||||
"@tiptap/starter-kit": "2.11.9",
|
||||
"@trigger.dev/react-hooks": "^4.0.0-v4-beta.22",
|
||||
"@trigger.dev/sdk": "^4.0.0-v4-beta.22",
|
||||
"@types/react-calendar-heatmap": "^1.9.0",
|
||||
"ai": "4.3.14",
|
||||
"axios": "^1.10.0",
|
||||
"bullmq": "^5.53.2",
|
||||
@ -111,6 +112,7 @@
|
||||
"ollama-ai-provider": "1.2.0",
|
||||
"posthog-js": "^1.116.6",
|
||||
"react": "^18.2.0",
|
||||
"react-calendar-heatmap": "^1.10.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-resizable-panels": "^1.0.9",
|
||||
"react-virtualized": "^9.22.6",
|
||||
|
||||
@ -50,8 +50,8 @@ services:
|
||||
image: neo4j:5.25-community
|
||||
environment:
|
||||
- NEO4J_AUTH=${NEO4J_AUTH}
|
||||
- NEO4J_dbms_security_procedures_unrestricted=gds.*
|
||||
- NEO4J_dbms_security_procedures_allowlist=gds.*
|
||||
- NEO4J_dbms_security_procedures_unrestricted=gds.*,apoc.*
|
||||
- NEO4J_dbms_security_procedures_allowlist=gds.*,apoc.*
|
||||
ports:
|
||||
- "7474:7474"
|
||||
- "7687:7687"
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
VERSION=0.1.13
|
||||
VERSION=0.1.14
|
||||
|
||||
# Nest run in docker, change host to database container name
|
||||
DB_HOST=localhost
|
||||
|
||||
@ -76,8 +76,13 @@ services:
|
||||
image: neo4j:5
|
||||
environment:
|
||||
- NEO4J_AUTH=${NEO4J_AUTH}
|
||||
- NEO4J_dbms_security_procedures_unrestricted=gds.*
|
||||
- NEO4J_dbms_security_procedures_allowlist=gds.*
|
||||
- NEO4J_dbms_security_procedures_unrestricted=gds.*,apoc.*
|
||||
- NEO4J_dbms_security_procedures_allowlist=gds.*,apoc.*
|
||||
- NEO4J_apoc_export_file_enabled=true # Enable file export
|
||||
- NEO4J_apoc_import_file_enabled=true # Enable file import
|
||||
- NEO4J_apoc_import_file_use_neo4j_config=true
|
||||
- NEO4J_dbms_memory_heap_initial__size=2G
|
||||
- NEO4J_dbms_memory_heap_max__size=4G
|
||||
ports:
|
||||
- "7474:7474"
|
||||
- "7687:7687"
|
||||
|
||||
@ -136,4 +136,4 @@ OBJECT_STORE_SECRET_ACCESS_KEY=very-safe-password
|
||||
# TRAEFIK_HTTPS_PUBLISH_IP=0.0.0.0
|
||||
# TRAEFIK_DASHBOARD_PUBLISH_IP=127.0.0.1
|
||||
|
||||
CORE_VERSION=0.1.13
|
||||
CORE_VERSION=0.1.14
|
||||
2
integrations/github/.gitignore
vendored
Normal file
2
integrations/github/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
bin
|
||||
node_modules
|
||||
22
integrations/github/.prettierrc
Normal file
22
integrations/github/.prettierrc
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"arrowParens": "always",
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"bracketSpacing": true,
|
||||
"jsxBracketSameLine": false,
|
||||
"requirePragma": false,
|
||||
"proseWrap": "preserve",
|
||||
"singleQuote": true,
|
||||
"formatOnSave": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"overrides": [
|
||||
{
|
||||
"files": ".prettierrc",
|
||||
"options": {
|
||||
"parser": "json"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
98
integrations/github/eslint.config.js
Normal file
98
integrations/github/eslint.config.js
Normal file
@ -0,0 +1,98 @@
|
||||
const eslint = require('@eslint/js');
|
||||
const tseslint = require('typescript-eslint');
|
||||
const reactPlugin = require('eslint-plugin-react');
|
||||
const jestPlugin = require('eslint-plugin-jest');
|
||||
const importPlugin = require('eslint-plugin-import');
|
||||
const prettierPlugin = require('eslint-plugin-prettier');
|
||||
const unusedImportsPlugin = require('eslint-plugin-unused-imports');
|
||||
const jsxA11yPlugin = require('eslint-plugin-jsx-a11y');
|
||||
|
||||
module.exports = [
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ['**/*.{js,jsx,ts,tsx}'],
|
||||
plugins: {
|
||||
react: reactPlugin,
|
||||
jest: jestPlugin,
|
||||
import: importPlugin,
|
||||
prettier: prettierPlugin,
|
||||
'unused-imports': unusedImportsPlugin,
|
||||
'jsx-a11y': jsxA11yPlugin,
|
||||
},
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'jsx-a11y/label-has-associated-control': 'error',
|
||||
curly: 'warn',
|
||||
'dot-location': 'warn',
|
||||
eqeqeq: 'error',
|
||||
'prettier/prettier': 'warn',
|
||||
'unused-imports/no-unused-imports': 'warn',
|
||||
'no-else-return': 'warn',
|
||||
'no-lonely-if': 'warn',
|
||||
'no-inner-declarations': 'off',
|
||||
'no-unused-vars': 'off',
|
||||
'no-useless-computed-key': 'warn',
|
||||
'no-useless-return': 'warn',
|
||||
'no-var': 'warn',
|
||||
'object-shorthand': ['warn', 'always'],
|
||||
'prefer-arrow-callback': 'warn',
|
||||
'prefer-const': 'warn',
|
||||
'prefer-destructuring': ['warn', { AssignmentExpression: { array: true } }],
|
||||
'prefer-object-spread': 'warn',
|
||||
'prefer-template': 'warn',
|
||||
'spaced-comment': ['warn', 'always', { markers: ['/'] }],
|
||||
yoda: 'warn',
|
||||
'import/order': [
|
||||
'warn',
|
||||
{
|
||||
'newlines-between': 'always',
|
||||
groups: ['type', 'builtin', 'external', 'internal', ['parent', 'sibling'], 'index'],
|
||||
pathGroupsExcludedImportTypes: ['builtin'],
|
||||
pathGroups: [],
|
||||
alphabetize: {
|
||||
order: 'asc',
|
||||
caseInsensitive: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/array-type': ['warn', { default: 'array-simple' }],
|
||||
'@typescript-eslint/ban-ts-comment': [
|
||||
'warn',
|
||||
{
|
||||
'ts-expect-error': 'allow-with-description',
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/consistent-indexed-object-style': ['warn', 'record'],
|
||||
'@typescript-eslint/consistent-type-definitions': ['warn', 'interface'],
|
||||
'@typescript-eslint/no-unused-vars': 'warn',
|
||||
'react/function-component-definition': [
|
||||
'warn',
|
||||
{
|
||||
namedComponents: 'arrow-function',
|
||||
unnamedComponents: 'arrow-function',
|
||||
},
|
||||
],
|
||||
'react/jsx-boolean-value': 'warn',
|
||||
'react/jsx-curly-brace-presence': 'warn',
|
||||
'react/jsx-fragments': 'warn',
|
||||
'react/jsx-no-useless-fragment': ['warn', { allowExpressions: true }],
|
||||
'react/self-closing-comp': 'warn',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['scripts/**/*'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-var-requires': 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
64
integrations/github/package.json
Normal file
64
integrations/github/package.json
Normal file
@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "@core/github",
|
||||
"version": "0.1.0",
|
||||
"description": "github extension for CORE",
|
||||
"main": "./bin/index.js",
|
||||
"module": "./bin/index.mjs",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"github",
|
||||
"bin"
|
||||
],
|
||||
"bin": {
|
||||
"github": "./bin/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "rimraf bin && npx tsup",
|
||||
"lint": "eslint --ext js,ts,tsx backend/ frontend/ --fix",
|
||||
"prettier": "prettier --config .prettierrc --write .",
|
||||
"copy:spec": "cp spec.json bin/"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/preset-typescript": "^7.26.0",
|
||||
"@types/node": "^18.0.20",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
"eslint-import-resolver-alias": "^1.1.2",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-jest": "^27.9.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-unused-imports": "^2.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"rimraf": "^3.0.2",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^4.7.2",
|
||||
"tsup": "^8.0.1",
|
||||
"ncc": "0.3.6"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.9",
|
||||
"commander": "^12.0.0",
|
||||
"openai": "^4.0.0",
|
||||
"react-query": "^3.39.3",
|
||||
"@redplanethq/sdk": "0.1.2"
|
||||
}
|
||||
}
|
||||
4217
integrations/github/pnpm-lock.yaml
generated
Normal file
4217
integrations/github/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
integrations/github/rollup.config.mjs
Normal file
35
integrations/github/rollup.config.mjs
Normal file
@ -0,0 +1,35 @@
|
||||
import commonjs from '@rollup/plugin-commonjs';
|
||||
import json from '@rollup/plugin-json';
|
||||
import resolve from '@rollup/plugin-node-resolve';
|
||||
import nodePolyfills from 'rollup-plugin-node-polyfills';
|
||||
|
||||
import { terser } from 'rollup-plugin-terser';
|
||||
import typescript from 'rollup-plugin-typescript2';
|
||||
|
||||
export default [
|
||||
{
|
||||
input: 'backend/index.ts',
|
||||
external: ['axios'],
|
||||
output: [
|
||||
{
|
||||
file: 'dist/backend/index.js',
|
||||
sourcemap: true,
|
||||
format: 'cjs',
|
||||
exports: 'named',
|
||||
preserveModules: false,
|
||||
},
|
||||
],
|
||||
plugins: [
|
||||
nodePolyfills(),
|
||||
json(),
|
||||
resolve({ extensions: ['.js', '.ts'] }),
|
||||
commonjs({
|
||||
include: /\/node_modules\//,
|
||||
}),
|
||||
typescript({
|
||||
tsconfig: 'tsconfig.json',
|
||||
}),
|
||||
terser(),
|
||||
],
|
||||
},
|
||||
];
|
||||
25
integrations/github/spec.json
Normal file
25
integrations/github/spec.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "GitHub extension",
|
||||
"key": "github",
|
||||
"description": "Plan, track, and manage your agile and software development projects in GitHub. Customize your workflow, collaborate, and release great software.",
|
||||
"icon": "github",
|
||||
"schedule": {
|
||||
"frequency": "*/5 * * * *"
|
||||
},
|
||||
"auth": {
|
||||
"OAuth2": {
|
||||
"token_url": "https://github.com/login/oauth/access_token",
|
||||
"authorization_url": "https://github.com/login/oauth/authorize",
|
||||
"scopes": ["user", "public_repo", "repo", "notifications", "gist", "read:org", "repo_hooks"],
|
||||
"scope_separator": ","
|
||||
}
|
||||
},
|
||||
"mcp": {
|
||||
"type": "http",
|
||||
"url": "https://api.githubcopilot.com/mcp/",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${config:access_token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
}
|
||||
}
|
||||
37
integrations/github/src/account-create.ts
Normal file
37
integrations/github/src/account-create.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { getGithubData } from './utils';
|
||||
|
||||
export async function integrationCreate(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
data: any,
|
||||
) {
|
||||
const { oauthResponse } = data;
|
||||
const integrationConfiguration = {
|
||||
refresh_token: oauthResponse.refresh_token,
|
||||
access_token: oauthResponse.access_token,
|
||||
};
|
||||
|
||||
const user = await getGithubData(
|
||||
'https://api.github.com/user',
|
||||
integrationConfiguration.access_token,
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
type: 'account',
|
||||
data: {
|
||||
settings: {
|
||||
login: user.login,
|
||||
username: user.login,
|
||||
schedule: {
|
||||
frequency: '*/15 * * * *',
|
||||
},
|
||||
},
|
||||
accountId: user.id.toString(),
|
||||
config: {
|
||||
...integrationConfiguration,
|
||||
mcp: { tokens: { access_token: integrationConfiguration.access_token } },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
69
integrations/github/src/index.ts
Normal file
69
integrations/github/src/index.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { handleSchedule } from './schedule';
|
||||
import { integrationCreate } from './account-create';
|
||||
|
||||
import {
|
||||
IntegrationCLI,
|
||||
IntegrationEventPayload,
|
||||
IntegrationEventType,
|
||||
Spec,
|
||||
} from '@redplanethq/sdk';
|
||||
|
||||
export async function run(eventPayload: IntegrationEventPayload) {
|
||||
switch (eventPayload.event) {
|
||||
case IntegrationEventType.SETUP:
|
||||
console.log(eventPayload.eventBody);
|
||||
return await integrationCreate(eventPayload.eventBody);
|
||||
|
||||
case IntegrationEventType.SYNC:
|
||||
return await handleSchedule(eventPayload.config, eventPayload.state);
|
||||
|
||||
default:
|
||||
return { message: `The event payload type is ${eventPayload.event}` };
|
||||
}
|
||||
}
|
||||
|
||||
// CLI implementation that extends the base class
|
||||
class GitHubCLI extends IntegrationCLI {
|
||||
constructor() {
|
||||
super('github', '1.0.0');
|
||||
}
|
||||
|
||||
protected async handleEvent(eventPayload: IntegrationEventPayload): Promise<any> {
|
||||
return await run(eventPayload);
|
||||
}
|
||||
|
||||
protected async getSpec(): Promise<Spec> {
|
||||
return {
|
||||
name: 'GitHub extension',
|
||||
key: 'github',
|
||||
description:
|
||||
'Plan, track, and manage your agile and software development projects in GitHub. Customize your workflow, collaborate, and release great software.',
|
||||
icon: 'github',
|
||||
auth: {
|
||||
OAuth2: {
|
||||
token_url: 'https://github.com/login/oauth/access_token',
|
||||
authorization_url: 'https://github.com/login/oauth/authorize',
|
||||
scopes: [
|
||||
'user',
|
||||
'public_repo',
|
||||
'repo',
|
||||
'notifications',
|
||||
'gist',
|
||||
'read:org',
|
||||
'repo_hooks',
|
||||
],
|
||||
scope_separator: ',',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Define a main function and invoke it directly.
|
||||
// This works after bundling to JS and running with `node index.js`.
|
||||
function main() {
|
||||
const githubCLI = new GitHubCLI();
|
||||
githubCLI.parse();
|
||||
}
|
||||
|
||||
main();
|
||||
360
integrations/github/src/schedule.ts
Normal file
360
integrations/github/src/schedule.ts
Normal file
@ -0,0 +1,360 @@
|
||||
import { getUserEvents, getGithubData } from './utils';
|
||||
|
||||
interface GitHubActivityCreateParams {
|
||||
text: string;
|
||||
sourceURL: string;
|
||||
}
|
||||
|
||||
interface GitHubSettings {
|
||||
lastSyncTime?: string;
|
||||
lastUserEventTime?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an activity message based on GitHub data
|
||||
*/
|
||||
function createActivityMessage(params: GitHubActivityCreateParams) {
|
||||
return {
|
||||
type: 'activity',
|
||||
data: {
|
||||
text: params.text,
|
||||
sourceURL: params.sourceURL,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets default sync time (24 hours ago)
|
||||
*/
|
||||
function getDefaultSyncTime(): string {
|
||||
return new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches user information from GitHub
|
||||
*/
|
||||
async function fetchUserInfo(accessToken: string) {
|
||||
try {
|
||||
return await getGithubData('https://api.github.com/user', accessToken);
|
||||
} catch (error) {
|
||||
console.error('Error fetching GitHub user info:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes GitHub notifications into activity messages
|
||||
*/
|
||||
async function processNotifications(accessToken: string, lastSyncTime: string): Promise<any[]> {
|
||||
const activities = [];
|
||||
const allowedReasons = [
|
||||
'assign',
|
||||
'review_requested',
|
||||
'mention',
|
||||
'state_change',
|
||||
'subscribed',
|
||||
'author',
|
||||
'approval_requested',
|
||||
'comment',
|
||||
'ci_activity',
|
||||
'invitation',
|
||||
'member_feature_requested',
|
||||
'security_alert',
|
||||
'security_advisory_credit',
|
||||
'team_mention',
|
||||
];
|
||||
|
||||
let page = 1;
|
||||
let hasMorePages = true;
|
||||
|
||||
while (hasMorePages) {
|
||||
try {
|
||||
const notifications = await getGithubData(
|
||||
`https://api.github.com/notifications?page=${page}&per_page=50&all=true&since=${lastSyncTime}`,
|
||||
accessToken,
|
||||
);
|
||||
|
||||
if (!notifications || notifications.length === 0) {
|
||||
hasMorePages = false;
|
||||
break;
|
||||
}
|
||||
|
||||
if (notifications.length < 50) {
|
||||
hasMorePages = false;
|
||||
} else {
|
||||
page++;
|
||||
}
|
||||
|
||||
for (const notification of notifications) {
|
||||
try {
|
||||
if (!allowedReasons.includes(notification.reason)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const repository = notification.repository;
|
||||
const subject = notification.subject;
|
||||
let title = '';
|
||||
let sourceURL = '';
|
||||
|
||||
// Get the actual GitHub data for the notification
|
||||
let githubData: any = {};
|
||||
if (subject.url) {
|
||||
try {
|
||||
githubData = await getGithubData(subject.url, accessToken);
|
||||
} catch (error) {
|
||||
console.error('Error fetching GitHub data for notification:', error);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const url = githubData.html_url || notification.subject.url || '';
|
||||
sourceURL = url;
|
||||
|
||||
const isIssue = subject.type === 'Issue';
|
||||
const isPullRequest = subject.type === 'PullRequest';
|
||||
const isComment = notification.reason === 'comment';
|
||||
|
||||
switch (notification.reason) {
|
||||
case 'assign':
|
||||
title = `${isIssue ? 'Issue' : 'PR'} assigned to you: #${githubData.number} - ${githubData.title}`;
|
||||
break;
|
||||
|
||||
case 'author':
|
||||
if (isComment) {
|
||||
title = `New comment on your ${isIssue ? 'issue' : 'PR'} by ${githubData.user?.login}: ${githubData.body}`;
|
||||
} else {
|
||||
title = `You created this ${isIssue ? 'issue' : 'PR'}: #${githubData.number} - ${githubData.title}`;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'comment':
|
||||
title = `New comment by ${githubData.user?.login} in ${repository.full_name}: ${githubData.body}`;
|
||||
break;
|
||||
|
||||
case 'manual':
|
||||
title = `You subscribed to: #${githubData.number} - ${githubData.title}`;
|
||||
break;
|
||||
|
||||
case 'mention':
|
||||
title = `@mentioned by ${githubData.user?.login} in ${repository.full_name}: ${githubData.body}`;
|
||||
break;
|
||||
|
||||
case 'review_requested':
|
||||
title = `PR review requested in ${repository.full_name}: #${githubData.number} - ${githubData.title}`;
|
||||
break;
|
||||
|
||||
case 'state_change': {
|
||||
let stateInfo = '';
|
||||
if (githubData.state) {
|
||||
stateInfo = `to ${githubData.state}`;
|
||||
} else if (githubData.merged) {
|
||||
stateInfo = 'to merged';
|
||||
} else if (githubData.closed_at) {
|
||||
stateInfo = 'to closed';
|
||||
}
|
||||
title = `State changed ${stateInfo} in ${repository.full_name}: #${githubData.number} - ${githubData.title}`;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'subscribed':
|
||||
if (isComment) {
|
||||
title = `New comment on watched ${isIssue ? 'issue' : 'PR'} in ${repository.full_name} by ${githubData.user?.login}: ${githubData.body}`;
|
||||
} else if (isPullRequest) {
|
||||
title = `New PR created in watched repo ${repository.full_name}: #${githubData.number} - ${githubData.title}`;
|
||||
} else if (isIssue) {
|
||||
title = `New issue created in watched repo ${repository.full_name}: #${githubData.number} - ${githubData.title}`;
|
||||
} else {
|
||||
title = `Update in watched repo ${repository.full_name}: #${githubData.number} - ${githubData.title}`;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'team_mention':
|
||||
title = `Your team was mentioned in ${repository.full_name}`;
|
||||
break;
|
||||
|
||||
default:
|
||||
title = `GitHub notification: ${repository.full_name}`;
|
||||
break;
|
||||
}
|
||||
|
||||
if (title && sourceURL) {
|
||||
activities.push(
|
||||
createActivityMessage({
|
||||
text: title,
|
||||
sourceURL: sourceURL,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently ignore errors to prevent stdout pollution
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently ignore errors to prevent stdout pollution
|
||||
hasMorePages = false;
|
||||
}
|
||||
}
|
||||
|
||||
return activities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes user events (PRs, issues, comments) into activity messages
|
||||
*/
|
||||
async function processUserEvents(
|
||||
username: string,
|
||||
accessToken: string,
|
||||
lastUserEventTime: string,
|
||||
): Promise<any[]> {
|
||||
const activities = [];
|
||||
let page = 1;
|
||||
let hasMorePages = true;
|
||||
|
||||
console.log('Processing user events');
|
||||
|
||||
while (hasMorePages) {
|
||||
try {
|
||||
const userEvents = await getUserEvents(username, page, accessToken, lastUserEventTime);
|
||||
console.log('User events', userEvents);
|
||||
|
||||
if (!userEvents || userEvents.length === 0) {
|
||||
hasMorePages = false;
|
||||
break;
|
||||
}
|
||||
|
||||
if (userEvents.length < 30) {
|
||||
hasMorePages = false;
|
||||
} else {
|
||||
page++;
|
||||
}
|
||||
|
||||
for (const event of userEvents) {
|
||||
try {
|
||||
let title = '';
|
||||
const sourceURL = event.html_url || '';
|
||||
|
||||
switch (event.type) {
|
||||
case 'pr':
|
||||
title = `You created PR #${event.number}: ${event.title}`;
|
||||
break;
|
||||
case 'issue':
|
||||
title = `You created issue #${event.number}: ${event.title}`;
|
||||
break;
|
||||
case 'pr_comment':
|
||||
title = `You commented on PR #${event.number}: ${event.title}`;
|
||||
break;
|
||||
case 'issue_comment':
|
||||
title = `You commented on issue #${event.number}: ${event.title}`;
|
||||
break;
|
||||
case 'self_assigned_issue':
|
||||
title = `You assigned yourself to issue #${event.number}: ${event.title}`;
|
||||
break;
|
||||
default:
|
||||
title = `GitHub activity: ${event.title || 'Unknown'}`;
|
||||
break;
|
||||
}
|
||||
|
||||
if (title && sourceURL) {
|
||||
activities.push(
|
||||
createActivityMessage({
|
||||
text: title,
|
||||
sourceURL: sourceURL,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
console.log('Activities', activities);
|
||||
} catch (error) {
|
||||
// Silently ignore errors to prevent stdout pollution
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently ignore errors to prevent stdout pollution
|
||||
hasMorePages = false;
|
||||
}
|
||||
}
|
||||
|
||||
return activities;
|
||||
}
|
||||
|
||||
export async function handleSchedule(config: any, state: any) {
|
||||
try {
|
||||
const integrationConfiguration = config;
|
||||
|
||||
// Check if we have a valid access token
|
||||
if (!integrationConfiguration?.access_token) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get settings or initialize if not present
|
||||
let settings = (state || {}) as GitHubSettings;
|
||||
|
||||
// Default to 24 hours ago if no last sync times
|
||||
const lastSyncTime = settings.lastSyncTime || getDefaultSyncTime();
|
||||
const lastUserEventTime = settings.lastUserEventTime || getDefaultSyncTime();
|
||||
|
||||
// Fetch user info to get username if not available
|
||||
let user;
|
||||
try {
|
||||
user = await fetchUserInfo(integrationConfiguration.access_token);
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Update username in settings if not present
|
||||
if (!settings.username && user.login) {
|
||||
settings.username = user.login;
|
||||
}
|
||||
|
||||
// Collect all messages
|
||||
const messages = [];
|
||||
|
||||
// Process notifications
|
||||
try {
|
||||
const notificationActivities = await processNotifications(
|
||||
integrationConfiguration.access_token,
|
||||
lastSyncTime,
|
||||
);
|
||||
messages.push(...notificationActivities);
|
||||
} catch (error) {
|
||||
// Silently ignore errors to prevent stdout pollution
|
||||
}
|
||||
|
||||
// Process user events if we have a username
|
||||
if (settings.username) {
|
||||
console.log('Processing user events');
|
||||
try {
|
||||
const userEventActivities = await processUserEvents(
|
||||
settings.username,
|
||||
integrationConfiguration.access_token,
|
||||
lastUserEventTime,
|
||||
);
|
||||
messages.push(...userEventActivities);
|
||||
} catch (error) {
|
||||
// Silently ignore errors to prevent stdout pollution
|
||||
}
|
||||
}
|
||||
|
||||
// Update last sync times
|
||||
const newSyncTime = new Date().toISOString();
|
||||
|
||||
// Add state message for saving settings
|
||||
messages.push({
|
||||
type: 'state',
|
||||
data: {
|
||||
...settings,
|
||||
lastSyncTime: newSyncTime,
|
||||
lastUserEventTime: newSyncTime,
|
||||
},
|
||||
});
|
||||
|
||||
return messages;
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
97
integrations/github/src/utils.ts
Normal file
97
integrations/github/src/utils.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import axios from 'axios';
|
||||
|
||||
export async function getGithubData(url: string, accessToken: string) {
|
||||
return (
|
||||
await axios.get(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
},
|
||||
})
|
||||
).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user events (PRs, issues, comments) and also issues assigned to the user by themselves.
|
||||
*/
|
||||
export async function getUserEvents(
|
||||
username: string,
|
||||
page: number,
|
||||
accessToken: string,
|
||||
since?: string,
|
||||
) {
|
||||
try {
|
||||
const formattedDate = since ? encodeURIComponent(since.split('T')[0]) : '';
|
||||
// Search for user's PRs, issues, and comments since the last sync
|
||||
const [
|
||||
prsResponse,
|
||||
issuesResponse,
|
||||
commentsResponse,
|
||||
// For self-assigned issues, we need to fetch issues assigned to the user and authored by the user
|
||||
assignedIssuesResponse,
|
||||
] = await Promise.all([
|
||||
// Search for PRs created by user
|
||||
getGithubData(
|
||||
`https://api.github.com/search/issues?q=author:${username}+type:pr+created:>${formattedDate}&sort=created&order=desc&page=${page}&per_page=10`,
|
||||
accessToken,
|
||||
),
|
||||
// Search for issues created by user
|
||||
getGithubData(
|
||||
`https://api.github.com/search/issues?q=author:${username}+type:issue+created:>${formattedDate}&sort=created&order=desc&page=${page}&per_page=10`,
|
||||
accessToken,
|
||||
),
|
||||
// Search for issues/PRs the user commented on
|
||||
getGithubData(
|
||||
`https://api.github.com/search/issues?q=commenter:${username}+updated:>${formattedDate}&sort=updated&order=desc&page=${page}&per_page=10`,
|
||||
accessToken,
|
||||
),
|
||||
// Search for issues assigned to the user and authored by the user (self-assigned)
|
||||
getGithubData(
|
||||
`https://api.github.com/search/issues?q=assignee:${username}+author:${username}+type:issue+updated:>${formattedDate}&sort=updated&order=desc&page=${page}&per_page=10`,
|
||||
accessToken,
|
||||
),
|
||||
]);
|
||||
|
||||
console.log('PRs found:', prsResponse?.items?.length || 0);
|
||||
console.log('Issues found:', issuesResponse?.items?.length || 0);
|
||||
console.log('Comments found:', commentsResponse?.items?.length || 0);
|
||||
console.log('Self-assigned issues found:', assignedIssuesResponse?.items?.length || 0);
|
||||
|
||||
// Return simplified results - combine PRs, issues, commented items, and self-assigned issues
|
||||
const results = [
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
...(prsResponse?.items || []).map((item: any) => ({ ...item, type: 'pr' })),
|
||||
...(issuesResponse?.items || [])
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.filter((item: any) => !item.pull_request)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.map((item: any) => ({ ...item, type: 'issue' })),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
...(commentsResponse?.items || []).map((item: any) => ({
|
||||
...item,
|
||||
type: item.pull_request ? 'pr_comment' : 'issue_comment',
|
||||
})),
|
||||
// Add self-assigned issues, but only if not already present in issuesResponse
|
||||
...(assignedIssuesResponse?.items || [])
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.filter((item: any) => {
|
||||
// Only include if not already in issuesResponse (by id)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return !(issuesResponse?.items || []).some((issue: any) => issue.id === item.id);
|
||||
})
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.map((item: any) => ({
|
||||
...item,
|
||||
type: 'self_assigned_issue',
|
||||
})),
|
||||
];
|
||||
|
||||
// Sort by created_at descending
|
||||
results.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error('Error fetching user activity via search:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
32
integrations/github/tsconfig.json
Normal file
32
integrations/github/tsconfig.json
Normal file
@ -0,0 +1,32 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"baseUrl": "frontend",
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"strictNullChecks": true,
|
||||
"removeComments": true,
|
||||
"preserveConstEnums": true,
|
||||
"sourceMap": true,
|
||||
"noUnusedParameters": true,
|
||||
"noUnusedLocals": true,
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitThis": true,
|
||||
"noImplicitAny": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"useUnknownInCatchVariables": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "build", "dist", "scripts", "acceptance-tests", "webpack", "jest"],
|
||||
"types": ["typePatches"]
|
||||
}
|
||||
20
integrations/github/tsup.config.ts
Normal file
20
integrations/github/tsup.config.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
import { dependencies } from './package.json';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['cjs'], // or esm if you're using that
|
||||
bundle: true,
|
||||
target: 'node16',
|
||||
outDir: 'bin',
|
||||
splitting: false,
|
||||
shims: true,
|
||||
clean: true,
|
||||
name: 'github',
|
||||
platform: 'node',
|
||||
legacyOutput: false,
|
||||
noExternal: Object.keys(dependencies || {}), // ⬅️ bundle all deps
|
||||
treeshake: {
|
||||
preset: 'recommended',
|
||||
},
|
||||
});
|
||||
@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "core",
|
||||
"private": true,
|
||||
"version": "0.1.13",
|
||||
"version": "0.1.14",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "dotenv -- turbo run build",
|
||||
"dev": "dotenv -- turbo run dev --filter=!@redplanethq/core",
|
||||
"dev": "dotenv -- turbo run dev",
|
||||
"lint": "dotenv -- turbo run lint",
|
||||
"format": "dotenv -- prettier --write \"**/*.{ts,tsx,md}\"",
|
||||
"check-types": "dotenv -- turbo run check-types",
|
||||
|
||||
@ -0,0 +1,35 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "RecallLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"deleted" TIMESTAMP(3),
|
||||
"accessType" TEXT NOT NULL,
|
||||
"query" TEXT,
|
||||
"targetType" TEXT,
|
||||
"targetId" TEXT,
|
||||
"searchMethod" TEXT,
|
||||
"minSimilarity" DOUBLE PRECISION,
|
||||
"maxResults" INTEGER,
|
||||
"resultCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"similarityScore" DOUBLE PRECISION,
|
||||
"context" TEXT,
|
||||
"source" TEXT,
|
||||
"sessionId" TEXT,
|
||||
"responseTimeMs" INTEGER,
|
||||
"userId" TEXT NOT NULL,
|
||||
"workspaceId" TEXT,
|
||||
"conversationId" TEXT,
|
||||
"metadata" JSONB DEFAULT '{}',
|
||||
|
||||
CONSTRAINT "RecallLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RecallLog" ADD CONSTRAINT "RecallLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RecallLog" ADD CONSTRAINT "RecallLog_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RecallLog" ADD CONSTRAINT "RecallLog_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "Conversation"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@ -64,6 +64,7 @@ model Conversation {
|
||||
status String @default("pending") // Can be "pending", "running", "completed", "failed", "need_attention"
|
||||
|
||||
ConversationHistory ConversationHistory[]
|
||||
RecallLog RecallLog[]
|
||||
}
|
||||
|
||||
model ConversationExecutionStep {
|
||||
@ -423,6 +424,51 @@ model PersonalAccessToken {
|
||||
authorizationCodes AuthorizationCode[]
|
||||
}
|
||||
|
||||
model RecallLog {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deleted DateTime?
|
||||
|
||||
// Access details
|
||||
accessType String // "search", "recall", "direct_access"
|
||||
query String? // Search query (null for direct access)
|
||||
|
||||
// Target information
|
||||
targetType String? // "episode", "statement", "entity", "mixed_results"
|
||||
targetId String? // UUID of specific target (null for search with multiple results)
|
||||
|
||||
// Search/access parameters
|
||||
searchMethod String? // "semantic", "keyword", "hybrid", "contextual", "graph_traversal"
|
||||
minSimilarity Float? // Minimum similarity threshold used
|
||||
maxResults Int? // Maximum results requested
|
||||
|
||||
// Results and interaction
|
||||
resultCount Int @default(0) // Number of results returned
|
||||
similarityScore Float? // Similarity score (for single result access)
|
||||
|
||||
// Context and source
|
||||
context String? // Additional context
|
||||
source String? // Source of the access (e.g., "chat", "api", "integration")
|
||||
sessionId String? // Session identifier
|
||||
|
||||
// Performance metrics
|
||||
responseTimeMs Int? // Response time in milliseconds
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId String
|
||||
|
||||
workspace Workspace? @relation(fields: [workspaceId], references: [id])
|
||||
workspaceId String?
|
||||
|
||||
conversation Conversation? @relation(fields: [conversationId], references: [id])
|
||||
conversationId String?
|
||||
|
||||
// Metadata for additional tracking data
|
||||
metadata Json? @default("{}")
|
||||
}
|
||||
|
||||
model Space {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
@ -505,6 +551,7 @@ model User {
|
||||
oauthIntegrationGrants OAuthIntegrationGrant[]
|
||||
oAuthClientInstallation OAuthClientInstallation[]
|
||||
UserUsage UserUsage?
|
||||
RecallLog RecallLog[]
|
||||
}
|
||||
|
||||
model UserUsage {
|
||||
@ -579,6 +626,7 @@ model Workspace {
|
||||
OAuthAuthorizationCode OAuthAuthorizationCode[]
|
||||
OAuthAccessToken OAuthAccessToken[]
|
||||
OAuthRefreshToken OAuthRefreshToken[]
|
||||
RecallLog RecallLog[]
|
||||
}
|
||||
|
||||
enum AuthenticationMethod {
|
||||
|
||||
@ -20,6 +20,7 @@ export interface EpisodicNode {
|
||||
userId: string;
|
||||
space?: string;
|
||||
sessionId?: string;
|
||||
recallCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -52,6 +53,7 @@ export interface StatementNode {
|
||||
attributes: Record<string, any>;
|
||||
userId: string;
|
||||
space?: string;
|
||||
recallCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
276
pnpm-lock.yaml
generated
276
pnpm-lock.yaml
generated
@ -463,6 +463,9 @@ importers:
|
||||
'@trigger.dev/sdk':
|
||||
specifier: ^4.0.0-v4-beta.22
|
||||
version: 4.0.0-v4-beta.22(ai@4.3.14(react@18.3.1)(zod@3.23.8))(zod@3.23.8)
|
||||
'@types/react-calendar-heatmap':
|
||||
specifier: ^1.9.0
|
||||
version: 1.9.0
|
||||
ai:
|
||||
specifier: 4.3.14
|
||||
version: 4.3.14(react@18.3.1)(zod@3.23.8)
|
||||
@ -571,6 +574,9 @@ importers:
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1
|
||||
react-calendar-heatmap:
|
||||
specifier: ^1.10.0
|
||||
version: 1.10.0(react@18.3.1)
|
||||
react-dom:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1(react@18.3.1)
|
||||
@ -747,253 +753,6 @@ importers:
|
||||
specifier: ^4.2.1
|
||||
version: 4.3.2(typescript@5.8.3)(vite@6.3.5(@types/node@20.19.7)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.42.0)(tsx@4.17.0)(yaml@2.8.0))
|
||||
|
||||
packages/core-cli:
|
||||
dependencies:
|
||||
'@clack/prompts':
|
||||
specifier: ^0.10.0
|
||||
version: 0.10.1
|
||||
'@depot/cli':
|
||||
specifier: 0.0.1-cli.2.80.0
|
||||
version: 0.0.1-cli.2.80.0
|
||||
'@opentelemetry/api':
|
||||
specifier: 1.9.0
|
||||
version: 1.9.0
|
||||
'@opentelemetry/api-logs':
|
||||
specifier: 0.52.1
|
||||
version: 0.52.1
|
||||
'@opentelemetry/exporter-logs-otlp-http':
|
||||
specifier: 0.52.1
|
||||
version: 0.52.1(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/exporter-trace-otlp-http':
|
||||
specifier: 0.52.1
|
||||
version: 0.52.1(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/instrumentation':
|
||||
specifier: 0.52.1
|
||||
version: 0.52.1(@opentelemetry/api@1.9.0)(supports-color@10.0.0)
|
||||
'@opentelemetry/instrumentation-fetch':
|
||||
specifier: 0.52.1
|
||||
version: 0.52.1(@opentelemetry/api@1.9.0)(supports-color@10.0.0)
|
||||
'@opentelemetry/resources':
|
||||
specifier: 1.25.1
|
||||
version: 1.25.1(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/sdk-logs':
|
||||
specifier: 0.52.1
|
||||
version: 0.52.1(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/sdk-node':
|
||||
specifier: 0.52.1
|
||||
version: 0.52.1(@opentelemetry/api@1.9.0)(supports-color@10.0.0)
|
||||
'@opentelemetry/sdk-trace-base':
|
||||
specifier: 1.25.1
|
||||
version: 1.25.1(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/sdk-trace-node':
|
||||
specifier: 1.25.1
|
||||
version: 1.25.1(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/semantic-conventions':
|
||||
specifier: 1.25.1
|
||||
version: 1.25.1
|
||||
ansi-escapes:
|
||||
specifier: ^7.0.0
|
||||
version: 7.0.0
|
||||
braces:
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.3
|
||||
c12:
|
||||
specifier: ^1.11.1
|
||||
version: 1.11.2(magicast@0.3.5)
|
||||
chalk:
|
||||
specifier: ^5.2.0
|
||||
version: 5.4.1
|
||||
chokidar:
|
||||
specifier: ^3.6.0
|
||||
version: 3.6.0
|
||||
cli-table3:
|
||||
specifier: ^0.6.3
|
||||
version: 0.6.5
|
||||
commander:
|
||||
specifier: ^9.4.1
|
||||
version: 9.5.0
|
||||
defu:
|
||||
specifier: ^6.1.4
|
||||
version: 6.1.4
|
||||
dotenv:
|
||||
specifier: ^16.4.5
|
||||
version: 16.5.0
|
||||
dotenv-expand:
|
||||
specifier: ^12.0.2
|
||||
version: 12.0.2
|
||||
esbuild:
|
||||
specifier: ^0.23.0
|
||||
version: 0.23.1
|
||||
eventsource:
|
||||
specifier: ^3.0.2
|
||||
version: 3.0.7
|
||||
evt:
|
||||
specifier: ^2.4.13
|
||||
version: 2.5.9
|
||||
fast-npm-meta:
|
||||
specifier: ^0.2.2
|
||||
version: 0.2.2
|
||||
git-last-commit:
|
||||
specifier: ^1.0.1
|
||||
version: 1.0.1
|
||||
gradient-string:
|
||||
specifier: ^2.0.2
|
||||
version: 2.0.2
|
||||
has-flag:
|
||||
specifier: ^5.0.1
|
||||
version: 5.0.1
|
||||
import-in-the-middle:
|
||||
specifier: 1.11.0
|
||||
version: 1.11.0
|
||||
import-meta-resolve:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
ini:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0
|
||||
jsonc-parser:
|
||||
specifier: 3.2.1
|
||||
version: 3.2.1
|
||||
knex:
|
||||
specifier: 3.1.0
|
||||
version: 3.1.0(pg@8.16.3)(supports-color@10.0.0)
|
||||
magicast:
|
||||
specifier: ^0.3.4
|
||||
version: 0.3.5
|
||||
minimatch:
|
||||
specifier: ^10.0.1
|
||||
version: 10.0.2
|
||||
mlly:
|
||||
specifier: ^1.7.1
|
||||
version: 1.7.4
|
||||
nanoid:
|
||||
specifier: 3.3.8
|
||||
version: 3.3.8
|
||||
nypm:
|
||||
specifier: ^0.5.4
|
||||
version: 0.5.4
|
||||
object-hash:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
open:
|
||||
specifier: ^10.0.3
|
||||
version: 10.2.0
|
||||
p-limit:
|
||||
specifier: ^6.2.0
|
||||
version: 6.2.0
|
||||
p-retry:
|
||||
specifier: ^6.1.0
|
||||
version: 6.2.1
|
||||
partysocket:
|
||||
specifier: ^1.0.2
|
||||
version: 1.1.4
|
||||
pg:
|
||||
specifier: 8.16.3
|
||||
version: 8.16.3
|
||||
pkg-types:
|
||||
specifier: ^1.1.3
|
||||
version: 1.3.1
|
||||
polka:
|
||||
specifier: ^0.5.2
|
||||
version: 0.5.2
|
||||
resolve:
|
||||
specifier: ^1.22.8
|
||||
version: 1.22.10
|
||||
semver:
|
||||
specifier: ^7.5.0
|
||||
version: 7.7.2
|
||||
signal-exit:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
source-map-support:
|
||||
specifier: 0.5.21
|
||||
version: 0.5.21
|
||||
std-env:
|
||||
specifier: ^3.7.0
|
||||
version: 3.9.0
|
||||
supports-color:
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.0
|
||||
tiny-invariant:
|
||||
specifier: ^1.2.0
|
||||
version: 1.3.3
|
||||
tinyexec:
|
||||
specifier: ^0.3.1
|
||||
version: 0.3.2
|
||||
tinyglobby:
|
||||
specifier: ^0.2.10
|
||||
version: 0.2.14
|
||||
uuid:
|
||||
specifier: 11.1.0
|
||||
version: 11.1.0
|
||||
ws:
|
||||
specifier: ^8.18.0
|
||||
version: 8.18.3
|
||||
xdg-app-paths:
|
||||
specifier: ^8.3.0
|
||||
version: 8.3.0
|
||||
zod:
|
||||
specifier: 3.23.8
|
||||
version: 3.23.8
|
||||
zod-validation-error:
|
||||
specifier: ^1.5.0
|
||||
version: 1.5.0(zod@3.23.8)
|
||||
devDependencies:
|
||||
'@epic-web/test-server':
|
||||
specifier: ^0.1.0
|
||||
version: 0.1.6
|
||||
'@types/gradient-string':
|
||||
specifier: ^1.1.2
|
||||
version: 1.1.6
|
||||
'@types/ini':
|
||||
specifier: ^4.1.1
|
||||
version: 4.1.1
|
||||
'@types/object-hash':
|
||||
specifier: 3.0.6
|
||||
version: 3.0.6
|
||||
'@types/polka':
|
||||
specifier: ^0.5.7
|
||||
version: 0.5.7
|
||||
'@types/react':
|
||||
specifier: ^18.2.48
|
||||
version: 18.2.69
|
||||
'@types/resolve':
|
||||
specifier: ^1.20.6
|
||||
version: 1.20.6
|
||||
'@types/rimraf':
|
||||
specifier: ^4.0.5
|
||||
version: 4.0.5
|
||||
'@types/semver':
|
||||
specifier: ^7.5.0
|
||||
version: 7.7.0
|
||||
'@types/source-map-support':
|
||||
specifier: 0.5.10
|
||||
version: 0.5.10
|
||||
'@types/ws':
|
||||
specifier: ^8.5.3
|
||||
version: 8.18.1
|
||||
cpy-cli:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0
|
||||
execa:
|
||||
specifier: ^8.0.1
|
||||
version: 8.0.1
|
||||
find-up:
|
||||
specifier: ^7.0.0
|
||||
version: 7.0.0
|
||||
rimraf:
|
||||
specifier: ^5.0.7
|
||||
version: 5.0.10
|
||||
ts-essentials:
|
||||
specifier: 10.0.1
|
||||
version: 10.0.1(typescript@5.8.3)
|
||||
tshy:
|
||||
specifier: ^3.0.2
|
||||
version: 3.0.2
|
||||
tsx:
|
||||
specifier: 4.17.0
|
||||
version: 4.17.0
|
||||
|
||||
packages/database:
|
||||
dependencies:
|
||||
'@prisma/client':
|
||||
@ -5218,6 +4977,9 @@ packages:
|
||||
'@types/range-parser@1.2.7':
|
||||
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
|
||||
|
||||
'@types/react-calendar-heatmap@1.9.0':
|
||||
resolution: {integrity: sha512-BH8M/nsXoLGa3hxWbrq3guPwlK0cV+w1i4c/ktrTxTzN5fBths6WbeUZ4dK0+tE76qiGoVSo9Tse8WVVuMIV+w==}
|
||||
|
||||
'@types/react-dom@18.2.18':
|
||||
resolution: {integrity: sha512-TJxDm6OfAX2KJWJdMEVTwWke5Sc/E/RlnPGvGfS0W7+6ocy2xhDVQVh/KvC2Uf7kACs+gDytdusDSdWfWkaNzw==}
|
||||
|
||||
@ -8519,6 +8281,9 @@ packages:
|
||||
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
memoize-one@5.2.1:
|
||||
resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
|
||||
|
||||
memorystream@0.3.1:
|
||||
resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==}
|
||||
engines: {node: '>= 0.10.0'}
|
||||
@ -9902,6 +9667,11 @@ packages:
|
||||
rc9@2.1.2:
|
||||
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
|
||||
|
||||
react-calendar-heatmap@1.10.0:
|
||||
resolution: {integrity: sha512-e5vcrzMWzKIF710egr1FpjWyuDEFeZm39nvV25muc8Wtqqi8iDOfqREELeQ9Wouqf9hhj939gq0i+iAxo7KdSw==}
|
||||
peerDependencies:
|
||||
react: '>=0.14.0'
|
||||
|
||||
react-css-styled@1.1.9:
|
||||
resolution: {integrity: sha512-M7fJZ3IWFaIHcZEkoFOnkjdiUFmwd8d+gTh2bpqMOcnxy/0Gsykw4dsL4QBiKsxcGow6tETUa4NAUcmJF+/nfw==}
|
||||
|
||||
@ -16153,6 +15923,10 @@ snapshots:
|
||||
|
||||
'@types/range-parser@1.2.7': {}
|
||||
|
||||
'@types/react-calendar-heatmap@1.9.0':
|
||||
dependencies:
|
||||
'@types/react': 18.2.69
|
||||
|
||||
'@types/react-dom@18.2.18':
|
||||
dependencies:
|
||||
'@types/react': 18.2.69
|
||||
@ -20145,6 +19919,8 @@ snapshots:
|
||||
|
||||
media-typer@1.1.0: {}
|
||||
|
||||
memoize-one@5.2.1: {}
|
||||
|
||||
memorystream@0.3.1: {}
|
||||
|
||||
meow@12.1.1: {}
|
||||
@ -21735,6 +21511,12 @@ snapshots:
|
||||
defu: 6.1.4
|
||||
destr: 2.0.5
|
||||
|
||||
react-calendar-heatmap@1.10.0(react@18.3.1):
|
||||
dependencies:
|
||||
memoize-one: 5.2.1
|
||||
prop-types: 15.8.1
|
||||
react: 18.3.1
|
||||
|
||||
react-css-styled@1.1.9:
|
||||
dependencies:
|
||||
css-styled: 1.0.8
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user