mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-11 00:08:27 +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
|
# Nest run in docker, change host to database container name
|
||||||
DB_HOST=localhost
|
DB_HOST=localhost
|
||||||
|
|||||||
@ -62,7 +62,6 @@
|
|||||||
"clean": "rimraf dist .tshy .tshy-build .turbo",
|
"clean": "rimraf dist .tshy .tshy-build .turbo",
|
||||||
"typecheck": "tsc -p tsconfig.src.json --noEmit",
|
"typecheck": "tsc -p tsconfig.src.json --noEmit",
|
||||||
"build": "tshy",
|
"build": "tshy",
|
||||||
"dev": "tshy --watch",
|
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:e2e": "vitest --run -c ./e2e/vitest.config.ts"
|
"test:e2e": "vitest --run -c ./e2e/vitest.config.ts"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { z } from "zod";
|
|||||||
|
|
||||||
const EnvironmentSchema = z.object({
|
const EnvironmentSchema = z.object({
|
||||||
// Version
|
// Version
|
||||||
VERSION: z.string().default("0.1.13"),
|
VERSION: z.string().default("0.1.14"),
|
||||||
|
|
||||||
// Database
|
// Database
|
||||||
DB_HOST: z.string().default("localhost"),
|
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 { type GraphVisualizationProps } from "./graph-visualization";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
export function GraphVisualizationClient(props: GraphVisualizationProps) {
|
export function GraphVisualizationClient(
|
||||||
|
props: GraphClusteringVisualizationProps,
|
||||||
|
) {
|
||||||
const [Component, setComponent] = useState<any>(undefined);
|
const [Component, setComponent] = useState<any>(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
import("./graph-visualization").then(({ GraphVisualization }) => {
|
import("./graph-clustering-visualization").then(
|
||||||
setComponent(GraphVisualization);
|
({ GraphClusteringVisualization }) => {
|
||||||
});
|
setComponent(GraphClusteringVisualization);
|
||||||
|
},
|
||||||
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!Component) {
|
if (!Component) {
|
||||||
|
|||||||
@ -0,0 +1,271 @@
|
|||||||
|
import { useState, useMemo, forwardRef, useEffect } from "react";
|
||||||
|
import { useTheme } from "remix-themes";
|
||||||
|
import { GraphClustering, type GraphClusteringRef } from "./graph-clustering";
|
||||||
|
import { GraphPopovers } from "./graph-popover";
|
||||||
|
import type { RawTriplet, NodePopupContent, EdgePopupContent } from "./type";
|
||||||
|
import { Card, CardContent } from "~/components/ui/card";
|
||||||
|
|
||||||
|
import { createLabelColorMap, nodeColorPalette } from "./node-colors";
|
||||||
|
import { toGraphTriplets } from "./utils";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "../ui/select";
|
||||||
|
|
||||||
|
interface ClusterData {
|
||||||
|
uuid: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
size: number;
|
||||||
|
cohesionScore?: number;
|
||||||
|
aspectType?: "thematic" | "social" | "activity";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphClusteringVisualizationProps {
|
||||||
|
triplets: RawTriplet[];
|
||||||
|
clusters: ClusterData[];
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
zoomOnMount?: boolean;
|
||||||
|
className?: string;
|
||||||
|
selectedClusterId?: string | null;
|
||||||
|
onClusterSelect?: (clusterId: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GraphClusteringVisualization = forwardRef<
|
||||||
|
GraphClusteringRef,
|
||||||
|
GraphClusteringVisualizationProps
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
triplets,
|
||||||
|
clusters,
|
||||||
|
width = window.innerWidth * 0.85,
|
||||||
|
height = window.innerHeight * 0.85,
|
||||||
|
zoomOnMount = true,
|
||||||
|
className = "rounded-md h-full overflow-hidden relative",
|
||||||
|
selectedClusterId,
|
||||||
|
onClusterSelect,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const [themeMode] = useTheme();
|
||||||
|
|
||||||
|
// Graph state for popovers
|
||||||
|
const [showNodePopup, setShowNodePopup] = useState<boolean>(false);
|
||||||
|
const [showEdgePopup, setShowEdgePopup] = useState<boolean>(false);
|
||||||
|
const [nodePopupContent, setNodePopupContent] =
|
||||||
|
useState<NodePopupContent | null>(null);
|
||||||
|
const [edgePopupContent, setEdgePopupContent] =
|
||||||
|
useState<EdgePopupContent | null>(null);
|
||||||
|
|
||||||
|
// Filter triplets based on selected cluster (like Marvel's comic filter)
|
||||||
|
const filteredTriplets = useMemo(() => {
|
||||||
|
if (!selectedClusterId) return triplets;
|
||||||
|
|
||||||
|
// Filter triplets to show only nodes from the selected cluster
|
||||||
|
return triplets.filter(
|
||||||
|
(triplet) =>
|
||||||
|
triplet.sourceNode.attributes?.clusterId === selectedClusterId ||
|
||||||
|
triplet.targetNode.attributes?.clusterId === selectedClusterId,
|
||||||
|
);
|
||||||
|
}, [triplets, selectedClusterId]);
|
||||||
|
|
||||||
|
// Convert filtered triplets to graph triplets
|
||||||
|
const graphTriplets = useMemo(
|
||||||
|
() => toGraphTriplets(filteredTriplets),
|
||||||
|
[filteredTriplets],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extract all unique labels from triplets
|
||||||
|
const allLabels = useMemo(() => {
|
||||||
|
const labels = new Set<string>();
|
||||||
|
labels.add("Entity"); // Always include Entity as default
|
||||||
|
|
||||||
|
graphTriplets.forEach((triplet) => {
|
||||||
|
if (triplet.source.primaryLabel)
|
||||||
|
labels.add(triplet.source.primaryLabel);
|
||||||
|
if (triplet.target.primaryLabel)
|
||||||
|
labels.add(triplet.target.primaryLabel);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(labels).sort((a, b) => {
|
||||||
|
// Always put "Entity" first
|
||||||
|
if (a === "Entity") return -1;
|
||||||
|
if (b === "Entity") return 1;
|
||||||
|
// Sort others alphabetically
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
}, [graphTriplets]);
|
||||||
|
|
||||||
|
// Create a shared label color map
|
||||||
|
const sharedLabelColorMap = useMemo(() => {
|
||||||
|
return createLabelColorMap(allLabels);
|
||||||
|
}, [allLabels]);
|
||||||
|
|
||||||
|
// Handle node click
|
||||||
|
const handleNodeClick = (nodeId: string) => {
|
||||||
|
// Find the triplet that contains this node by searching through graphTriplets
|
||||||
|
let foundNode = null;
|
||||||
|
for (const triplet of filteredTriplets) {
|
||||||
|
if (triplet.sourceNode.uuid === nodeId) {
|
||||||
|
foundNode = triplet.sourceNode;
|
||||||
|
break;
|
||||||
|
} else if (triplet.targetNode.uuid === nodeId) {
|
||||||
|
foundNode = triplet.targetNode;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundNode) {
|
||||||
|
// Try to find in the converted graph triplets
|
||||||
|
for (const graphTriplet of graphTriplets) {
|
||||||
|
if (graphTriplet.source.id === nodeId) {
|
||||||
|
foundNode = {
|
||||||
|
uuid: graphTriplet.source.id,
|
||||||
|
value: graphTriplet.source.value,
|
||||||
|
primaryLabel: graphTriplet.source.primaryLabel,
|
||||||
|
attributes: graphTriplet.source,
|
||||||
|
} as any;
|
||||||
|
break;
|
||||||
|
} else if (graphTriplet.target.id === nodeId) {
|
||||||
|
foundNode = {
|
||||||
|
uuid: graphTriplet.target.id,
|
||||||
|
value: graphTriplet.target.value,
|
||||||
|
primaryLabel: graphTriplet.target.primaryLabel,
|
||||||
|
attributes: graphTriplet.target,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundNode) return;
|
||||||
|
|
||||||
|
// Set popup content and show the popup
|
||||||
|
setNodePopupContent({
|
||||||
|
id: nodeId,
|
||||||
|
node: foundNode,
|
||||||
|
});
|
||||||
|
setShowNodePopup(true);
|
||||||
|
setShowEdgePopup(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle edge click
|
||||||
|
const handleEdgeClick = (edgeId: string) => {
|
||||||
|
// Find the triplet that contains this edge
|
||||||
|
const triplet = triplets.find((t) => t.edge.uuid === edgeId);
|
||||||
|
|
||||||
|
if (!triplet) return;
|
||||||
|
|
||||||
|
// Set popup content and show the popup
|
||||||
|
setEdgePopupContent({
|
||||||
|
id: edgeId,
|
||||||
|
source: triplet.sourceNode,
|
||||||
|
target: triplet.targetNode,
|
||||||
|
relation: triplet.edge,
|
||||||
|
});
|
||||||
|
setShowEdgePopup(true);
|
||||||
|
setShowNodePopup(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle cluster click - toggle filter like Marvel
|
||||||
|
const handleClusterClick = (clusterId: string) => {
|
||||||
|
if (onClusterSelect) {
|
||||||
|
const newSelection = selectedClusterId === clusterId ? null : clusterId;
|
||||||
|
onClusterSelect(newSelection);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle popover close
|
||||||
|
const handlePopoverClose = () => {
|
||||||
|
setShowNodePopup(false);
|
||||||
|
setShowEdgePopup(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col gap-4", className)}>
|
||||||
|
{/* Cluster Filter Dropdown - Marvel style */}
|
||||||
|
<div>
|
||||||
|
<Card className="bg-transparent">
|
||||||
|
<CardContent className="bg-transparent p-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
value={selectedClusterId || ""}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
value === "all_clusters"
|
||||||
|
? onClusterSelect?.("")
|
||||||
|
: onClusterSelect?.(value || null)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className="bg-background w-48 rounded px-2 py-1 text-sm"
|
||||||
|
showIcon
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="All Clusters" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all_clusters">All Clusters</SelectItem>
|
||||||
|
{clusters.map((cluster, index) => {
|
||||||
|
// Get cluster color from the same palette used in the graph
|
||||||
|
const palette = themeMode === "dark" ? nodeColorPalette.dark : nodeColorPalette.light;
|
||||||
|
const clusterColor = palette[index % palette.length];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectItem key={cluster.uuid} value={cluster.uuid}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: clusterColor }}
|
||||||
|
/>
|
||||||
|
<span>{cluster.name}</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredTriplets.length > 0 ? (
|
||||||
|
<GraphClustering
|
||||||
|
ref={ref}
|
||||||
|
triplets={graphTriplets}
|
||||||
|
clusters={clusters}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
onNodeClick={handleNodeClick}
|
||||||
|
onEdgeClick={handleEdgeClick}
|
||||||
|
onClusterClick={handleClusterClick}
|
||||||
|
onBlur={handlePopoverClose}
|
||||||
|
zoomOnMount={zoomOnMount}
|
||||||
|
labelColorMap={sharedLabelColorMap}
|
||||||
|
showClusterLabels={!selectedClusterId} // Show cluster labels when not filtering
|
||||||
|
enableClusterColors={true} // Always enable cluster colors
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<p className="text-muted-foreground">No graph data to visualize.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Standard Graph Popovers */}
|
||||||
|
<GraphPopovers
|
||||||
|
showNodePopup={showNodePopup}
|
||||||
|
showEdgePopup={showEdgePopup}
|
||||||
|
nodePopupContent={nodePopupContent}
|
||||||
|
edgePopupContent={edgePopupContent}
|
||||||
|
onOpenChange={handlePopoverClose}
|
||||||
|
labelColorMap={sharedLabelColorMap}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
851
apps/webapp/app/components/graph/graph-clustering.tsx
Normal file
851
apps/webapp/app/components/graph/graph-clustering.tsx
Normal file
@ -0,0 +1,851 @@
|
|||||||
|
import {
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useMemo,
|
||||||
|
useCallback,
|
||||||
|
useImperativeHandle,
|
||||||
|
forwardRef,
|
||||||
|
} from "react";
|
||||||
|
import Sigma from "sigma";
|
||||||
|
import GraphologyGraph from "graphology";
|
||||||
|
import forceAtlas2 from "graphology-layout-forceatlas2";
|
||||||
|
import FA2Layout from "graphology-layout-forceatlas2/worker";
|
||||||
|
import { EdgeLineProgram } from "sigma/rendering";
|
||||||
|
import colors from "tailwindcss/colors";
|
||||||
|
import type { GraphTriplet, IdValue, GraphNode } from "./type";
|
||||||
|
import {
|
||||||
|
createLabelColorMap,
|
||||||
|
getNodeColor as getNodeColorByLabel,
|
||||||
|
nodeColorPalette,
|
||||||
|
} from "./node-colors";
|
||||||
|
import { useTheme } from "remix-themes";
|
||||||
|
import { drawHover } from "./utils";
|
||||||
|
|
||||||
|
interface ClusterData {
|
||||||
|
uuid: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
size: number;
|
||||||
|
cohesionScore?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphClusteringProps {
|
||||||
|
triplets: GraphTriplet[];
|
||||||
|
clusters: ClusterData[];
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
zoomOnMount?: boolean;
|
||||||
|
onNodeClick?: (nodeId: string) => void;
|
||||||
|
onEdgeClick?: (edgeId: string) => void;
|
||||||
|
onClusterClick?: (clusterId: string) => void;
|
||||||
|
onBlur?: () => void;
|
||||||
|
labelColorMap?: Map<string, number>;
|
||||||
|
showClusterLabels?: boolean;
|
||||||
|
enableClusterColors?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphClusteringRef {
|
||||||
|
zoomToLinkById: (linkId: string) => void;
|
||||||
|
zoomToCluster: (clusterId: string) => void;
|
||||||
|
highlightCluster: (clusterId: string) => void;
|
||||||
|
resetHighlights: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use node-colors palette for cluster colors
|
||||||
|
const generateClusterColors = (
|
||||||
|
clusterCount: number,
|
||||||
|
isDarkMode: boolean,
|
||||||
|
): string[] => {
|
||||||
|
const palette = isDarkMode ? nodeColorPalette.dark : nodeColorPalette.light;
|
||||||
|
const colors: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < clusterCount; i++) {
|
||||||
|
colors.push(palette[i % palette.length]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return colors;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GraphClustering = forwardRef<
|
||||||
|
GraphClusteringRef,
|
||||||
|
GraphClusteringProps
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
triplets,
|
||||||
|
clusters,
|
||||||
|
width = 1000,
|
||||||
|
height = 800,
|
||||||
|
zoomOnMount = false,
|
||||||
|
onNodeClick,
|
||||||
|
onEdgeClick,
|
||||||
|
onClusterClick,
|
||||||
|
onBlur,
|
||||||
|
labelColorMap: externalLabelColorMap,
|
||||||
|
showClusterLabels = true,
|
||||||
|
enableClusterColors = true,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const sigmaRef = useRef<Sigma | null>(null);
|
||||||
|
const graphRef = useRef<GraphologyGraph | null>(null);
|
||||||
|
const clustersLayerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [themeMode] = useTheme();
|
||||||
|
|
||||||
|
const isInitializedRef = useRef(false);
|
||||||
|
const selectedNodeRef = useRef<string | null>(null);
|
||||||
|
const selectedEdgeRef = useRef<string | null>(null);
|
||||||
|
const selectedClusterRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// Create cluster color mapping
|
||||||
|
const clusterColorMap = useMemo(() => {
|
||||||
|
if (!enableClusterColors) return new Map();
|
||||||
|
|
||||||
|
const clusterIds = clusters.map((c) => c.uuid);
|
||||||
|
const clusterColors = generateClusterColors(
|
||||||
|
clusterIds.length,
|
||||||
|
themeMode === "dark",
|
||||||
|
);
|
||||||
|
const colorMap = new Map<string, string>();
|
||||||
|
|
||||||
|
clusterIds.forEach((id, index) => {
|
||||||
|
colorMap.set(id, clusterColors[index]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return colorMap;
|
||||||
|
}, [clusters, enableClusterColors, themeMode]);
|
||||||
|
|
||||||
|
// Memoize theme to prevent unnecessary recreation
|
||||||
|
const theme = useMemo(
|
||||||
|
() => ({
|
||||||
|
node: {
|
||||||
|
fill: colors.pink[500],
|
||||||
|
stroke: themeMode === "dark" ? colors.slate[100] : colors.slate[900],
|
||||||
|
hover: "#646464",
|
||||||
|
text: themeMode === "dark" ? colors.slate[100] : colors.slate[900],
|
||||||
|
selected: "#646464",
|
||||||
|
dimmed: colors.pink[300],
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
stroke: colors.gray[400],
|
||||||
|
selected: "#646464",
|
||||||
|
dimmed: themeMode === "dark" ? colors.slate[800] : colors.slate[200],
|
||||||
|
},
|
||||||
|
cluster: {
|
||||||
|
labelColor:
|
||||||
|
themeMode === "dark" ? colors.slate[100] : colors.slate[900],
|
||||||
|
labelBg:
|
||||||
|
themeMode === "dark"
|
||||||
|
? colors.slate[800] + "CC"
|
||||||
|
: colors.slate[200] + "CC",
|
||||||
|
},
|
||||||
|
background:
|
||||||
|
themeMode === "dark" ? colors.slate[900] : colors.slate[100],
|
||||||
|
}),
|
||||||
|
[themeMode],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extract all unique labels from triplets
|
||||||
|
const allLabels = useMemo(() => {
|
||||||
|
if (externalLabelColorMap) return [];
|
||||||
|
const labels = new Set<string>();
|
||||||
|
labels.add("Entity");
|
||||||
|
triplets.forEach((triplet) => {
|
||||||
|
if (triplet.source.primaryLabel)
|
||||||
|
labels.add(triplet.source.primaryLabel);
|
||||||
|
if (triplet.target.primaryLabel)
|
||||||
|
labels.add(triplet.target.primaryLabel);
|
||||||
|
});
|
||||||
|
return Array.from(labels);
|
||||||
|
}, [triplets, externalLabelColorMap]);
|
||||||
|
|
||||||
|
// Create a mapping of label to color
|
||||||
|
const labelColorMap = useMemo(() => {
|
||||||
|
return externalLabelColorMap || createLabelColorMap(allLabels);
|
||||||
|
}, [allLabels, externalLabelColorMap]);
|
||||||
|
|
||||||
|
// Create a mapping of node IDs to their data
|
||||||
|
const nodeDataMap = useMemo(() => {
|
||||||
|
const result = new Map<string, GraphNode>();
|
||||||
|
triplets.forEach((triplet) => {
|
||||||
|
result.set(triplet.source.id, triplet.source);
|
||||||
|
result.set(triplet.target.id, triplet.target);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}, [triplets]);
|
||||||
|
|
||||||
|
// Function to get node color (with cluster coloring support)
|
||||||
|
const getNodeColor = useCallback(
|
||||||
|
(node: any): string => {
|
||||||
|
if (!node) {
|
||||||
|
return getNodeColorByLabel(null, themeMode === "dark", labelColorMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeData = nodeDataMap.get(node.id) || node;
|
||||||
|
|
||||||
|
// Check if this is a Statement node
|
||||||
|
const isStatementNode =
|
||||||
|
nodeData.attributes.nodeType === "Statement" ||
|
||||||
|
(nodeData.labels && nodeData.labels.includes("Statement"));
|
||||||
|
|
||||||
|
if (isStatementNode) {
|
||||||
|
// Statement nodes with cluster IDs use cluster colors
|
||||||
|
if (
|
||||||
|
enableClusterColors &&
|
||||||
|
nodeData.clusterId &&
|
||||||
|
clusterColorMap.has(nodeData.clusterId)
|
||||||
|
) {
|
||||||
|
return clusterColorMap.get(nodeData.clusterId)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unclustered statement nodes use a specific light color
|
||||||
|
return themeMode === "dark" ? "#2b9684" : "#54935b"; // Teal/Green from palette
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entity nodes use light gray
|
||||||
|
return themeMode === "dark" ? "#6B7280" : "#9CA3AF"; // Tailwind gray-500/gray-400
|
||||||
|
},
|
||||||
|
[
|
||||||
|
labelColorMap,
|
||||||
|
nodeDataMap,
|
||||||
|
themeMode,
|
||||||
|
enableClusterColors,
|
||||||
|
clusterColorMap,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Process graph data for Sigma
|
||||||
|
const { nodes, edges } = useMemo(() => {
|
||||||
|
const nodeMap = new Map<string, any>();
|
||||||
|
triplets.forEach((triplet) => {
|
||||||
|
if (!nodeMap.has(triplet.source.id)) {
|
||||||
|
const nodeColor = getNodeColor(triplet.source);
|
||||||
|
const isStatementNode =
|
||||||
|
triplet.source.attributes?.nodeType === "Statement" ||
|
||||||
|
(triplet.source.labels &&
|
||||||
|
triplet.source.labels.includes("Statement"));
|
||||||
|
|
||||||
|
nodeMap.set(triplet.source.id, {
|
||||||
|
id: triplet.source.id,
|
||||||
|
label: triplet.source.value
|
||||||
|
? triplet.source.value.split(/\s+/).slice(0, 4).join(" ") +
|
||||||
|
(triplet.source.value.split(/\s+/).length > 4 ? " ..." : "")
|
||||||
|
: "",
|
||||||
|
size: isStatementNode ? 4 : 2, // Statement nodes slightly larger
|
||||||
|
color: nodeColor,
|
||||||
|
x: width,
|
||||||
|
y: height,
|
||||||
|
nodeData: triplet.source,
|
||||||
|
clusterId: triplet.source.clusterId,
|
||||||
|
// Enhanced border for visual appeal, thicker for Statement nodes
|
||||||
|
borderSize: 1,
|
||||||
|
borderColor: nodeColor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!nodeMap.has(triplet.target.id)) {
|
||||||
|
const nodeColor = getNodeColor(triplet.target);
|
||||||
|
const isStatementNode =
|
||||||
|
triplet.target.attributes?.nodeType === "Statement" ||
|
||||||
|
(triplet.target.labels &&
|
||||||
|
triplet.target.labels.includes("Statement"));
|
||||||
|
|
||||||
|
nodeMap.set(triplet.target.id, {
|
||||||
|
id: triplet.target.id,
|
||||||
|
label: triplet.target.value
|
||||||
|
? triplet.target.value.split(/\s+/).slice(0, 4).join(" ") +
|
||||||
|
(triplet.target.value.split(/\s+/).length > 4 ? " ..." : "")
|
||||||
|
: "",
|
||||||
|
size: isStatementNode ? 4 : 2, // Statement nodes slightly larger
|
||||||
|
color: nodeColor,
|
||||||
|
x: width,
|
||||||
|
y: height,
|
||||||
|
nodeData: triplet.target,
|
||||||
|
clusterId: triplet.target.clusterId,
|
||||||
|
// Enhanced border for visual appeal, thicker for Statement nodes
|
||||||
|
borderSize: 1,
|
||||||
|
borderColor: nodeColor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const linkGroups = triplets.reduce(
|
||||||
|
(groups, triplet) => {
|
||||||
|
if (triplet.relation.type === "_isolated_node_") {
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
let key = `${triplet.source.id}-${triplet.target.id}`;
|
||||||
|
const reverseKey = `${triplet.target.id}-${triplet.source.id}`;
|
||||||
|
if (groups[reverseKey]) {
|
||||||
|
key = reverseKey;
|
||||||
|
}
|
||||||
|
if (!groups[key]) {
|
||||||
|
groups[key] = {
|
||||||
|
id: key,
|
||||||
|
source: triplet.source.id,
|
||||||
|
target: triplet.target.id,
|
||||||
|
relations: [],
|
||||||
|
relationData: [],
|
||||||
|
label: "",
|
||||||
|
color: "#0000001A",
|
||||||
|
labelColor: "#0000001A",
|
||||||
|
size: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
groups[key].relations.push(triplet.relation.value);
|
||||||
|
groups[key].relationData.push(triplet.relation);
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
},
|
||||||
|
{} as Record<string, any>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: Array.from(nodeMap.values()),
|
||||||
|
edges: Object.values(linkGroups),
|
||||||
|
};
|
||||||
|
}, [triplets, getNodeColor, width, height]);
|
||||||
|
|
||||||
|
// Helper function to reset highlights without affecting camera
|
||||||
|
const resetHighlights = useCallback(() => {
|
||||||
|
if (!graphRef.current || !sigmaRef.current) return;
|
||||||
|
const graph = graphRef.current;
|
||||||
|
const sigma = sigmaRef.current;
|
||||||
|
|
||||||
|
// Store camera state before making changes
|
||||||
|
const camera = sigma.getCamera();
|
||||||
|
const currentState = camera.getState();
|
||||||
|
|
||||||
|
graph.forEachNode((node) => {
|
||||||
|
const nodeData = graph.getNodeAttribute(node, "nodeData");
|
||||||
|
const originalColor = getNodeColor(nodeData);
|
||||||
|
const isStatementNode =
|
||||||
|
nodeData?.attributes.nodeType === "Statement" ||
|
||||||
|
(nodeData?.labels && nodeData.labels.includes("Statement"));
|
||||||
|
|
||||||
|
graph.setNodeAttribute(node, "highlighted", false);
|
||||||
|
graph.setNodeAttribute(node, "color", originalColor);
|
||||||
|
graph.setNodeAttribute(node, "size", isStatementNode ? 4 : 2);
|
||||||
|
graph.setNodeAttribute(node, "zIndex", 1);
|
||||||
|
});
|
||||||
|
graph.forEachEdge((edge) => {
|
||||||
|
graph.setEdgeAttribute(edge, "highlighted", false);
|
||||||
|
graph.setEdgeAttribute(edge, "color", "#0000001A");
|
||||||
|
graph.setEdgeAttribute(edge, "size", 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore camera state to prevent unwanted movements
|
||||||
|
camera.setState(currentState);
|
||||||
|
|
||||||
|
selectedNodeRef.current = null;
|
||||||
|
selectedEdgeRef.current = null;
|
||||||
|
selectedClusterRef.current = null;
|
||||||
|
}, [getNodeColor]);
|
||||||
|
|
||||||
|
// Highlight entire cluster
|
||||||
|
const highlightCluster = useCallback(
|
||||||
|
(clusterId: string) => {
|
||||||
|
if (!graphRef.current || !sigmaRef.current) return;
|
||||||
|
|
||||||
|
const graph = graphRef.current;
|
||||||
|
const sigma = sigmaRef.current;
|
||||||
|
|
||||||
|
resetHighlights();
|
||||||
|
selectedClusterRef.current = clusterId;
|
||||||
|
|
||||||
|
const clusterNodes: string[] = [];
|
||||||
|
const clusterColor =
|
||||||
|
clusterColorMap.get(clusterId) || theme.node.selected;
|
||||||
|
|
||||||
|
// Find all nodes in the cluster
|
||||||
|
graph.forEachNode((nodeId, attributes) => {
|
||||||
|
if (attributes.clusterId === clusterId) {
|
||||||
|
clusterNodes.push(nodeId);
|
||||||
|
graph.setNodeAttribute(nodeId, "highlighted", true);
|
||||||
|
graph.setNodeAttribute(nodeId, "color", clusterColor);
|
||||||
|
graph.setNodeAttribute(nodeId, "size", attributes.size * 1.75);
|
||||||
|
graph.setNodeAttribute(nodeId, "zIndex", 2);
|
||||||
|
} else {
|
||||||
|
// Dim other nodes
|
||||||
|
graph.setNodeAttribute(nodeId, "color", theme.node.dimmed);
|
||||||
|
graph.setNodeAttribute(nodeId, "size", attributes.size * 0.7);
|
||||||
|
graph.setNodeAttribute(nodeId, "zIndex", 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Highlight edges within the cluster
|
||||||
|
graph.forEachEdge((edgeId, attributes, source, target) => {
|
||||||
|
const sourceInCluster = clusterNodes.includes(source);
|
||||||
|
const targetInCluster = clusterNodes.includes(target);
|
||||||
|
|
||||||
|
if (sourceInCluster && targetInCluster) {
|
||||||
|
graph.setEdgeAttribute(edgeId, "highlighted", true);
|
||||||
|
graph.setEdgeAttribute(edgeId, "color", clusterColor);
|
||||||
|
graph.setEdgeAttribute(edgeId, "size", 3);
|
||||||
|
} else {
|
||||||
|
graph.setEdgeAttribute(edgeId, "color", theme.link.dimmed);
|
||||||
|
graph.setEdgeAttribute(edgeId, "size", 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[graphRef, sigmaRef, clusterColorMap, theme, resetHighlights],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Zoom to cluster
|
||||||
|
const zoomToCluster = useCallback(
|
||||||
|
(clusterId: string) => {
|
||||||
|
if (!graphRef.current || !sigmaRef.current) return;
|
||||||
|
|
||||||
|
const graph = graphRef.current;
|
||||||
|
const sigma = sigmaRef.current;
|
||||||
|
const clusterNodes: string[] = [];
|
||||||
|
|
||||||
|
// Find all nodes in the cluster
|
||||||
|
graph.forEachNode((nodeId, attributes) => {
|
||||||
|
if (attributes.clusterId === clusterId) {
|
||||||
|
clusterNodes.push(nodeId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (clusterNodes.length === 0) return;
|
||||||
|
|
||||||
|
// Calculate bounding box of cluster nodes
|
||||||
|
let minX = Infinity,
|
||||||
|
maxX = -Infinity;
|
||||||
|
let minY = Infinity,
|
||||||
|
maxY = -Infinity;
|
||||||
|
|
||||||
|
clusterNodes.forEach((nodeId) => {
|
||||||
|
const pos = sigma.getNodeDisplayData(nodeId);
|
||||||
|
if (pos) {
|
||||||
|
minX = Math.min(minX, pos.x);
|
||||||
|
maxX = Math.max(maxX, pos.x);
|
||||||
|
minY = Math.min(minY, pos.y);
|
||||||
|
maxY = Math.max(maxY, pos.y);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate center and zoom level
|
||||||
|
const centerX = (minX + maxX) / 2;
|
||||||
|
const centerY = (minY + maxY) / 2;
|
||||||
|
const containerRect = containerRef.current?.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (containerRect) {
|
||||||
|
const padding = 100;
|
||||||
|
const clusterWidth = maxX - minX + padding;
|
||||||
|
const clusterHeight = maxY - minY + padding;
|
||||||
|
const ratio = Math.min(
|
||||||
|
containerRect.width / clusterWidth,
|
||||||
|
containerRect.height / clusterHeight,
|
||||||
|
2.0, // Maximum zoom
|
||||||
|
);
|
||||||
|
|
||||||
|
sigma
|
||||||
|
.getCamera()
|
||||||
|
.animate({ x: centerX, y: centerY, ratio }, { duration: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
highlightCluster(clusterId);
|
||||||
|
},
|
||||||
|
[highlightCluster],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Expose methods via ref
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
zoomToLinkById: (linkId: string) => {
|
||||||
|
// Implementation similar to original graph component
|
||||||
|
if (!sigmaRef.current || !graphRef.current) return;
|
||||||
|
// ... existing zoomToLinkById logic
|
||||||
|
},
|
||||||
|
zoomToCluster,
|
||||||
|
highlightCluster,
|
||||||
|
resetHighlights,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Calculate optimal ForceAtlas2 parameters based on graph properties
|
||||||
|
const calculateOptimalParameters = useCallback((graph: GraphologyGraph) => {
|
||||||
|
const nodeCount = graph.order;
|
||||||
|
const edgeCount = graph.size;
|
||||||
|
|
||||||
|
if (nodeCount === 0)
|
||||||
|
return { scalingRatio: 30, gravity: 5, iterations: 600 };
|
||||||
|
|
||||||
|
// Similar logic to original implementation
|
||||||
|
const maxPossibleEdges = (nodeCount * (nodeCount - 1)) / 2;
|
||||||
|
const density = maxPossibleEdges > 0 ? edgeCount / maxPossibleEdges : 0;
|
||||||
|
|
||||||
|
let scalingRatio: number;
|
||||||
|
if (nodeCount < 10) {
|
||||||
|
scalingRatio = 15;
|
||||||
|
} else if (nodeCount < 50) {
|
||||||
|
scalingRatio = 20 + (nodeCount - 10) * 0.5;
|
||||||
|
} else if (nodeCount < 200) {
|
||||||
|
scalingRatio = 40 + (nodeCount - 50) * 0.2;
|
||||||
|
} else {
|
||||||
|
scalingRatio = Math.min(80, 70 + (nodeCount - 200) * 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
let gravity: number;
|
||||||
|
if (density > 0.3) {
|
||||||
|
gravity = 1 + density * 2;
|
||||||
|
} else if (density > 0.1) {
|
||||||
|
gravity = 3 + density * 5;
|
||||||
|
} else {
|
||||||
|
gravity = Math.min(8, 5 + (1 - density) * 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodeCount < 20) {
|
||||||
|
gravity *= 1.5;
|
||||||
|
} else if (nodeCount > 100) {
|
||||||
|
gravity *= 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
const complexity = nodeCount + edgeCount;
|
||||||
|
let durationSeconds: number;
|
||||||
|
if (complexity < 50) {
|
||||||
|
durationSeconds = 1.5;
|
||||||
|
} else if (complexity < 200) {
|
||||||
|
durationSeconds = 2.5;
|
||||||
|
} else if (complexity < 500) {
|
||||||
|
durationSeconds = 3.5;
|
||||||
|
} else {
|
||||||
|
durationSeconds = Math.min(6, 4 + (complexity - 500) * 0.004);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
scalingRatio: Math.round(scalingRatio * 10) / 10,
|
||||||
|
gravity: Math.round(gravity * 10) / 10,
|
||||||
|
duration: Math.round(durationSeconds * 100) / 100, // in seconds
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isInitializedRef.current || !containerRef.current) return;
|
||||||
|
isInitializedRef.current = true;
|
||||||
|
|
||||||
|
// Create graphology graph
|
||||||
|
const graph = new GraphologyGraph();
|
||||||
|
graphRef.current = graph;
|
||||||
|
|
||||||
|
// Add nodes
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
graph.addNode(node.id, node);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add edges
|
||||||
|
edges.forEach((edge) => {
|
||||||
|
if (graph.hasNode(edge.source) && graph.hasNode(edge.target)) {
|
||||||
|
graph.addEdge(edge.source, edge.target, { ...edge });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// No virtual edges - let the natural graph structure determine layout
|
||||||
|
|
||||||
|
// Apply layout
|
||||||
|
if (graph.order > 0) {
|
||||||
|
// Strong cluster-based positioning for Statement nodes only
|
||||||
|
const clusterNodeMap = new Map<string, string[]>();
|
||||||
|
const entityNodes: string[] = [];
|
||||||
|
|
||||||
|
// Group Statement nodes by their cluster ID, separate Entity nodes
|
||||||
|
graph.forEachNode((nodeId, attributes) => {
|
||||||
|
const isStatementNode =
|
||||||
|
attributes.nodeData?.nodeType === "Statement" ||
|
||||||
|
(attributes.nodeData?.labels &&
|
||||||
|
attributes.nodeData.labels.includes("Statement"));
|
||||||
|
|
||||||
|
if (isStatementNode && attributes.clusterId) {
|
||||||
|
// Statement nodes with cluster IDs go into clusters
|
||||||
|
if (!clusterNodeMap.has(attributes.clusterId)) {
|
||||||
|
clusterNodeMap.set(attributes.clusterId, []);
|
||||||
|
}
|
||||||
|
clusterNodeMap.get(attributes.clusterId)!.push(nodeId);
|
||||||
|
} else {
|
||||||
|
// Entity nodes (or unclustered nodes) positioned separately
|
||||||
|
entityNodes.push(nodeId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const clusterIds = Array.from(clusterNodeMap.keys());
|
||||||
|
|
||||||
|
if (clusterIds.length > 0) {
|
||||||
|
// Use a more aggressive clustering approach - create distinct regions
|
||||||
|
const padding = Math.min(width, height) * 0.1; // 10% padding
|
||||||
|
const availableWidth = width - 2 * padding;
|
||||||
|
const availableHeight = height - 2 * padding;
|
||||||
|
|
||||||
|
// Calculate optimal grid layout
|
||||||
|
const cols = Math.ceil(Math.sqrt(clusterIds.length));
|
||||||
|
const rows = Math.ceil(clusterIds.length / cols);
|
||||||
|
const cellWidth = availableWidth / cols;
|
||||||
|
const cellHeight = availableHeight / rows;
|
||||||
|
|
||||||
|
clusterIds.forEach((clusterId, index) => {
|
||||||
|
const col = index % cols;
|
||||||
|
const row = Math.floor(index / cols);
|
||||||
|
|
||||||
|
// Calculate cluster region with more separation
|
||||||
|
const regionLeft = padding + col * cellWidth;
|
||||||
|
const regionTop = padding + row * cellHeight;
|
||||||
|
const regionCenterX = regionLeft + cellWidth / 2;
|
||||||
|
const regionCenterY = regionTop + cellHeight / 2;
|
||||||
|
|
||||||
|
// Get nodes in this cluster
|
||||||
|
const nodesInCluster = clusterNodeMap.get(clusterId)!;
|
||||||
|
const clusterSize = nodesInCluster.length;
|
||||||
|
|
||||||
|
// Create cluster radius with Marvel-style spacing - more generous
|
||||||
|
const maxRadius = Math.min(cellWidth, cellHeight) * 0.35;
|
||||||
|
const baseSpacing = 150; // Larger base spacing between nodes
|
||||||
|
const clusterRadius = Math.max(
|
||||||
|
baseSpacing,
|
||||||
|
Math.min(maxRadius, Math.sqrt(clusterSize) * baseSpacing * 1.2),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (clusterSize === 1) {
|
||||||
|
// Single node at region center
|
||||||
|
graph.setNodeAttribute(nodesInCluster[0], "x", regionCenterX);
|
||||||
|
graph.setNodeAttribute(nodesInCluster[0], "y", regionCenterY);
|
||||||
|
} else if (clusterSize <= 6) {
|
||||||
|
// Small clusters - tight circle
|
||||||
|
nodesInCluster.forEach((nodeId, nodeIndex) => {
|
||||||
|
const angle = (nodeIndex / clusterSize) * 2 * Math.PI;
|
||||||
|
const x = regionCenterX + Math.cos(angle) * clusterRadius;
|
||||||
|
const y = regionCenterY + Math.sin(angle) * clusterRadius;
|
||||||
|
graph.setNodeAttribute(nodeId, "x", x);
|
||||||
|
graph.setNodeAttribute(nodeId, "y", y);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Larger clusters - dense spiral pattern
|
||||||
|
nodesInCluster.forEach((nodeId, nodeIndex) => {
|
||||||
|
const spiralTurns = Math.ceil(clusterSize / 8);
|
||||||
|
const angle =
|
||||||
|
(nodeIndex / clusterSize) * 2 * Math.PI * spiralTurns;
|
||||||
|
const radius = (nodeIndex / clusterSize) * clusterRadius;
|
||||||
|
const x = regionCenterX + Math.cos(angle) * radius;
|
||||||
|
const y = regionCenterY + Math.sin(angle) * radius;
|
||||||
|
graph.setNodeAttribute(nodeId, "x", x);
|
||||||
|
graph.setNodeAttribute(nodeId, "y", y);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position Entity nodes using ForceAtlas2 natural positioning
|
||||||
|
// They will be positioned by the algorithm based on their connections to Statement nodes
|
||||||
|
entityNodes.forEach((nodeId) => {
|
||||||
|
// Give them initial random positions, ForceAtlas2 will adjust based on connections
|
||||||
|
graph.setNodeAttribute(nodeId, "x", Math.random() * width);
|
||||||
|
graph.setNodeAttribute(nodeId, "y", Math.random() * height);
|
||||||
|
});
|
||||||
|
|
||||||
|
const optimalParams = calculateOptimalParameters(graph);
|
||||||
|
const settings = forceAtlas2.inferSettings(graph);
|
||||||
|
|
||||||
|
console.log(optimalParams);
|
||||||
|
const layout = new FA2Layout(graph, {
|
||||||
|
settings: {
|
||||||
|
...settings,
|
||||||
|
barnesHutOptimize: true,
|
||||||
|
strongGravityMode: false, // Marvel doesn't use strong gravity
|
||||||
|
gravity: Math.max(0.1, optimalParams.gravity * 0.005), // Much weaker gravity like Marvel
|
||||||
|
scalingRatio: optimalParams.scalingRatio * 10, // Higher scaling for more spacing
|
||||||
|
slowDown: 20, // Much slower to preserve cluster positions
|
||||||
|
outboundAttractionDistribution: false, // Use standard distribution
|
||||||
|
linLogMode: false, // Linear mode
|
||||||
|
edgeWeightInfluence: 0, // Disable edge weight influence to maintain positioning
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
layout.start();
|
||||||
|
setTimeout(() => layout.stop(), (optimalParams.duration ?? 2) * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Sigma instance
|
||||||
|
const sigma = new Sigma(graph, containerRef.current, {
|
||||||
|
renderEdgeLabels: true,
|
||||||
|
defaultEdgeColor: "#0000001A",
|
||||||
|
defaultNodeColor: theme.node.fill,
|
||||||
|
defaultEdgeType: "edges-fast",
|
||||||
|
edgeProgramClasses: {
|
||||||
|
"edges-fast": EdgeLineProgram,
|
||||||
|
},
|
||||||
|
renderLabels: false,
|
||||||
|
enableEdgeEvents: true,
|
||||||
|
minCameraRatio: 0.01,
|
||||||
|
defaultDrawNodeHover: drawHover,
|
||||||
|
|
||||||
|
maxCameraRatio: 2,
|
||||||
|
allowInvalidContainer: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
sigmaRef.current = sigma;
|
||||||
|
|
||||||
|
// Set up camera for zoom on mount
|
||||||
|
if (zoomOnMount) {
|
||||||
|
setTimeout(() => {
|
||||||
|
sigma
|
||||||
|
.getCamera()
|
||||||
|
.animate(sigma.getCamera().getState(), { duration: 750 });
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cluster labels after any camera movement
|
||||||
|
sigma.getCamera().on("updated", () => {
|
||||||
|
if (showClusterLabels) {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drag and drop implementation (same as original)
|
||||||
|
let draggedNode: string | null = null;
|
||||||
|
let isDragging = false;
|
||||||
|
|
||||||
|
sigma.on("downNode", (e) => {
|
||||||
|
isDragging = true;
|
||||||
|
draggedNode = e.node;
|
||||||
|
graph.setNodeAttribute(draggedNode, "highlighted", true);
|
||||||
|
if (!sigma.getCustomBBox()) sigma.setCustomBBox(sigma.getBBox());
|
||||||
|
});
|
||||||
|
|
||||||
|
sigma.on("moveBody", ({ event }) => {
|
||||||
|
if (!isDragging || !draggedNode) return;
|
||||||
|
const pos = sigma.viewportToGraph(event);
|
||||||
|
graph.setNodeAttribute(draggedNode, "x", pos.x);
|
||||||
|
graph.setNodeAttribute(draggedNode, "y", pos.y);
|
||||||
|
event.preventSigmaDefault?.();
|
||||||
|
event.original?.preventDefault?.();
|
||||||
|
event.original?.stopPropagation?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleUp = () => {
|
||||||
|
if (draggedNode) {
|
||||||
|
graph.removeNodeAttribute(draggedNode, "highlighted");
|
||||||
|
}
|
||||||
|
isDragging = false;
|
||||||
|
draggedNode = null;
|
||||||
|
};
|
||||||
|
sigma.on("upNode", handleUp);
|
||||||
|
sigma.on("upStage", handleUp);
|
||||||
|
|
||||||
|
// Node click handler
|
||||||
|
sigma.on("clickNode", (event) => {
|
||||||
|
const { node } = event;
|
||||||
|
|
||||||
|
// Store current camera state to prevent unwanted movements
|
||||||
|
const camera = sigma.getCamera();
|
||||||
|
const currentState = camera.getState();
|
||||||
|
|
||||||
|
resetHighlights(); // Clear previous highlights first
|
||||||
|
|
||||||
|
// Restore camera state after reset to prevent zoom changes
|
||||||
|
setTimeout(() => {
|
||||||
|
camera.setState(currentState);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
if (onNodeClick) {
|
||||||
|
onNodeClick(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight the clicked node
|
||||||
|
graph.setNodeAttribute(node, "highlighted", true);
|
||||||
|
graph.setNodeAttribute(node, "color", theme.node.selected);
|
||||||
|
graph.setNodeAttribute(
|
||||||
|
node,
|
||||||
|
"size",
|
||||||
|
graph.getNodeAttribute(node, "size"),
|
||||||
|
);
|
||||||
|
// Enhanced border for selected node
|
||||||
|
graph.setNodeAttribute(node, "borderSize", 3);
|
||||||
|
graph.setNodeAttribute(node, "borderColor", theme.node.selected);
|
||||||
|
graph.setNodeAttribute(node, "zIndex", 3);
|
||||||
|
selectedNodeRef.current = node;
|
||||||
|
|
||||||
|
// Highlight connected edges and nodes
|
||||||
|
graph.forEachEdge(node, (edge, _attributes, source, target) => {
|
||||||
|
graph.setEdgeAttribute(edge, "highlighted", true);
|
||||||
|
graph.setEdgeAttribute(edge, "color", theme.link.selected);
|
||||||
|
graph.setEdgeAttribute(edge, "size", 2);
|
||||||
|
const otherNode = source === node ? target : source;
|
||||||
|
graph.setNodeAttribute(otherNode, "highlighted", true);
|
||||||
|
graph.setNodeAttribute(otherNode, "color", theme.node.hover);
|
||||||
|
graph.setNodeAttribute(
|
||||||
|
otherNode,
|
||||||
|
"size",
|
||||||
|
graph.getNodeAttribute(otherNode, "size"),
|
||||||
|
);
|
||||||
|
graph.setNodeAttribute(otherNode, "zIndex", 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Edge click handler
|
||||||
|
sigma.on("clickEdge", (event) => {
|
||||||
|
const { edge } = event;
|
||||||
|
resetHighlights();
|
||||||
|
const edgeAttrs = graph.getEdgeAttributes(edge);
|
||||||
|
if (edgeAttrs.relationData && edgeAttrs.relationData.length > 0) {
|
||||||
|
const relation = edgeAttrs.relationData[0];
|
||||||
|
if (onEdgeClick) {
|
||||||
|
onEdgeClick(relation.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
graph.setEdgeAttribute(edge, "highlighted", true);
|
||||||
|
graph.setEdgeAttribute(edge, "color", theme.link.selected);
|
||||||
|
selectedEdgeRef.current = edge;
|
||||||
|
const source = graph.source(edge);
|
||||||
|
const target = graph.target(edge);
|
||||||
|
graph.setNodeAttribute(source, "highlighted", true);
|
||||||
|
graph.setNodeAttribute(source, "color", theme.node.selected);
|
||||||
|
graph.setNodeAttribute(target, "highlighted", true);
|
||||||
|
graph.setNodeAttribute(target, "color", theme.node.selected);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Background click handler
|
||||||
|
sigma.on("clickStage", (event) => {
|
||||||
|
// Store camera state before reset
|
||||||
|
const camera = sigma.getCamera();
|
||||||
|
const currentState = camera.getState();
|
||||||
|
|
||||||
|
resetHighlights();
|
||||||
|
|
||||||
|
// Restore camera state
|
||||||
|
camera.setState(currentState);
|
||||||
|
|
||||||
|
if (onBlur) {
|
||||||
|
onBlur();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
|
return () => {
|
||||||
|
if (sigmaRef.current) {
|
||||||
|
sigmaRef.current.kill();
|
||||||
|
sigmaRef.current = null;
|
||||||
|
}
|
||||||
|
if (graphRef.current) {
|
||||||
|
graphRef.current.clear();
|
||||||
|
graphRef.current = null;
|
||||||
|
}
|
||||||
|
if (clustersLayerRef.current) {
|
||||||
|
clustersLayerRef.current.remove();
|
||||||
|
clustersLayerRef.current = null;
|
||||||
|
}
|
||||||
|
isInitializedRef.current = false;
|
||||||
|
};
|
||||||
|
}, [nodes, edges, clusters, showClusterLabels]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className=""
|
||||||
|
style={{
|
||||||
|
width: `${width}px`,
|
||||||
|
height: `${height}px`,
|
||||||
|
borderRadius: "8px",
|
||||||
|
cursor: "grab",
|
||||||
|
fontSize: "12px",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
@ -36,9 +36,7 @@ interface GraphPopoversProps {
|
|||||||
|
|
||||||
export function GraphPopovers({
|
export function GraphPopovers({
|
||||||
showNodePopup,
|
showNodePopup,
|
||||||
showEdgePopup,
|
|
||||||
nodePopupContent,
|
nodePopupContent,
|
||||||
edgePopupContent,
|
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
labelColorMap,
|
labelColorMap,
|
||||||
}: GraphPopoversProps) {
|
}: GraphPopoversProps) {
|
||||||
@ -52,8 +50,12 @@ export function GraphPopovers({
|
|||||||
|
|
||||||
// Check if node has primaryLabel property (GraphNode)
|
// Check if node has primaryLabel property (GraphNode)
|
||||||
const nodeAny = nodePopupContent.node as any;
|
const nodeAny = nodePopupContent.node as any;
|
||||||
if (nodeAny.primaryLabel && typeof nodeAny.primaryLabel === "string") {
|
|
||||||
return nodeAny.primaryLabel;
|
if (
|
||||||
|
nodeAny.attributes.nodeType &&
|
||||||
|
typeof nodeAny.attributes.nodeType === "string"
|
||||||
|
) {
|
||||||
|
return nodeAny.attributes.nodeType;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to original logic with labels
|
// Fall back to original logic with labels
|
||||||
@ -93,7 +95,7 @@ export function GraphPopovers({
|
|||||||
<div className="pointer-events-none h-4 w-4" />
|
<div className="pointer-events-none h-4 w-4" />
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
className="h-60 max-w-80 overflow-auto"
|
className="h-35 max-w-80 overflow-auto"
|
||||||
side="bottom"
|
side="bottom"
|
||||||
align="end"
|
align="end"
|
||||||
sideOffset={5}
|
sideOffset={5}
|
||||||
@ -104,7 +106,7 @@ export function GraphPopovers({
|
|||||||
<h4 className="leading-none font-medium">Node Details</h4>
|
<h4 className="leading-none font-medium">Node Details</h4>
|
||||||
{primaryNodeLabel && (
|
{primaryNodeLabel && (
|
||||||
<span
|
<span
|
||||||
className="rounded-full px-2 py-1 text-xs font-medium text-white"
|
className="rounded-md px-2 py-1 text-xs font-medium text-white"
|
||||||
style={{ backgroundColor: labelColor }}
|
style={{ backgroundColor: labelColor }}
|
||||||
>
|
>
|
||||||
{primaryNodeLabel}
|
{primaryNodeLabel}
|
||||||
@ -118,7 +120,9 @@ export function GraphPopovers({
|
|||||||
{attributesToDisplay.map(({ key, value }) => (
|
{attributesToDisplay.map(({ key, value }) => (
|
||||||
<p key={key} className="text-sm">
|
<p key={key} className="text-sm">
|
||||||
<span className="font-medium text-black dark:text-white">
|
<span className="font-medium text-black dark:text-white">
|
||||||
{key}:
|
{key.charAt(0).toUpperCase() +
|
||||||
|
key.slice(1).toLowerCase()}
|
||||||
|
:
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
<span className="text-muted-foreground break-words">
|
<span className="text-muted-foreground break-words">
|
||||||
{typeof value === "object"
|
{typeof value === "object"
|
||||||
@ -134,48 +138,6 @@ export function GraphPopovers({
|
|||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
<Popover open={showEdgePopup} onOpenChange={onOpenChange}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<div className="pointer-events-none h-4 w-4" />
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent
|
|
||||||
className="w-80 overflow-hidden"
|
|
||||||
side="bottom"
|
|
||||||
align="end"
|
|
||||||
sideOffset={5}
|
|
||||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<div className="bg-grayAlpha-100 mb-4 rounded-md p-2">
|
|
||||||
<p className="text-sm break-all">
|
|
||||||
Episode → {edgePopupContent?.target.name || "Unknown"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="leading-none font-medium">Relationship</h4>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<p className="text-muted-foreground text-sm break-all">
|
|
||||||
<span className="mr-2 text-sm font-medium text-black dark:text-white">
|
|
||||||
UUID:
|
|
||||||
</span>
|
|
||||||
{edgePopupContent?.relation.uuid || "Unknown"}
|
|
||||||
</p>
|
|
||||||
<p className="text-muted-foreground text-sm break-all">
|
|
||||||
<span className="mr-2 text-sm font-medium text-black dark:text-white">
|
|
||||||
Type:
|
|
||||||
</span>
|
|
||||||
{edgePopupContent?.relation.type || "Unknown"}
|
|
||||||
</p>
|
|
||||||
<p className="text-muted-foreground text-sm break-all">
|
|
||||||
<span className="mr-2 text-sm font-medium text-black dark:text-white">
|
|
||||||
Created:
|
|
||||||
</span>
|
|
||||||
{formatDate(edgePopupContent?.relation.createdAt)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
import colors from "tailwindcss/colors";
|
|
||||||
|
|
||||||
// Define a color palette for node coloring using hex values directly
|
// Define a color palette for node coloring using hex values directly
|
||||||
export const nodeColorPalette = {
|
export const nodeColorPalette = {
|
||||||
light: [
|
light: [
|
||||||
|
|||||||
@ -5,6 +5,7 @@ export interface Node {
|
|||||||
labels?: string[];
|
labels?: string[];
|
||||||
attributes?: Record<string, any>;
|
attributes?: Record<string, any>;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
clusterId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Edge {
|
export interface Edge {
|
||||||
@ -25,6 +26,7 @@ export interface GraphNode extends Node {
|
|||||||
id: string;
|
id: string;
|
||||||
value: string;
|
value: string;
|
||||||
primaryLabel?: string;
|
primaryLabel?: string;
|
||||||
|
clusterId?: string; // Add cluster information
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GraphEdge extends Edge {
|
export interface GraphEdge extends Edge {
|
||||||
|
|||||||
@ -21,6 +21,7 @@ export function toGraphNode(node: Node): GraphNode {
|
|||||||
summary: node.summary,
|
summary: node.summary,
|
||||||
labels: node.labels,
|
labels: node.labels,
|
||||||
primaryLabel,
|
primaryLabel,
|
||||||
|
clusterId: node?.clusterId, // Extract cluster ID from attributes
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,62 +45,109 @@ export function toGraphTriplets(triplets: RawTriplet[]): GraphTriplet[] {
|
|||||||
return triplets.map(toGraphTriplet);
|
return triplets.map(toGraphTriplet);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTriplets(edges: Edge[], nodes: Node[]): RawTriplet[] {
|
export function drawRoundRect(
|
||||||
// Create a Set of node UUIDs that are connected by edges
|
ctx: CanvasRenderingContext2D,
|
||||||
const connectedNodeIds = new Set<string>();
|
x: number,
|
||||||
|
y: number,
|
||||||
// Create triplets from edges
|
width: number,
|
||||||
const edgeTriplets = edges
|
height: number,
|
||||||
.map((edge) => {
|
radius: number,
|
||||||
const sourceNode = nodes.find(
|
): void {
|
||||||
(node) => node.uuid === edge.source_node_uuid,
|
ctx.beginPath();
|
||||||
);
|
ctx.moveTo(x + radius, y);
|
||||||
const targetNode = nodes.find(
|
ctx.lineTo(x + width - radius, y);
|
||||||
(node) => node.uuid === edge.target_node_uuid,
|
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
|
||||||
);
|
ctx.lineTo(x + width, y + height - radius);
|
||||||
|
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
||||||
if (!sourceNode || !targetNode) return null;
|
ctx.lineTo(x + radius, y + height);
|
||||||
|
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
|
||||||
// Add source and target node IDs to connected set
|
ctx.lineTo(x, y + radius);
|
||||||
connectedNodeIds.add(sourceNode.uuid);
|
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||||
connectedNodeIds.add(targetNode.uuid);
|
ctx.closePath();
|
||||||
|
}
|
||||||
return {
|
|
||||||
sourceNode,
|
const TEXT_COLOR = "#000000";
|
||||||
edge,
|
|
||||||
targetNode,
|
export function drawHover(
|
||||||
};
|
context: CanvasRenderingContext2D,
|
||||||
})
|
data: any,
|
||||||
.filter(
|
settings: any,
|
||||||
(t): t is RawTriplet =>
|
) {
|
||||||
t !== null && t.sourceNode !== undefined && t.targetNode !== undefined,
|
const size = settings.labelSize;
|
||||||
);
|
const font = settings.labelFont;
|
||||||
|
const weight = settings.labelWeight;
|
||||||
// Find isolated nodes (nodes that don't appear in any edge)
|
const subLabelSize = size - 2;
|
||||||
const isolatedNodes = nodes.filter(
|
|
||||||
(node) => !connectedNodeIds.has(node.uuid),
|
const label = data.label;
|
||||||
);
|
const subLabel = data.tag !== "unknown" ? data.tag : "";
|
||||||
|
const entityLabel = data.nodeData.attributes.nodeType;
|
||||||
// For isolated nodes, create special triplets
|
|
||||||
const isolatedTriplets: RawTriplet[] = isolatedNodes.map((node) => {
|
// Simulate the --shadow-1 Tailwind shadow:
|
||||||
// Create a special marker edge for isolated nodes
|
// lch(0 0 0 / 0.022) 0px 3px 6px -2px, lch(0 0 0 / 0.044) 0px 1px 1px;
|
||||||
const virtualEdge: Edge = {
|
// Canvas only supports a single shadow, so we approximate with the stronger one.
|
||||||
uuid: `isolated-node-${node.uuid}`,
|
// lch(0 0 0 / 0.044) is roughly rgba(0,0,0,0.044)
|
||||||
source_node_uuid: node.uuid,
|
context.beginPath();
|
||||||
target_node_uuid: node.uuid,
|
context.fillStyle = "#fff";
|
||||||
// Use a special type that we can filter out in the Graph component
|
context.shadowOffsetX = 0;
|
||||||
type: "_isolated_node_",
|
context.shadowOffsetY = 1;
|
||||||
|
context.shadowBlur = 1;
|
||||||
createdAt: node.createdAt,
|
context.shadowColor = "rgba(0,0,0,0.044)";
|
||||||
};
|
|
||||||
|
context.font = `${weight} ${size}px ${font}`;
|
||||||
return {
|
const labelWidth = context.measureText(label).width;
|
||||||
sourceNode: node,
|
context.font = `${weight} ${subLabelSize}px ${font}`;
|
||||||
edge: virtualEdge,
|
const subLabelWidth = subLabel ? context.measureText(subLabel).width : 0;
|
||||||
targetNode: node,
|
context.font = `${weight} ${subLabelSize}px ${font}`;
|
||||||
};
|
const entityLabelWidth = entityLabel
|
||||||
});
|
? context.measureText(entityLabel).width
|
||||||
|
: 0;
|
||||||
// Combine edge triplets with isolated node triplets
|
|
||||||
return [...edgeTriplets, ...isolatedTriplets];
|
const textWidth = Math.max(labelWidth, subLabelWidth, entityLabelWidth);
|
||||||
|
|
||||||
|
const x = Math.round(data.x);
|
||||||
|
const y = Math.round(data.y);
|
||||||
|
const w = Math.round(textWidth + size / 2 + data.size + 3);
|
||||||
|
const hLabel = Math.round(size / 2 + 4);
|
||||||
|
const hSubLabel = subLabel ? Math.round(subLabelSize / 2 + 9) : 0;
|
||||||
|
const hentityLabel = Math.round(subLabelSize / 2 + 9);
|
||||||
|
|
||||||
|
drawRoundRect(
|
||||||
|
context,
|
||||||
|
x,
|
||||||
|
y - hSubLabel - 12,
|
||||||
|
w,
|
||||||
|
hentityLabel + hLabel + hSubLabel + 12,
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
context.closePath();
|
||||||
|
context.fill();
|
||||||
|
|
||||||
|
// Remove shadow for text
|
||||||
|
context.shadowOffsetX = 0;
|
||||||
|
context.shadowOffsetY = 0;
|
||||||
|
context.shadowBlur = 0;
|
||||||
|
context.shadowColor = "transparent";
|
||||||
|
|
||||||
|
// And finally we draw the labels
|
||||||
|
context.fillStyle = TEXT_COLOR;
|
||||||
|
context.font = `${weight} ${size}px ${font}`;
|
||||||
|
context.fillText(label, data.x + data.size + 3, data.y + size / 3);
|
||||||
|
|
||||||
|
if (subLabel) {
|
||||||
|
context.fillStyle = TEXT_COLOR;
|
||||||
|
context.font = `${weight} ${subLabelSize}px ${font}`;
|
||||||
|
context.fillText(
|
||||||
|
subLabel,
|
||||||
|
data.x + data.size + 3,
|
||||||
|
data.y - (2 * size) / 3 - 2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.fillStyle = data.color;
|
||||||
|
context.font = `${weight} ${subLabelSize}px ${font}`;
|
||||||
|
context.fillText(
|
||||||
|
entityLabel,
|
||||||
|
data.x + data.size + 3,
|
||||||
|
data.y + size / 3 + 3 + subLabelSize,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,10 @@ import {
|
|||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import { LayoutGrid } from "lucide-react";
|
import { LayoutGrid } from "lucide-react";
|
||||||
import { LinearIcon, SlackIcon } from "./icons";
|
import { LinearIcon, SlackIcon } from "./icons";
|
||||||
|
import { Cursor } from "./icons/cursor";
|
||||||
|
import { Claude } from "./icons/claude";
|
||||||
|
import { Cline } from "./icons/cline";
|
||||||
|
import { VSCode } from "./icons/vscode";
|
||||||
|
|
||||||
export const ICON_MAPPING = {
|
export const ICON_MAPPING = {
|
||||||
slack: SlackIcon,
|
slack: SlackIcon,
|
||||||
@ -15,6 +19,10 @@ export const ICON_MAPPING = {
|
|||||||
|
|
||||||
gmail: RiMailFill,
|
gmail: RiMailFill,
|
||||||
linear: LinearIcon,
|
linear: LinearIcon,
|
||||||
|
cursor: Cursor,
|
||||||
|
claude: Claude,
|
||||||
|
cline: Cline,
|
||||||
|
vscode: VSCode,
|
||||||
|
|
||||||
// Default icon
|
// Default icon
|
||||||
integration: LayoutGrid,
|
integration: LayoutGrid,
|
||||||
|
|||||||
20
apps/webapp/app/components/icons/claude.tsx
Normal file
20
apps/webapp/app/components/icons/claude.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import type { IconProps } from "./types";
|
||||||
|
|
||||||
|
export function Claude({ size = 18, className }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
height={size}
|
||||||
|
className={className}
|
||||||
|
width={size}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<title>Claude</title>
|
||||||
|
<path
|
||||||
|
d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"
|
||||||
|
fill="#D97757"
|
||||||
|
fill-rule="nonzero"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
apps/webapp/app/components/icons/cline.tsx
Normal file
19
apps/webapp/app/components/icons/cline.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import type { IconProps } from "./types";
|
||||||
|
|
||||||
|
export function Cline({ size = 18, className }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
fill="currentColor"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
height={size}
|
||||||
|
className={className}
|
||||||
|
width={size}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<title>Cline</title>
|
||||||
|
<path d="M17.035 3.991c2.75 0 4.98 2.24 4.98 5.003v1.667l1.45 2.896a1.01 1.01 0 01-.002.909l-1.448 2.864v1.668c0 2.762-2.23 5.002-4.98 5.002H7.074c-2.751 0-4.98-2.24-4.98-5.002V17.33l-1.48-2.855a1.01 1.01 0 01-.003-.927l1.482-2.887V8.994c0-2.763 2.23-5.003 4.98-5.003h9.962zM8.265 9.6a2.274 2.274 0 00-2.274 2.274v4.042a2.274 2.274 0 004.547 0v-4.042A2.274 2.274 0 008.265 9.6zm7.326 0a2.274 2.274 0 00-2.274 2.274v4.042a2.274 2.274 0 104.548 0v-4.042A2.274 2.274 0 0015.59 9.6z"></path>
|
||||||
|
<path d="M12.054 5.558a2.779 2.779 0 100-5.558 2.779 2.779 0 000 5.558z"></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
apps/webapp/app/components/icons/cursor.tsx
Normal file
64
apps/webapp/app/components/icons/cursor.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import type { IconProps } from "./types";
|
||||||
|
|
||||||
|
export function Cursor({ size = 18, className }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className={className}
|
||||||
|
height={size}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<title>Cursor</title>
|
||||||
|
<path
|
||||||
|
d="M11.925 24l10.425-6-10.425-6L1.5 18l10.425 6z"
|
||||||
|
fill="url(#lobe-icons-cursorundefined-fill-0)"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="M22.35 18V6L11.925 0v12l10.425 6z"
|
||||||
|
fill="url(#lobe-icons-cursorundefined-fill-1)"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="M11.925 0L1.5 6v12l10.425-6V0z"
|
||||||
|
fill="url(#lobe-icons-cursorundefined-fill-2)"
|
||||||
|
></path>
|
||||||
|
<path d="M22.35 6L11.925 24V12L22.35 6z" fill="#555"></path>
|
||||||
|
<path d="M22.35 6l-10.425 6L1.5 6h20.85z" fill="#000"></path>
|
||||||
|
<defs>
|
||||||
|
<linearGradient
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="lobe-icons-cursorundefined-fill-0"
|
||||||
|
x1="11.925"
|
||||||
|
x2="11.925"
|
||||||
|
y1="12"
|
||||||
|
y2="24"
|
||||||
|
>
|
||||||
|
<stop offset=".16" stop-color="#000" stop-opacity=".39"></stop>
|
||||||
|
<stop offset=".658" stop-color="#000" stop-opacity=".8"></stop>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="lobe-icons-cursorundefined-fill-1"
|
||||||
|
x1="22.35"
|
||||||
|
x2="11.925"
|
||||||
|
y1="6.037"
|
||||||
|
y2="12.15"
|
||||||
|
>
|
||||||
|
<stop offset=".182" stop-color="#000" stop-opacity=".31"></stop>
|
||||||
|
<stop offset=".715" stop-color="#000" stop-opacity="0"></stop>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="lobe-icons-cursorundefined-fill-2"
|
||||||
|
x1="11.925"
|
||||||
|
x2="1.5"
|
||||||
|
y1="0"
|
||||||
|
y2="18"
|
||||||
|
>
|
||||||
|
<stop stop-color="#000" stop-opacity=".6"></stop>
|
||||||
|
<stop offset=".667" stop-color="#000" stop-opacity=".22"></stop>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
apps/webapp/app/components/icons/vscode.tsx
Normal file
26
apps/webapp/app/components/icons/vscode.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import type { IconProps } from "./types";
|
||||||
|
|
||||||
|
export function VSCode({ size = 18, className }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
width={size}
|
||||||
|
className={className}
|
||||||
|
height={size}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="#29b6f6"
|
||||||
|
d="M44,11.11v25.78c0,1.27-0.79,2.4-1.98,2.82l-8.82,4.14L34,33V15L33.2,4.15l8.82,4.14 C43.21,8.71,44,9.84,44,11.11z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#0277bd"
|
||||||
|
d="M9,33.896L34,15V5.353c0-1.198-1.482-1.758-2.275-0.86L4.658,29.239 c-0.9,0.83-0.849,2.267,0.107,3.032c0,0,1.324,1.232,1.803,1.574C7.304,34.37,8.271,34.43,9,33.896z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#0288d1"
|
||||||
|
d="M9,14.104L34,33v9.647c0,1.198-1.482,1.758-2.275,0.86L4.658,18.761 c-0.9-0.83-0.849-2.267,0.107-3.032c0,0,1.324-1.232,1.803-1.574C7.304,13.63,8.271,13.57,9,14.104z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,10 +1,7 @@
|
|||||||
import React from "react";
|
|
||||||
import { Link } from "@remix-run/react";
|
import { Link } from "@remix-run/react";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "~/components/ui/card";
|
} from "~/components/ui/card";
|
||||||
@ -35,23 +32,24 @@ export function IntegrationCard({
|
|||||||
>
|
>
|
||||||
<Card className="transition-all">
|
<Card className="transition-all">
|
||||||
<CardHeader className="p-4">
|
<CardHeader className="p-4">
|
||||||
<div className="bg-background-2 mb-2 flex h-6 w-6 items-center justify-center rounded">
|
<div className="flex items-center justify-between">
|
||||||
<Component size={18} />
|
<div className="bg-background-2 mb-2 flex h-6 w-6 items-center justify-center rounded">
|
||||||
|
<Component size={18} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isConnected && (
|
||||||
|
<div className="flex w-full items-center justify-end">
|
||||||
|
<Badge className="h-6 rounded bg-green-100 p-2 text-xs text-green-800">
|
||||||
|
Connected
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-base">{integration.name}</CardTitle>
|
<CardTitle className="text-base">{integration.name}</CardTitle>
|
||||||
<CardDescription className="line-clamp-2 text-xs">
|
<CardDescription className="line-clamp-2 text-xs">
|
||||||
{integration.description || `Connect to ${integration.name}`}
|
{integration.description || `Connect to ${integration.name}`}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
{isConnected && (
|
|
||||||
<CardFooter className="p-3">
|
|
||||||
<div className="flex w-full items-center justify-end">
|
|
||||||
<Badge className="h-6 rounded bg-green-100 p-2 text-xs text-green-800">
|
|
||||||
Connected
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</CardFooter>
|
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
34
apps/webapp/app/components/integrations/utils.tsx
Normal file
34
apps/webapp/app/components/integrations/utils.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
export const FIXED_INTEGRATIONS = [
|
||||||
|
{
|
||||||
|
id: "claude",
|
||||||
|
name: "Claude",
|
||||||
|
description: "AI assistant for coding, writing, and analysis",
|
||||||
|
icon: "claude",
|
||||||
|
slug: "claude",
|
||||||
|
spec: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cursor",
|
||||||
|
name: "Cursor",
|
||||||
|
description: "AI-powered code editor",
|
||||||
|
icon: "cursor",
|
||||||
|
slug: "cursor",
|
||||||
|
spec: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cline",
|
||||||
|
name: "Cline",
|
||||||
|
description: "AI coding assistant for terminal and command line",
|
||||||
|
icon: "cline",
|
||||||
|
slug: "cline",
|
||||||
|
spec: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "vscode",
|
||||||
|
name: "Visual Studio Code",
|
||||||
|
description: "Popular code editor with extensive extensions",
|
||||||
|
icon: "vscode",
|
||||||
|
slug: "vscode",
|
||||||
|
spec: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
@ -4,6 +4,7 @@ import { AlertCircle, Info, Trash } from "lucide-react";
|
|||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog";
|
||||||
import { Button } from "../ui";
|
import { Button } from "../ui";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@ -15,21 +16,42 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "../ui/alert-dialog";
|
} from "../ui/alert-dialog";
|
||||||
|
import { Badge } from "../ui/badge";
|
||||||
|
import { type LogItem } from "~/hooks/use-logs";
|
||||||
|
|
||||||
interface LogTextCollapseProps {
|
interface LogTextCollapseProps {
|
||||||
text?: string;
|
text?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
logData: any;
|
logData: any;
|
||||||
|
log: LogItem;
|
||||||
id: string;
|
id: string;
|
||||||
episodeUUID?: string;
|
episodeUUID?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "PROCESSING":
|
||||||
|
return "bg-blue-100 text-blue-800 hover:bg-blue-100 hover:text-blue-800";
|
||||||
|
case "PENDING":
|
||||||
|
return "bg-yellow-100 text-yellow-800 hover:bg-yellow-100 hover:text-yellow-800";
|
||||||
|
case "COMPLETED":
|
||||||
|
return "bg-success/10 text-success hover:bg-success/10 hover:text-success";
|
||||||
|
case "FAILED":
|
||||||
|
return "bg-destructive/10 text-destructive hover:bg-destructive/10 hover:text-destructive";
|
||||||
|
case "CANCELLED":
|
||||||
|
return "bg-gray-100 text-gray-800 hover:bg-gray-100 hover:text-gray-800";
|
||||||
|
default:
|
||||||
|
return "bg-gray-100 text-gray-800 hover:bg-gray-100 hover:text-gray-800";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export function LogTextCollapse({
|
export function LogTextCollapse({
|
||||||
episodeUUID,
|
episodeUUID,
|
||||||
text,
|
text,
|
||||||
error,
|
error,
|
||||||
id,
|
id,
|
||||||
logData,
|
logData,
|
||||||
|
log,
|
||||||
}: LogTextCollapseProps) {
|
}: LogTextCollapseProps) {
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
@ -75,19 +97,28 @@ export function LogTextCollapse({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex w-full items-center">
|
||||||
<div className="mb-2">
|
<div
|
||||||
<p
|
className={cn(
|
||||||
|
"group-hover:bg-grayAlpha-100 flex min-w-[0px] shrink grow items-start gap-2 rounded-md px-4",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"whitespace-p-wrap pt-2 text-sm break-words",
|
"border-border flex w-full min-w-[0px] shrink flex-col border-b py-2",
|
||||||
isLong ? "max-h-16 overflow-hidden" : "",
|
|
||||||
)}
|
)}
|
||||||
style={{ lineHeight: "1.5" }}
|
>
|
||||||
dangerouslySetInnerHTML={{ __html: text }}
|
<div className="flex w-full items-center justify-between gap-4">
|
||||||
/>
|
<div
|
||||||
|
className="inline-flex min-h-[24px] min-w-[0px] shrink cursor-pointer items-center justify-start"
|
||||||
|
onClick={() => setDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn("truncate text-left")}
|
||||||
|
dangerouslySetInnerHTML={{ __html: text }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isLong && (
|
|
||||||
<>
|
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
<DialogContent className="max-w-2xl p-4">
|
<DialogContent className="max-w-2xl p-4">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@ -101,66 +132,84 @@ export function LogTextCollapse({
|
|||||||
style={{ lineHeight: "1.5" }}
|
style={{ lineHeight: "1.5" }}
|
||||||
dangerouslySetInnerHTML={{ __html: text }}
|
dangerouslySetInnerHTML={{ __html: text }}
|
||||||
/>
|
/>
|
||||||
|
{error && (
|
||||||
|
<div className="mt-4 border-t px-3 py-2">
|
||||||
|
<div className="flex items-start gap-2 text-red-600">
|
||||||
|
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-sm font-medium">
|
||||||
|
Error Details
|
||||||
|
</p>
|
||||||
|
<p className="text-sm break-words whitespace-pre-wrap">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</>
|
<div className="text-muted-foreground flex items-center justify-end text-xs">
|
||||||
)}
|
<div className="flex items-center">
|
||||||
</div>
|
<Badge
|
||||||
<div
|
className={cn(
|
||||||
className={cn(
|
"mr-3 rounded text-xs",
|
||||||
"text-muted-foreground flex items-center justify-end text-xs",
|
getStatusColor(log.status),
|
||||||
isLong && "justify-between",
|
)}
|
||||||
)}
|
>
|
||||||
>
|
{log.status.charAt(0).toUpperCase() +
|
||||||
{isLong && (
|
log.status.slice(1).toLowerCase()}
|
||||||
<div className="flex items-center">
|
</Badge>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
<div className="text-muted-foreground mr-3">
|
||||||
size="sm"
|
{new Date(log.time).toLocaleString()}
|
||||||
className="-ml-2 rounded px-2"
|
</div>
|
||||||
onClick={() => setDialogOpen(true)}
|
|
||||||
>
|
<Button
|
||||||
<Info size={15} />
|
variant="ghost"
|
||||||
</Button>
|
size="sm"
|
||||||
{episodeUUID && (
|
className="-ml-2 rounded px-2"
|
||||||
<AlertDialog
|
onClick={() => setDialogOpen(true)}
|
||||||
open={deleteDialogOpen}
|
>
|
||||||
onOpenChange={setDeleteDialogOpen}
|
<Info size={15} />
|
||||||
>
|
</Button>
|
||||||
<AlertDialogTrigger asChild>
|
{episodeUUID && (
|
||||||
<Button variant="ghost" size="sm" className="rounded px-2">
|
<AlertDialog
|
||||||
<Trash size={15} />
|
open={deleteDialogOpen}
|
||||||
</Button>
|
onOpenChange={setDeleteDialogOpen}
|
||||||
</AlertDialogTrigger>
|
>
|
||||||
<AlertDialogContent>
|
<AlertDialogTrigger asChild>
|
||||||
<AlertDialogHeader>
|
<Button
|
||||||
<AlertDialogTitle>Delete Episode</AlertDialogTitle>
|
variant="ghost"
|
||||||
<AlertDialogDescription>
|
size="sm"
|
||||||
Are you sure you want to delete this episode? This action
|
className="rounded px-2"
|
||||||
cannot be undone.
|
>
|
||||||
</AlertDialogDescription>
|
<Trash size={15} />
|
||||||
</AlertDialogHeader>
|
</Button>
|
||||||
<AlertDialogFooter>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogContent>
|
||||||
<AlertDialogAction onClick={handleDelete}>
|
<AlertDialogHeader>
|
||||||
Continue
|
<AlertDialogTitle>Delete Episode</AlertDialogTitle>
|
||||||
</AlertDialogAction>
|
<AlertDialogDescription>
|
||||||
</AlertDialogFooter>
|
Are you sure you want to delete this episode? This
|
||||||
</AlertDialogContent>
|
action cannot be undone.
|
||||||
</AlertDialog>
|
</AlertDialogDescription>
|
||||||
)}
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDelete}>
|
||||||
|
Continue
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
{error && (
|
|
||||||
<div className="flex items-center gap-1 text-red-600">
|
|
||||||
<AlertCircle className="h-3 w-3" />
|
|
||||||
<span className="max-w-[200px] truncate" title={error}>
|
|
||||||
{error}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,7 +51,7 @@ export function LogsFilters({
|
|||||||
const handleBack = () => setStep("main");
|
const handleBack = () => setStep("main");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-4 flex w-full items-center justify-start gap-2">
|
<div className="mb-4 flex w-full items-center justify-start gap-2 px-5">
|
||||||
<Popover
|
<Popover
|
||||||
open={popoverOpen}
|
open={popoverOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import {
|
|||||||
} from "react-virtualized";
|
} from "react-virtualized";
|
||||||
import { type LogItem } from "~/hooks/use-logs";
|
import { type LogItem } from "~/hooks/use-logs";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Card, CardContent } from "~/components/ui/card";
|
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import { ScrollManagedList } from "../virtualized-list";
|
import { ScrollManagedList } from "../virtualized-list";
|
||||||
import { LogTextCollapse } from "./log-text-collapse";
|
import { LogTextCollapse } from "./log-text-collapse";
|
||||||
@ -46,23 +45,6 @@ function LogItemRenderer(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case "PROCESSING":
|
|
||||||
return "bg-blue-100 text-blue-800 hover:bg-blue-100 hover:text-blue-800";
|
|
||||||
case "PENDING":
|
|
||||||
return "bg-yellow-100 text-yellow-800 hover:bg-yellow-100 hover:text-yellow-800";
|
|
||||||
case "COMPLETED":
|
|
||||||
return "bg-green-100 text-green-800 hover:bg-green-100 hover:text-green-800";
|
|
||||||
case "FAILED":
|
|
||||||
return "bg-red-100 text-red-800 hover:bg-red-100 hover:text-red-800";
|
|
||||||
case "CANCELLED":
|
|
||||||
return "bg-gray-100 text-gray-800 hover:bg-gray-100 hover:text-gray-800";
|
|
||||||
default:
|
|
||||||
return "bg-gray-100 text-gray-800 hover:bg-gray-100 hover:text-gray-800";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CellMeasurer
|
<CellMeasurer
|
||||||
key={key}
|
key={key}
|
||||||
@ -71,40 +53,17 @@ function LogItemRenderer(
|
|||||||
parent={parent}
|
parent={parent}
|
||||||
rowIndex={index}
|
rowIndex={index}
|
||||||
>
|
>
|
||||||
<div key={key} style={style} className="pb-2">
|
<div key={key} style={style}>
|
||||||
<Card className="h-full">
|
<div className="group mx-2 flex cursor-default gap-2">
|
||||||
<CardContent className="p-4">
|
<LogTextCollapse
|
||||||
<div className="mb-2 flex items-start justify-between">
|
text={log.ingestText}
|
||||||
<div className="flex items-center gap-2">
|
error={log.error}
|
||||||
<Badge variant="secondary" className="rounded text-xs">
|
logData={log.data}
|
||||||
{log.source}
|
log={log}
|
||||||
</Badge>
|
id={log.id}
|
||||||
<div className="flex items-center gap-1">
|
episodeUUID={log.episodeUUID}
|
||||||
<Badge
|
/>
|
||||||
className={cn(
|
</div>
|
||||||
"rounded text-xs",
|
|
||||||
getStatusColor(log.status),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{log.status.charAt(0).toUpperCase() +
|
|
||||||
log.status.slice(1).toLowerCase()}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground text-xs">
|
|
||||||
{new Date(log.time).toLocaleString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<LogTextCollapse
|
|
||||||
text={log.ingestText}
|
|
||||||
error={log.error}
|
|
||||||
logData={log.data}
|
|
||||||
id={log.id}
|
|
||||||
episodeUUID={log.episodeUUID}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</CellMeasurer>
|
</CellMeasurer>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -9,6 +9,7 @@ const EnvironmentSchema = z.object({
|
|||||||
z.literal("production"),
|
z.literal("production"),
|
||||||
z.literal("test"),
|
z.literal("test"),
|
||||||
]),
|
]),
|
||||||
|
POSTGRES_DB: z.string(),
|
||||||
DATABASE_URL: z
|
DATABASE_URL: z
|
||||||
.string()
|
.string()
|
||||||
.refine(
|
.refine(
|
||||||
|
|||||||
@ -102,6 +102,153 @@ export const getNodeLinks = async (userId: string) => {
|
|||||||
return triplets;
|
return triplets;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Get graph data with cluster information for reified graph
|
||||||
|
export const getClusteredGraphData = async (userId: string) => {
|
||||||
|
const session = driver.session();
|
||||||
|
try {
|
||||||
|
// Get the proper reified graph structure: Entity -> Statement -> Entity
|
||||||
|
const result = await session.run(
|
||||||
|
`// Get all statements and their entity connections for reified graph
|
||||||
|
MATCH (s:Statement)
|
||||||
|
WHERE s.userId = $userId AND s.invalidAt IS NULL
|
||||||
|
|
||||||
|
// Get all entities connected to each statement
|
||||||
|
MATCH (s)-[:HAS_SUBJECT]->(subj:Entity)
|
||||||
|
MATCH (s)-[:HAS_PREDICATE]->(pred:Entity)
|
||||||
|
MATCH (s)-[:HAS_OBJECT]->(obj:Entity)
|
||||||
|
|
||||||
|
// Return both Entity->Statement and Statement->Entity relationships
|
||||||
|
WITH s, subj, pred, obj
|
||||||
|
UNWIND [
|
||||||
|
// Subject Entity -> Statement
|
||||||
|
{source: subj, target: s, type: 'HAS_SUBJECT', isEntityToStatement: true},
|
||||||
|
// Statement -> Predicate Entity
|
||||||
|
{source: s, target: pred, type: 'HAS_PREDICATE', isStatementToEntity: true},
|
||||||
|
// Statement -> Object Entity
|
||||||
|
{source: s, target: obj, type: 'HAS_OBJECT', isStatementToEntity: true}
|
||||||
|
] AS rel
|
||||||
|
|
||||||
|
RETURN DISTINCT
|
||||||
|
rel.source.uuid as sourceUuid,
|
||||||
|
rel.source.name as sourceName,
|
||||||
|
rel.source.labels as sourceLabels,
|
||||||
|
rel.source.type as sourceType,
|
||||||
|
rel.source.properties as sourceProperties,
|
||||||
|
rel.target.uuid as targetUuid,
|
||||||
|
rel.target.name as targetName,
|
||||||
|
rel.target.type as targetType,
|
||||||
|
rel.target.labels as targetLabels,
|
||||||
|
rel.target.properties as targetProperties,
|
||||||
|
rel.type as relationshipType,
|
||||||
|
s.uuid as statementUuid,
|
||||||
|
s.clusterId as clusterId,
|
||||||
|
s.fact as fact,
|
||||||
|
s.createdAt as createdAt,
|
||||||
|
rel.isEntityToStatement as isEntityToStatement,
|
||||||
|
rel.isStatementToEntity as isStatementToEntity`,
|
||||||
|
{ userId },
|
||||||
|
);
|
||||||
|
|
||||||
|
const triplets: RawTriplet[] = [];
|
||||||
|
const processedEdges = new Set<string>();
|
||||||
|
|
||||||
|
result.records.forEach((record) => {
|
||||||
|
const sourceUuid = record.get("sourceUuid");
|
||||||
|
const sourceName = record.get("sourceName");
|
||||||
|
const sourceType = record.get("sourceType");
|
||||||
|
const sourceLabels = record.get("sourceLabels") || [];
|
||||||
|
const sourceProperties = record.get("sourceProperties") || {};
|
||||||
|
|
||||||
|
const targetUuid = record.get("targetUuid");
|
||||||
|
const targetName = record.get("targetName");
|
||||||
|
const targetLabels = record.get("targetLabels") || [];
|
||||||
|
const targetProperties = record.get("targetProperties") || {};
|
||||||
|
const targetType = record.get("targetType");
|
||||||
|
|
||||||
|
const relationshipType = record.get("relationshipType");
|
||||||
|
const statementUuid = record.get("statementUuid");
|
||||||
|
const clusterId = record.get("clusterId");
|
||||||
|
const fact = record.get("fact");
|
||||||
|
const createdAt = record.get("createdAt");
|
||||||
|
|
||||||
|
// Create unique edge identifier to avoid duplicates
|
||||||
|
const edgeKey = `${sourceUuid}-${targetUuid}-${relationshipType}`;
|
||||||
|
if (processedEdges.has(edgeKey)) return;
|
||||||
|
processedEdges.add(edgeKey);
|
||||||
|
|
||||||
|
// Determine node types and add appropriate cluster information
|
||||||
|
const isSourceStatement =
|
||||||
|
sourceLabels.includes("Statement") || sourceUuid === statementUuid;
|
||||||
|
const isTargetStatement =
|
||||||
|
targetLabels.includes("Statement") || targetUuid === statementUuid;
|
||||||
|
|
||||||
|
// Statement nodes get cluster info, Entity nodes get default attributes
|
||||||
|
const sourceAttributes = isSourceStatement
|
||||||
|
? {
|
||||||
|
...sourceProperties,
|
||||||
|
clusterId,
|
||||||
|
nodeType: "Statement",
|
||||||
|
fact,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
...sourceProperties,
|
||||||
|
nodeType: "Entity",
|
||||||
|
type: sourceType,
|
||||||
|
name: sourceName,
|
||||||
|
};
|
||||||
|
|
||||||
|
const targetAttributes = isTargetStatement
|
||||||
|
? {
|
||||||
|
...targetProperties,
|
||||||
|
clusterId,
|
||||||
|
nodeType: "Statement",
|
||||||
|
fact,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
...targetProperties,
|
||||||
|
nodeType: "Entity",
|
||||||
|
type: targetType,
|
||||||
|
name: targetName,
|
||||||
|
};
|
||||||
|
|
||||||
|
triplets.push({
|
||||||
|
sourceNode: {
|
||||||
|
uuid: sourceUuid,
|
||||||
|
labels: sourceLabels,
|
||||||
|
attributes: sourceAttributes,
|
||||||
|
name: isSourceStatement ? fact : sourceName || sourceUuid,
|
||||||
|
clusterId,
|
||||||
|
createdAt: createdAt || "",
|
||||||
|
},
|
||||||
|
edge: {
|
||||||
|
uuid: `${sourceUuid}-${targetUuid}-${relationshipType}`,
|
||||||
|
type: relationshipType,
|
||||||
|
source_node_uuid: sourceUuid,
|
||||||
|
target_node_uuid: targetUuid,
|
||||||
|
createdAt: createdAt || "",
|
||||||
|
},
|
||||||
|
targetNode: {
|
||||||
|
uuid: targetUuid,
|
||||||
|
labels: targetLabels,
|
||||||
|
attributes: targetAttributes,
|
||||||
|
clusterId,
|
||||||
|
name: isTargetStatement ? fact : targetName || targetUuid,
|
||||||
|
createdAt: createdAt || "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return triplets;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`Error getting clustered graph data for user ${userId}: ${error}`,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await session.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export async function initNeo4jSchemaOnce() {
|
export async function initNeo4jSchemaOnce() {
|
||||||
if (schemaInitialized) return;
|
if (schemaInitialized) return;
|
||||||
|
|
||||||
@ -141,6 +288,9 @@ const initializeSchema = async () => {
|
|||||||
await runQuery(
|
await runQuery(
|
||||||
"CREATE CONSTRAINT statement_uuid IF NOT EXISTS FOR (n:Statement) REQUIRE n.uuid IS UNIQUE",
|
"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
|
// Create indexes for better query performance
|
||||||
await runQuery(
|
await runQuery(
|
||||||
@ -152,9 +302,18 @@ const initializeSchema = async () => {
|
|||||||
await runQuery(
|
await runQuery(
|
||||||
"CREATE INDEX statement_invalid_at IF NOT EXISTS FOR (n:Statement) ON (n.invalidAt)",
|
"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(
|
await runQuery(
|
||||||
"CREATE INDEX entity_name IF NOT EXISTS FOR (n:Entity) ON (n.name)",
|
"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+)
|
// Create vector indexes for semantic search (if using Neo4j 5.0+)
|
||||||
await runQuery(`
|
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 { logger } from "~/services/logger.service";
|
||||||
import { prisma } from "~/db.server";
|
import { prisma } from "~/db.server";
|
||||||
import { triggerIntegrationWebhook } from "~/trigger/webhooks/integration-webhook-delivery";
|
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) {
|
export async function action({ request }: ActionFunctionArgs) {
|
||||||
if (request.method !== "POST") {
|
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(
|
await triggerIntegrationWebhook(
|
||||||
integrationAccountId,
|
integrationAccountId,
|
||||||
userId,
|
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
|
// MCP request body schema
|
||||||
const MCPRequestSchema = z.object({}).passthrough();
|
const MCPRequestSchema = z.object({}).passthrough();
|
||||||
const SourceParams = z.object({
|
const SourceParams = z.object({
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
import { useState, useEffect } from "react";
|
import React, { useState } from "react";
|
||||||
|
import { useFetcher } from "@remix-run/react";
|
||||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||||
import { requireUserId } from "~/services/session.server";
|
import { requireUserId } from "~/services/session.server";
|
||||||
import { useTypedLoaderData } from "remix-typedjson";
|
import { useTypedLoaderData } from "remix-typedjson";
|
||||||
|
|
||||||
import { GraphVisualizationClient } from "~/components/graph/graph-client";
|
|
||||||
import { LoaderCircle } from "lucide-react";
|
import { LoaderCircle } from "lucide-react";
|
||||||
import { PageHeader } from "~/components/common/page-header";
|
import { PageHeader } from "~/components/common/page-header";
|
||||||
|
import { GraphVisualizationClient } from "~/components/graph/graph-client";
|
||||||
|
import { GraphNode } from "~/components/graph/type";
|
||||||
|
|
||||||
export async function loader({ request }: LoaderFunctionArgs) {
|
export async function loader({ request }: LoaderFunctionArgs) {
|
||||||
// Only return userId, not the heavy nodeLinks
|
// Only return userId, not the heavy nodeLinks
|
||||||
@ -15,38 +17,31 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const { userId } = useTypedLoaderData<typeof loader>();
|
const { userId } = useTypedLoaderData<typeof loader>();
|
||||||
|
const fetcher = useFetcher<any>();
|
||||||
|
const [selectedClusterId, setSelectedClusterId] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
// State for nodeLinks and loading
|
// Kick off the fetcher on mount if not already done
|
||||||
const [nodeLinks, setNodeLinks] = useState<any[] | null>(null);
|
React.useEffect(() => {
|
||||||
const [loading, setLoading] = useState(true);
|
if (userId && fetcher.state === "idle" && !fetcher.data) {
|
||||||
|
fetcher.load("/api/v1/graph/clustered");
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
fetchNodeLinks();
|
}, [userId, fetcher]);
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
// Determine loading state
|
||||||
};
|
const loading =
|
||||||
}, [userId]);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -60,7 +55,15 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
typeof window !== "undefined" &&
|
typeof window !== "undefined" &&
|
||||||
nodeLinks && <GraphVisualizationClient triplets={nodeLinks} />
|
graphData && (
|
||||||
|
<GraphVisualizationClient
|
||||||
|
triplets={graphData.triplets || []}
|
||||||
|
clusters={graphData.clusters || []}
|
||||||
|
selectedClusterId={selectedClusterId}
|
||||||
|
onClusterSelect={setSelectedClusterId}
|
||||||
|
className="h-full w-full"
|
||||||
|
/>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import React, { useMemo } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
json,
|
json,
|
||||||
type LoaderFunctionArgs,
|
type LoaderFunctionArgs,
|
||||||
type ActionFunctionArgs,
|
type ActionFunctionArgs,
|
||||||
} from "@remix-run/node";
|
} from "@remix-run/node";
|
||||||
import { useLoaderData } from "@remix-run/react";
|
import { useLoaderData, useParams } from "@remix-run/react";
|
||||||
import { requireUserId, requireWorkpace } from "~/services/session.server";
|
import { requireUserId, requireWorkpace } from "~/services/session.server";
|
||||||
import { getIntegrationDefinitions } from "~/services/integrationDefinition.server";
|
import { getIntegrationDefinitions } from "~/services/integrationDefinition.server";
|
||||||
import { getIntegrationAccounts } from "~/services/integrationAccount.server";
|
import { getIntegrationAccounts } from "~/services/integrationAccount.server";
|
||||||
@ -21,7 +21,15 @@ import {
|
|||||||
} from "~/services/ingestionRule.server";
|
} from "~/services/ingestionRule.server";
|
||||||
import { Section } from "~/components/integrations/section";
|
import { Section } from "~/components/integrations/section";
|
||||||
import { PageHeader } from "~/components/common/page-header";
|
import { PageHeader } from "~/components/common/page-header";
|
||||||
import { Plus } from "lucide-react";
|
import { Check, Copy, Plus } from "lucide-react";
|
||||||
|
import { FIXED_INTEGRATIONS } from "~/components/integrations/utils";
|
||||||
|
import {
|
||||||
|
IngestionRule,
|
||||||
|
type IntegrationAccount,
|
||||||
|
IntegrationDefinitionV2,
|
||||||
|
} from "@prisma/client";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Button } from "~/components/ui";
|
||||||
|
|
||||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||||
const userId = await requireUserId(request);
|
const userId = await requireUserId(request);
|
||||||
@ -33,7 +41,10 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
|||||||
getIntegrationAccounts(userId),
|
getIntegrationAccounts(userId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const integration = integrationDefinitions.find(
|
// Combine fixed integrations with dynamic ones
|
||||||
|
const allIntegrations = [...FIXED_INTEGRATIONS, ...integrationDefinitions];
|
||||||
|
|
||||||
|
const integration = allIntegrations.find(
|
||||||
(def) => def.slug === slug || def.id === slug,
|
(def) => def.slug === slug || def.id === slug,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -78,7 +89,10 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
|||||||
getIntegrationAccounts(userId),
|
getIntegrationAccounts(userId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const integration = integrationDefinitions.find(
|
// Combine fixed integrations with dynamic ones
|
||||||
|
const allIntegrations = [...FIXED_INTEGRATIONS, ...integrationDefinitions];
|
||||||
|
|
||||||
|
const integration = allIntegrations.find(
|
||||||
(def) => def.slug === slug || def.id === slug,
|
(def) => def.slug === slug || def.id === slug,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -119,14 +133,311 @@ function parseSpec(spec: any) {
|
|||||||
return spec;
|
return spec;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function IntegrationDetail() {
|
function CustomIntegrationContent({ integration }: { integration: any }) {
|
||||||
const { integration, integrationAccounts, ingestionRule } =
|
const memoryUrl = `https://core.heysol.ai/api/v1/mcp/memory?source=${integration.slug}`;
|
||||||
useLoaderData<typeof loader>();
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const copyToClipboard = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(memoryUrl);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to copy:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCustomContent = () => {
|
||||||
|
switch (integration.id) {
|
||||||
|
case "claude":
|
||||||
|
return {
|
||||||
|
title: "About Claude",
|
||||||
|
content: (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="leading-relaxed">
|
||||||
|
Claude is an AI assistant created by Anthropic. It can help with
|
||||||
|
a wide variety of tasks including:
|
||||||
|
</p>
|
||||||
|
<ul className="ml-4 list-inside list-disc space-y-1">
|
||||||
|
<li>Code generation and debugging</li>
|
||||||
|
<li>Writing and editing</li>
|
||||||
|
<li>Analysis and research</li>
|
||||||
|
<li>Problem-solving</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
For Claude Web, Desktop, and Code - OAuth authentication handled
|
||||||
|
automatically
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="bg-background-3 flex items-center rounded">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
id="memoryUrl"
|
||||||
|
value={memoryUrl}
|
||||||
|
readOnly
|
||||||
|
className="bg-background-3 block w-full text-base"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="link"
|
||||||
|
size="sm"
|
||||||
|
onClick={copyToClipboard}
|
||||||
|
className="px-3"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
case "cursor":
|
||||||
|
return {
|
||||||
|
title: "About Cursor",
|
||||||
|
content: (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="leading-relaxed">
|
||||||
|
Cursor is an AI-powered code editor that helps developers write
|
||||||
|
code faster and more efficiently.
|
||||||
|
</p>
|
||||||
|
<ul className="ml-4 list-inside list-disc space-y-1">
|
||||||
|
<li>AI-powered code completion</li>
|
||||||
|
<li>Natural language to code conversion</li>
|
||||||
|
<li>Code explanation and debugging</li>
|
||||||
|
<li>Refactoring assistance</li>
|
||||||
|
</ul>
|
||||||
|
<div className="bg-background-3 flex items-center rounded p-2">
|
||||||
|
<pre className="bg-background-3 m-0 block w-full p-0 text-base break-words whitespace-pre-wrap">
|
||||||
|
{JSON.stringify(
|
||||||
|
{
|
||||||
|
memory: {
|
||||||
|
url: memoryUrl,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}
|
||||||
|
</pre>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="link"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
memory: {
|
||||||
|
url: memoryUrl,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="px-3"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
case "cline":
|
||||||
|
return {
|
||||||
|
title: "About Cline",
|
||||||
|
content: (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="leading-relaxed">
|
||||||
|
Cline is an AI coding assistant that works directly in your
|
||||||
|
terminal and command line environment.
|
||||||
|
</p>
|
||||||
|
<ul className="ml-4 list-inside list-disc space-y-1">
|
||||||
|
<li>Command line AI assistance</li>
|
||||||
|
<li>Terminal-based code generation</li>
|
||||||
|
<li>Shell script optimization</li>
|
||||||
|
<li>DevOps automation help</li>
|
||||||
|
</ul>
|
||||||
|
<div className="bg-background-3 flex items-center rounded">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
id="memoryUrl"
|
||||||
|
value={memoryUrl}
|
||||||
|
readOnly
|
||||||
|
className="bg-background-3 block w-full text-base"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="link"
|
||||||
|
size="sm"
|
||||||
|
onClick={copyToClipboard}
|
||||||
|
className="px-3"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
case "vscode":
|
||||||
|
return {
|
||||||
|
title: "About Visual Studio Code",
|
||||||
|
content: (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="leading-relaxed">
|
||||||
|
Visual Studio Code is a lightweight but powerful source code
|
||||||
|
editor with extensive extension support.
|
||||||
|
</p>
|
||||||
|
<ul className="ml-4 list-inside list-disc space-y-1">
|
||||||
|
<li>Intelligent code completion</li>
|
||||||
|
<li>Built-in Git integration</li>
|
||||||
|
<li>Extensive extension marketplace</li>
|
||||||
|
<li>Debugging and testing tools</li>
|
||||||
|
</ul>
|
||||||
|
<p>You need to enable MCP in settings</p>
|
||||||
|
<div className="bg-background-3 flex flex-col items-start gap-2 rounded p-2">
|
||||||
|
<pre>
|
||||||
|
{JSON.stringify(
|
||||||
|
{
|
||||||
|
"chat.mcp.enabled": true,
|
||||||
|
"chat.mcp.discovery.enabled": true,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
<div className="bg-background-3 flex items-center rounded p-2">
|
||||||
|
<pre className="bg-background-3 m-0 block w-full p-0 text-base break-words whitespace-pre-wrap">
|
||||||
|
{JSON.stringify(
|
||||||
|
{
|
||||||
|
memory: {
|
||||||
|
type: "http",
|
||||||
|
url: memoryUrl,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}
|
||||||
|
</pre>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="link"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
memory: {
|
||||||
|
type: "http",
|
||||||
|
url: memoryUrl,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="px-3"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const customContent = getCustomContent();
|
||||||
|
|
||||||
|
if (!customContent) return null;
|
||||||
|
const Component = getIcon(integration.icon as IconType);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
|
<PageHeader
|
||||||
|
title="Integrations"
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: "Integrations", href: "/home/integrations" },
|
||||||
|
{ label: integration?.name || "Untitled" },
|
||||||
|
]}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: "Request New Integration",
|
||||||
|
icon: <Plus size={14} />,
|
||||||
|
onClick: () =>
|
||||||
|
window.open(
|
||||||
|
"https://github.com/redplanethq/core/issues/new",
|
||||||
|
"_blank",
|
||||||
|
),
|
||||||
|
variant: "secondary",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<div className="flex h-[calc(100vh_-_56px)] flex-col items-center overflow-hidden p-4 px-5">
|
||||||
|
<div className="w-5xl">
|
||||||
|
<Section
|
||||||
|
title={integration.name}
|
||||||
|
description={integration.description}
|
||||||
|
icon={
|
||||||
|
<div className="bg-grayAlpha-100 flex h-12 w-12 items-center justify-center rounded">
|
||||||
|
<Component size={24} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div>{customContent.content}</div>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IntegrationDetailProps {
|
||||||
|
integration: any;
|
||||||
|
integrationAccounts: any;
|
||||||
|
ingestionRule: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IntegrationDetail({
|
||||||
|
integration,
|
||||||
|
integrationAccounts,
|
||||||
|
ingestionRule,
|
||||||
|
}: IntegrationDetailProps) {
|
||||||
const activeAccount = useMemo(
|
const activeAccount = useMemo(
|
||||||
() =>
|
() =>
|
||||||
integrationAccounts.find(
|
integrationAccounts.find(
|
||||||
(acc) => acc.integrationDefinitionId === integration.id && acc.isActive,
|
(acc: IntegrationAccount) =>
|
||||||
|
acc.integrationDefinitionId === integration.id && acc.isActive,
|
||||||
),
|
),
|
||||||
[integrationAccounts, integration.id],
|
[integrationAccounts, integration.id],
|
||||||
);
|
);
|
||||||
@ -181,21 +492,21 @@ export default function IntegrationDetail() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{hasApiKey && (
|
{hasApiKey && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="inline-flex items-center gap-2 text-sm">
|
<span className="inline-flex items-center gap-2">
|
||||||
<Checkbox checked /> API Key authentication
|
<Checkbox checked /> API Key authentication
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{hasOAuth2 && (
|
{hasOAuth2 && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="inline-flex items-center gap-2 text-sm">
|
<span className="inline-flex items-center gap-2">
|
||||||
<Checkbox checked />
|
<Checkbox checked />
|
||||||
OAuth 2.0 authentication
|
OAuth 2.0 authentication
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!hasApiKey && !hasOAuth2 && !hasMCPAuth && (
|
{!hasApiKey && !hasOAuth2 && !hasMCPAuth && (
|
||||||
<div className="text-muted-foreground text-sm">
|
<div className="text-muted-foreground">
|
||||||
No authentication method specified
|
No authentication method specified
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -226,7 +537,7 @@ export default function IntegrationDetail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Connected Account Info */}
|
{/* Connected Account Info */}
|
||||||
<ConnectedAccountSection activeAccount={activeAccount} />
|
<ConnectedAccountSection activeAccount={activeAccount as any} />
|
||||||
|
|
||||||
{/* MCP Authentication Section */}
|
{/* MCP Authentication Section */}
|
||||||
<MCPAuthSection
|
<MCPAuthSection
|
||||||
@ -247,3 +558,29 @@ export default function IntegrationDetail() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function IntegrationDetailWrapper() {
|
||||||
|
const { integration, integrationAccounts, ingestionRule } =
|
||||||
|
useLoaderData<typeof loader>();
|
||||||
|
|
||||||
|
const { slug } = useParams();
|
||||||
|
// You can now use the `slug` param in your component
|
||||||
|
|
||||||
|
const fixedIntegration = FIXED_INTEGRATIONS.some(
|
||||||
|
(fixedInt) => fixedInt.slug === slug,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{fixedIntegration ? (
|
||||||
|
<CustomIntegrationContent integration={integration} />
|
||||||
|
) : (
|
||||||
|
<IntegrationDetail
|
||||||
|
integration={integration}
|
||||||
|
integrationAccounts={integrationAccounts}
|
||||||
|
ingestionRule={ingestionRule}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { getIntegrationAccounts } from "~/services/integrationAccount.server";
|
|||||||
import { IntegrationGrid } from "~/components/integrations/integration-grid";
|
import { IntegrationGrid } from "~/components/integrations/integration-grid";
|
||||||
import { PageHeader } from "~/components/common/page-header";
|
import { PageHeader } from "~/components/common/page-header";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
|
import { FIXED_INTEGRATIONS } from "~/components/integrations/utils";
|
||||||
|
|
||||||
export async function loader({ request }: LoaderFunctionArgs) {
|
export async function loader({ request }: LoaderFunctionArgs) {
|
||||||
const userId = await requireUserId(request);
|
const userId = await requireUserId(request);
|
||||||
@ -18,8 +19,11 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
getIntegrationAccounts(userId),
|
getIntegrationAccounts(userId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Combine fixed integrations with dynamic ones
|
||||||
|
const allIntegrations = [...FIXED_INTEGRATIONS, ...integrationDefinitions];
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
integrationDefinitions,
|
integrationDefinitions: allIntegrations,
|
||||||
integrationAccounts,
|
integrationAccounts,
|
||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useNavigate } from "@remix-run/react";
|
import { useNavigate, useFetcher } from "@remix-run/react";
|
||||||
import { useLogs } from "~/hooks/use-logs";
|
import { useLogs } from "~/hooks/use-logs";
|
||||||
import { LogsFilters } from "~/components/logs/logs-filters";
|
import { LogsFilters } from "~/components/logs/logs-filters";
|
||||||
import { VirtualLogsList } from "~/components/logs/virtual-logs-list";
|
import { VirtualLogsList } from "~/components/logs/virtual-logs-list";
|
||||||
@ -7,11 +7,13 @@ import { AppContainer, PageContainer } from "~/components/layout/app-layout";
|
|||||||
import { Card, CardContent } from "~/components/ui/card";
|
import { Card, CardContent } from "~/components/ui/card";
|
||||||
import { Database, LoaderCircle } from "lucide-react";
|
import { Database, LoaderCircle } from "lucide-react";
|
||||||
import { PageHeader } from "~/components/common/page-header";
|
import { PageHeader } from "~/components/common/page-header";
|
||||||
|
import { ContributionGraph } from "~/components/activity/contribution-graph";
|
||||||
|
|
||||||
export default function LogsAll() {
|
export default function LogsAll() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [selectedSource, setSelectedSource] = useState<string | undefined>();
|
const [selectedSource, setSelectedSource] = useState<string | undefined>();
|
||||||
const [selectedStatus, setSelectedStatus] = useState<string | undefined>();
|
const [selectedStatus, setSelectedStatus] = useState<string | undefined>();
|
||||||
|
const contributionFetcher = useFetcher<any>();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
logs,
|
logs,
|
||||||
@ -26,17 +28,41 @@ export default function LogsAll() {
|
|||||||
status: selectedStatus,
|
status: selectedStatus,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch contribution data on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (contributionFetcher.state === "idle" && !contributionFetcher.data) {
|
||||||
|
contributionFetcher.load("/api/v1/activity/contribution");
|
||||||
|
}
|
||||||
|
}, [contributionFetcher]);
|
||||||
|
|
||||||
|
// Get contribution data from fetcher
|
||||||
|
const contributionData = contributionFetcher.data?.success
|
||||||
|
? contributionFetcher.data.data.contributionData
|
||||||
|
: [];
|
||||||
|
const totalActivities = contributionFetcher.data?.success
|
||||||
|
? contributionFetcher.data.data.totalActivities
|
||||||
|
: 0;
|
||||||
|
const isContributionLoading =
|
||||||
|
contributionFetcher.state === "loading" || !contributionFetcher.data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="Logs" />
|
<PageHeader title="Logs" />
|
||||||
<div className="flex h-[calc(100vh_-_56px)] w-full flex-col items-center space-y-6 p-4 px-5">
|
<div className="flex h-[calc(100vh_-_56px)] w-full flex-col items-center space-y-6 py-4">
|
||||||
|
{/* Contribution Graph */}
|
||||||
|
<div className="mb-0 w-full max-w-5xl px-4">
|
||||||
|
{isContributionLoading ? (
|
||||||
|
<LoaderCircle className="text-primary h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ContributionGraph data={contributionData} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{isInitialLoad ? (
|
{isInitialLoad ? (
|
||||||
<>
|
<>
|
||||||
<LoaderCircle className="text-primary h-4 w-4 animate-spin" />{" "}
|
<LoaderCircle className="text-primary h-4 w-4 animate-spin" />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{" "}
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
{logs.length > 0 && (
|
{logs.length > 0 && (
|
||||||
<LogsFilters
|
<LogsFilters
|
||||||
|
|||||||
@ -1,10 +0,0 @@
|
|||||||
import { json, type LoaderFunctionArgs } from "@remix-run/node";
|
|
||||||
import { getNodeLinks } from "~/lib/neo4j.server";
|
|
||||||
|
|
||||||
export async function loader({ request }: LoaderFunctionArgs) {
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const userId = url.searchParams.get("userId");
|
|
||||||
if (!userId) return json([], { status: 400 });
|
|
||||||
const nodeLinks = await getNodeLinks(userId);
|
|
||||||
return json(nodeLinks);
|
|
||||||
}
|
|
||||||
@ -25,6 +25,7 @@ import { requireUserId } from "~/services/session.server";
|
|||||||
import { updateUser } from "~/models/user.server";
|
import { updateUser } from "~/models/user.server";
|
||||||
import { Copy, Check } from "lucide-react";
|
import { Copy, Check } from "lucide-react";
|
||||||
import { addToQueue } from "~/lib/ingest.server";
|
import { addToQueue } from "~/lib/ingest.server";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
const ONBOARDING_STEP_COOKIE = "onboardingStep";
|
const ONBOARDING_STEP_COOKIE = "onboardingStep";
|
||||||
const onboardingStepCookie = createCookie(ONBOARDING_STEP_COOKIE, {
|
const onboardingStepCookie = createCookie(ONBOARDING_STEP_COOKIE, {
|
||||||
@ -108,6 +109,9 @@ export default function Onboarding() {
|
|||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [selectedSource, setSelectedSource] = useState<
|
||||||
|
"Claude" | "Cursor" | "Other"
|
||||||
|
>("Claude");
|
||||||
|
|
||||||
const [form, fields] = useForm({
|
const [form, fields] = useForm({
|
||||||
lastSubmission: lastSubmission as any,
|
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 () => {
|
const copyToClipboard = async () => {
|
||||||
try {
|
try {
|
||||||
@ -144,7 +153,25 @@ export default function Onboarding() {
|
|||||||
|
|
||||||
<CardContent className="pt-2 text-base">
|
<CardContent className="pt-2 text-base">
|
||||||
<div className="space-y-4">
|
<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">
|
<div className="bg-background-3 flex items-center rounded">
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
export type AuthorizationAction = "read" | "write" | string; // Add more actions as needed
|
export type AuthorizationAction = "read" | "write" | string; // Add more actions as needed
|
||||||
|
|
||||||
const ResourceTypes = ["spaces"] as const;
|
const ResourceTypes = ["clusters"] as const;
|
||||||
|
|
||||||
export type AuthorizationResources = {
|
export type AuthorizationResources = {
|
||||||
[key in (typeof ResourceTypes)[number]]?: string | string[];
|
[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,
|
type Triple,
|
||||||
} from "@core/types";
|
} from "@core/types";
|
||||||
import { logger } from "./logger.service";
|
import { logger } from "./logger.service";
|
||||||
|
import { ClusteringService } from "./clustering.server";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import {
|
import {
|
||||||
dedupeNodes,
|
dedupeNodes,
|
||||||
@ -53,6 +54,12 @@ import { type PrismaClient } from "@prisma/client";
|
|||||||
const DEFAULT_EPISODE_WINDOW = 5;
|
const DEFAULT_EPISODE_WINDOW = 5;
|
||||||
|
|
||||||
export class KnowledgeGraphService {
|
export class KnowledgeGraphService {
|
||||||
|
private clusteringService: ClusteringService;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.clusteringService = new ClusteringService();
|
||||||
|
}
|
||||||
|
|
||||||
async getEmbedding(text: string) {
|
async getEmbedding(text: string) {
|
||||||
return getEmbedding(text);
|
return getEmbedding(text);
|
||||||
}
|
}
|
||||||
@ -188,6 +195,26 @@ export class KnowledgeGraphService {
|
|||||||
// Invalidate invalidated statements
|
// Invalidate invalidated statements
|
||||||
await invalidateStatements({ statementIds: invalidatedStatements });
|
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 endTime = Date.now();
|
||||||
const processingTimeMs = endTime - startTime;
|
const processingTimeMs = endTime - startTime;
|
||||||
logger.log(`Processing time: ${processingTimeMs} ms`);
|
logger.log(`Processing time: ${processingTimeMs} ms`);
|
||||||
|
|||||||
@ -957,3 +957,291 @@ export function createHybridActionApiRoute<
|
|||||||
|
|
||||||
return { loader, action };
|
return { loader, action };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hybrid Loader API Route types and builder
|
||||||
|
type HybridLoaderRouteBuilderOptions<
|
||||||
|
TParamsSchema extends AnyZodSchema | undefined = undefined,
|
||||||
|
TSearchParamsSchema extends AnyZodSchema | undefined = undefined,
|
||||||
|
THeadersSchema extends AnyZodSchema | undefined = undefined,
|
||||||
|
TResource = never,
|
||||||
|
> = {
|
||||||
|
params?: TParamsSchema;
|
||||||
|
searchParams?: TSearchParamsSchema;
|
||||||
|
headers?: THeadersSchema;
|
||||||
|
allowJWT?: boolean;
|
||||||
|
corsStrategy?: "all" | "none";
|
||||||
|
findResource: (
|
||||||
|
params: TParamsSchema extends
|
||||||
|
| z.ZodFirstPartySchemaTypes
|
||||||
|
| z.ZodDiscriminatedUnion<any, any>
|
||||||
|
? z.infer<TParamsSchema>
|
||||||
|
: undefined,
|
||||||
|
authentication: HybridAuthenticationResult,
|
||||||
|
searchParams: TSearchParamsSchema extends
|
||||||
|
| z.ZodFirstPartySchemaTypes
|
||||||
|
| z.ZodDiscriminatedUnion<any, any>
|
||||||
|
? z.infer<TSearchParamsSchema>
|
||||||
|
: undefined,
|
||||||
|
) => Promise<TResource | undefined>;
|
||||||
|
shouldRetryNotFound?: boolean;
|
||||||
|
authorization?: {
|
||||||
|
action: AuthorizationAction;
|
||||||
|
resource: (
|
||||||
|
resource: NonNullable<TResource>,
|
||||||
|
params: TParamsSchema extends
|
||||||
|
| z.ZodFirstPartySchemaTypes
|
||||||
|
| z.ZodDiscriminatedUnion<any, any>
|
||||||
|
? z.infer<TParamsSchema>
|
||||||
|
: undefined,
|
||||||
|
searchParams: TSearchParamsSchema extends
|
||||||
|
| z.ZodFirstPartySchemaTypes
|
||||||
|
| z.ZodDiscriminatedUnion<any, any>
|
||||||
|
? z.infer<TSearchParamsSchema>
|
||||||
|
: undefined,
|
||||||
|
headers: THeadersSchema extends
|
||||||
|
| z.ZodFirstPartySchemaTypes
|
||||||
|
| z.ZodDiscriminatedUnion<any, any>
|
||||||
|
? z.infer<THeadersSchema>
|
||||||
|
: undefined,
|
||||||
|
) => AuthorizationResources;
|
||||||
|
superScopes?: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type HybridLoaderHandlerFunction<
|
||||||
|
TParamsSchema extends AnyZodSchema | undefined,
|
||||||
|
TSearchParamsSchema extends AnyZodSchema | undefined,
|
||||||
|
THeadersSchema extends AnyZodSchema | undefined = undefined,
|
||||||
|
TResource = never,
|
||||||
|
> = (args: {
|
||||||
|
params: TParamsSchema extends
|
||||||
|
| z.ZodFirstPartySchemaTypes
|
||||||
|
| z.ZodDiscriminatedUnion<any, any>
|
||||||
|
? z.infer<TParamsSchema>
|
||||||
|
: undefined;
|
||||||
|
searchParams: TSearchParamsSchema extends
|
||||||
|
| z.ZodFirstPartySchemaTypes
|
||||||
|
| z.ZodDiscriminatedUnion<any, any>
|
||||||
|
? z.infer<TSearchParamsSchema>
|
||||||
|
: undefined;
|
||||||
|
headers: THeadersSchema extends
|
||||||
|
| z.ZodFirstPartySchemaTypes
|
||||||
|
| z.ZodDiscriminatedUnion<any, any>
|
||||||
|
? z.infer<THeadersSchema>
|
||||||
|
: undefined;
|
||||||
|
authentication: HybridAuthenticationResult;
|
||||||
|
request: Request;
|
||||||
|
resource: NonNullable<TResource>;
|
||||||
|
}) => Promise<Response>;
|
||||||
|
|
||||||
|
export function createHybridLoaderApiRoute<
|
||||||
|
TParamsSchema extends AnyZodSchema | undefined = undefined,
|
||||||
|
TSearchParamsSchema extends AnyZodSchema | undefined = undefined,
|
||||||
|
THeadersSchema extends AnyZodSchema | undefined = undefined,
|
||||||
|
TResource = never,
|
||||||
|
>(
|
||||||
|
options: HybridLoaderRouteBuilderOptions<
|
||||||
|
TParamsSchema,
|
||||||
|
TSearchParamsSchema,
|
||||||
|
THeadersSchema,
|
||||||
|
TResource
|
||||||
|
>,
|
||||||
|
handler: HybridLoaderHandlerFunction<
|
||||||
|
TParamsSchema,
|
||||||
|
TSearchParamsSchema,
|
||||||
|
THeadersSchema,
|
||||||
|
TResource
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
return async function loader({ request, params }: LoaderFunctionArgs) {
|
||||||
|
const {
|
||||||
|
params: paramsSchema,
|
||||||
|
searchParams: searchParamsSchema,
|
||||||
|
headers: headersSchema,
|
||||||
|
allowJWT = false,
|
||||||
|
corsStrategy = "none",
|
||||||
|
authorization,
|
||||||
|
findResource,
|
||||||
|
shouldRetryNotFound,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (corsStrategy !== "none" && request.method.toUpperCase() === "OPTIONS") {
|
||||||
|
return apiCors(request, json({}));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const authenticationResult = await authenticateHybridRequest(request, {
|
||||||
|
allowJWT,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!authenticationResult) {
|
||||||
|
return await wrapResponse(
|
||||||
|
request,
|
||||||
|
json({ error: "Authentication required" }, { status: 401 }),
|
||||||
|
corsStrategy !== "none",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsedParams: any = undefined;
|
||||||
|
if (paramsSchema) {
|
||||||
|
const parsed = paramsSchema.safeParse(params);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return await wrapResponse(
|
||||||
|
request,
|
||||||
|
json(
|
||||||
|
{
|
||||||
|
error: "Params Error",
|
||||||
|
details: fromZodError(parsed.error).details,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
),
|
||||||
|
corsStrategy !== "none",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
parsedParams = parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsedSearchParams: any = undefined;
|
||||||
|
if (searchParamsSchema) {
|
||||||
|
const searchParams = Object.fromEntries(
|
||||||
|
new URL(request.url).searchParams,
|
||||||
|
);
|
||||||
|
const parsed = searchParamsSchema.safeParse(searchParams);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return await wrapResponse(
|
||||||
|
request,
|
||||||
|
json(
|
||||||
|
{
|
||||||
|
error: "Query Error",
|
||||||
|
details: fromZodError(parsed.error).details,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
),
|
||||||
|
corsStrategy !== "none",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
parsedSearchParams = parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsedHeaders: any = undefined;
|
||||||
|
if (headersSchema) {
|
||||||
|
const rawHeaders = Object.fromEntries(request.headers);
|
||||||
|
const headers = headersSchema.safeParse(rawHeaders);
|
||||||
|
if (!headers.success) {
|
||||||
|
return await wrapResponse(
|
||||||
|
request,
|
||||||
|
json(
|
||||||
|
{
|
||||||
|
error: "Headers Error",
|
||||||
|
details: fromZodError(headers.error).details,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
),
|
||||||
|
corsStrategy !== "none",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
parsedHeaders = headers.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the resource
|
||||||
|
const resource = await findResource(
|
||||||
|
parsedParams,
|
||||||
|
authenticationResult,
|
||||||
|
parsedSearchParams,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return await wrapResponse(
|
||||||
|
request,
|
||||||
|
json(
|
||||||
|
{ error: "Not found" },
|
||||||
|
{
|
||||||
|
status: 404,
|
||||||
|
headers: {
|
||||||
|
"x-should-retry": shouldRetryNotFound ? "true" : "false",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
corsStrategy !== "none",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorization check - only applies to API key authentication
|
||||||
|
if (authorization && authenticationResult.type === "PRIVATE") {
|
||||||
|
const { action, resource: authResource, superScopes } = authorization;
|
||||||
|
const $authResource = authResource(
|
||||||
|
resource,
|
||||||
|
parsedParams,
|
||||||
|
parsedSearchParams,
|
||||||
|
parsedHeaders,
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.debug("Checking authorization", {
|
||||||
|
action,
|
||||||
|
resource: $authResource,
|
||||||
|
superScopes,
|
||||||
|
scopes: authenticationResult.scopes,
|
||||||
|
});
|
||||||
|
|
||||||
|
const authorizationResult = checkAuthorization(authenticationResult);
|
||||||
|
|
||||||
|
if (!authorizationResult.authorized) {
|
||||||
|
return await wrapResponse(
|
||||||
|
request,
|
||||||
|
json(
|
||||||
|
{
|
||||||
|
error: `Unauthorized: ${authorizationResult.reason}`,
|
||||||
|
code: "unauthorized",
|
||||||
|
param: "access_token",
|
||||||
|
type: "authorization",
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
),
|
||||||
|
corsStrategy !== "none",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await handler({
|
||||||
|
params: parsedParams,
|
||||||
|
searchParams: parsedSearchParams,
|
||||||
|
headers: parsedHeaders,
|
||||||
|
authentication: authenticationResult,
|
||||||
|
request,
|
||||||
|
resource,
|
||||||
|
});
|
||||||
|
return await wrapResponse(request, result, corsStrategy !== "none");
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
if (error instanceof Response) {
|
||||||
|
return await wrapResponse(request, error, corsStrategy !== "none");
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error("Error in hybrid loader", {
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? {
|
||||||
|
name: error.name,
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
}
|
||||||
|
: String(error),
|
||||||
|
url: request.url,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await wrapResponse(
|
||||||
|
request,
|
||||||
|
json({ error: "Internal Server Error" }, { status: 500 }),
|
||||||
|
corsStrategy !== "none",
|
||||||
|
);
|
||||||
|
} catch (innerError) {
|
||||||
|
logger.error("[apiBuilder] Failed to handle error", {
|
||||||
|
error,
|
||||||
|
innerError,
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ error: "Internal Server Error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import type { StatementNode } from "@core/types";
|
import type { EpisodicNode, StatementNode } from "@core/types";
|
||||||
import { logger } from "./logger.service";
|
import { logger } from "./logger.service";
|
||||||
import { applyCrossEncoderReranking, applyWeightedRRF } from "./search/rerank";
|
import { applyCrossEncoderReranking, applyWeightedRRF } from "./search/rerank";
|
||||||
import {
|
import {
|
||||||
@ -8,6 +8,8 @@ import {
|
|||||||
performVectorSearch,
|
performVectorSearch,
|
||||||
} from "./search/utils";
|
} from "./search/utils";
|
||||||
import { getEmbedding } from "~/lib/model.server";
|
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
|
* SearchService provides methods to search the reified + temporal knowledge graph
|
||||||
@ -30,6 +32,7 @@ export class SearchService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
options: SearchOptions = {},
|
options: SearchOptions = {},
|
||||||
): Promise<{ episodes: string[]; facts: string[] }> {
|
): Promise<{ episodes: string[]; facts: string[] }> {
|
||||||
|
const startTime = Date.now();
|
||||||
// Default options
|
// Default options
|
||||||
|
|
||||||
const opts: Required<SearchOptions> = {
|
const opts: Required<SearchOptions> = {
|
||||||
@ -70,6 +73,21 @@ export class SearchService {
|
|||||||
|
|
||||||
// 3. Return top results
|
// 3. Return top results
|
||||||
const episodes = await getEpisodesByStatements(filteredResults);
|
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 {
|
return {
|
||||||
episodes: episodes.map((episode) => episode.content),
|
episodes: episodes.map((episode) => episode.content),
|
||||||
facts: filteredResults.map((statement) => statement.fact),
|
facts: filteredResults.map((statement) => statement.fact),
|
||||||
@ -201,6 +219,100 @@ export class SearchService {
|
|||||||
// Otherwise use weighted RRF for multiple sources
|
// Otherwise use weighted RRF for multiple sources
|
||||||
return applyWeightedRRF(results);
|
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 {
|
@layer base {
|
||||||
|
.react-calendar-heatmap {
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
.react-calendar-heatmap .react-calendar-heatmap-month-label {
|
||||||
|
font-size: 9px;
|
||||||
|
fill: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
.react-calendar-heatmap .react-calendar-heatmap-weekday-label {
|
||||||
|
font-size: 9px;
|
||||||
|
fill: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
.react-calendar-heatmap rect {
|
||||||
|
|
||||||
|
rx: 2;
|
||||||
|
}
|
||||||
|
.react-calendar-heatmap rect:hover {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.tiptap {
|
.tiptap {
|
||||||
:first-child {
|
:first-child {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
@ -535,3 +556,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
115
apps/webapp/app/trigger/cluster/index.ts
Normal file
115
apps/webapp/app/trigger/cluster/index.ts
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import { queue, task } from "@trigger.dev/sdk";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { ClusteringService } from "~/services/clustering.server";
|
||||||
|
import { logger } from "~/services/logger.service";
|
||||||
|
|
||||||
|
const clusteringService = new ClusteringService();
|
||||||
|
|
||||||
|
// Define the payload schema for cluster tasks
|
||||||
|
export const ClusterPayload = z.object({
|
||||||
|
userId: z.string(),
|
||||||
|
mode: z.enum(["auto", "incremental", "complete", "drift"]).default("auto"),
|
||||||
|
forceComplete: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
const clusterQueue = queue({
|
||||||
|
name: "cluster-queue",
|
||||||
|
concurrencyLimit: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single clustering task that handles all clustering operations based on payload mode
|
||||||
|
*/
|
||||||
|
export const clusterTask = task({
|
||||||
|
id: "cluster",
|
||||||
|
queue: clusterQueue,
|
||||||
|
maxDuration: 1800, // 30 minutes max
|
||||||
|
run: async (payload: z.infer<typeof ClusterPayload>) => {
|
||||||
|
logger.info(`Starting ${payload.mode} clustering task for user ${payload.userId}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let result;
|
||||||
|
|
||||||
|
switch (payload.mode) {
|
||||||
|
case "incremental":
|
||||||
|
result = await clusteringService.performIncrementalClustering(
|
||||||
|
payload.userId,
|
||||||
|
);
|
||||||
|
logger.info(`Incremental clustering completed for user ${payload.userId}:`, {
|
||||||
|
newStatementsProcessed: result.newStatementsProcessed,
|
||||||
|
newClustersCreated: result.newClustersCreated,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "complete":
|
||||||
|
result = await clusteringService.performCompleteClustering(
|
||||||
|
payload.userId,
|
||||||
|
);
|
||||||
|
logger.info(`Complete clustering completed for user ${payload.userId}:`, {
|
||||||
|
clustersCreated: result.clustersCreated,
|
||||||
|
statementsProcessed: result.statementsProcessed,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "drift":
|
||||||
|
// First detect drift
|
||||||
|
const driftMetrics = await clusteringService.detectClusterDrift(
|
||||||
|
payload.userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (driftMetrics.driftDetected) {
|
||||||
|
// Handle drift by splitting low-cohesion clusters
|
||||||
|
const driftResult = await clusteringService.handleClusterDrift(
|
||||||
|
payload.userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`Cluster drift handling completed for user ${payload.userId}:`, {
|
||||||
|
driftDetected: true,
|
||||||
|
clustersProcessed: driftResult.clustersProcessed,
|
||||||
|
newClustersCreated: driftResult.newClustersCreated,
|
||||||
|
splitClusters: driftResult.splitClusters,
|
||||||
|
});
|
||||||
|
|
||||||
|
result = {
|
||||||
|
driftDetected: true,
|
||||||
|
...driftResult,
|
||||||
|
driftMetrics,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
logger.info(`No cluster drift detected for user ${payload.userId}`);
|
||||||
|
result = {
|
||||||
|
driftDetected: false,
|
||||||
|
clustersProcessed: 0,
|
||||||
|
newClustersCreated: 0,
|
||||||
|
splitClusters: [],
|
||||||
|
driftMetrics,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "auto":
|
||||||
|
default:
|
||||||
|
result = await clusteringService.performClustering(
|
||||||
|
payload.userId,
|
||||||
|
payload.forceComplete,
|
||||||
|
);
|
||||||
|
logger.info(`Auto clustering completed for user ${payload.userId}:`, {
|
||||||
|
clustersCreated: result.clustersCreated,
|
||||||
|
statementsProcessed: result.statementsProcessed,
|
||||||
|
approach: result.approach,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`${payload.mode} clustering failed for user ${payload.userId}:`, {
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -108,7 +108,7 @@ const Keys = [
|
|||||||
export async function addEnvVariablesInTrigger() {
|
export async function addEnvVariablesInTrigger() {
|
||||||
const {
|
const {
|
||||||
APP_ORIGIN,
|
APP_ORIGIN,
|
||||||
TRIGGER_DB,
|
POSTGRES_DB,
|
||||||
EMBEDDING_MODEL,
|
EMBEDDING_MODEL,
|
||||||
MODEL,
|
MODEL,
|
||||||
ENCRYPTION_KEY,
|
ENCRYPTION_KEY,
|
||||||
@ -121,7 +121,7 @@ export async function addEnvVariablesInTrigger() {
|
|||||||
TRIGGER_SECRET_KEY,
|
TRIGGER_SECRET_KEY,
|
||||||
} = env;
|
} = env;
|
||||||
|
|
||||||
const DATABASE_URL = getDatabaseUrl(TRIGGER_DB);
|
const DATABASE_URL = getDatabaseUrl(POSTGRES_DB);
|
||||||
|
|
||||||
// Helper to replace 'localhost' with 'host.docker.internal'
|
// Helper to replace 'localhost' with 'host.docker.internal'
|
||||||
function replaceLocalhost(val: string | undefined): string | undefined {
|
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",
|
"@tiptap/starter-kit": "2.11.9",
|
||||||
"@trigger.dev/react-hooks": "^4.0.0-v4-beta.22",
|
"@trigger.dev/react-hooks": "^4.0.0-v4-beta.22",
|
||||||
"@trigger.dev/sdk": "^4.0.0-v4-beta.22",
|
"@trigger.dev/sdk": "^4.0.0-v4-beta.22",
|
||||||
|
"@types/react-calendar-heatmap": "^1.9.0",
|
||||||
"ai": "4.3.14",
|
"ai": "4.3.14",
|
||||||
"axios": "^1.10.0",
|
"axios": "^1.10.0",
|
||||||
"bullmq": "^5.53.2",
|
"bullmq": "^5.53.2",
|
||||||
@ -111,6 +112,7 @@
|
|||||||
"ollama-ai-provider": "1.2.0",
|
"ollama-ai-provider": "1.2.0",
|
||||||
"posthog-js": "^1.116.6",
|
"posthog-js": "^1.116.6",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
"react-calendar-heatmap": "^1.10.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-resizable-panels": "^1.0.9",
|
"react-resizable-panels": "^1.0.9",
|
||||||
"react-virtualized": "^9.22.6",
|
"react-virtualized": "^9.22.6",
|
||||||
|
|||||||
@ -50,8 +50,8 @@ services:
|
|||||||
image: neo4j:5.25-community
|
image: neo4j:5.25-community
|
||||||
environment:
|
environment:
|
||||||
- NEO4J_AUTH=${NEO4J_AUTH}
|
- NEO4J_AUTH=${NEO4J_AUTH}
|
||||||
- NEO4J_dbms_security_procedures_unrestricted=gds.*
|
- NEO4J_dbms_security_procedures_unrestricted=gds.*,apoc.*
|
||||||
- NEO4J_dbms_security_procedures_allowlist=gds.*
|
- NEO4J_dbms_security_procedures_allowlist=gds.*,apoc.*
|
||||||
ports:
|
ports:
|
||||||
- "7474:7474"
|
- "7474:7474"
|
||||||
- "7687:7687"
|
- "7687:7687"
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
VERSION=0.1.13
|
VERSION=0.1.14
|
||||||
|
|
||||||
# Nest run in docker, change host to database container name
|
# Nest run in docker, change host to database container name
|
||||||
DB_HOST=localhost
|
DB_HOST=localhost
|
||||||
|
|||||||
@ -76,8 +76,13 @@ services:
|
|||||||
image: neo4j:5
|
image: neo4j:5
|
||||||
environment:
|
environment:
|
||||||
- NEO4J_AUTH=${NEO4J_AUTH}
|
- NEO4J_AUTH=${NEO4J_AUTH}
|
||||||
- NEO4J_dbms_security_procedures_unrestricted=gds.*
|
- NEO4J_dbms_security_procedures_unrestricted=gds.*,apoc.*
|
||||||
- NEO4J_dbms_security_procedures_allowlist=gds.*
|
- 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:
|
ports:
|
||||||
- "7474:7474"
|
- "7474:7474"
|
||||||
- "7687:7687"
|
- "7687:7687"
|
||||||
|
|||||||
@ -136,4 +136,4 @@ OBJECT_STORE_SECRET_ACCESS_KEY=very-safe-password
|
|||||||
# TRAEFIK_HTTPS_PUBLISH_IP=0.0.0.0
|
# TRAEFIK_HTTPS_PUBLISH_IP=0.0.0.0
|
||||||
# TRAEFIK_DASHBOARD_PUBLISH_IP=127.0.0.1
|
# 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",
|
"name": "core",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.13",
|
"version": "0.1.14",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*",
|
"apps/*",
|
||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "dotenv -- turbo run build",
|
"build": "dotenv -- turbo run build",
|
||||||
"dev": "dotenv -- turbo run dev --filter=!@redplanethq/core",
|
"dev": "dotenv -- turbo run dev",
|
||||||
"lint": "dotenv -- turbo run lint",
|
"lint": "dotenv -- turbo run lint",
|
||||||
"format": "dotenv -- prettier --write \"**/*.{ts,tsx,md}\"",
|
"format": "dotenv -- prettier --write \"**/*.{ts,tsx,md}\"",
|
||||||
"check-types": "dotenv -- turbo run check-types",
|
"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"
|
status String @default("pending") // Can be "pending", "running", "completed", "failed", "need_attention"
|
||||||
|
|
||||||
ConversationHistory ConversationHistory[]
|
ConversationHistory ConversationHistory[]
|
||||||
|
RecallLog RecallLog[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model ConversationExecutionStep {
|
model ConversationExecutionStep {
|
||||||
@ -423,6 +424,51 @@ model PersonalAccessToken {
|
|||||||
authorizationCodes AuthorizationCode[]
|
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 {
|
model Space {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
@ -505,6 +551,7 @@ model User {
|
|||||||
oauthIntegrationGrants OAuthIntegrationGrant[]
|
oauthIntegrationGrants OAuthIntegrationGrant[]
|
||||||
oAuthClientInstallation OAuthClientInstallation[]
|
oAuthClientInstallation OAuthClientInstallation[]
|
||||||
UserUsage UserUsage?
|
UserUsage UserUsage?
|
||||||
|
RecallLog RecallLog[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model UserUsage {
|
model UserUsage {
|
||||||
@ -579,6 +626,7 @@ model Workspace {
|
|||||||
OAuthAuthorizationCode OAuthAuthorizationCode[]
|
OAuthAuthorizationCode OAuthAuthorizationCode[]
|
||||||
OAuthAccessToken OAuthAccessToken[]
|
OAuthAccessToken OAuthAccessToken[]
|
||||||
OAuthRefreshToken OAuthRefreshToken[]
|
OAuthRefreshToken OAuthRefreshToken[]
|
||||||
|
RecallLog RecallLog[]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AuthenticationMethod {
|
enum AuthenticationMethod {
|
||||||
|
|||||||
@ -20,6 +20,7 @@ export interface EpisodicNode {
|
|||||||
userId: string;
|
userId: string;
|
||||||
space?: string;
|
space?: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
|
recallCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -52,6 +53,7 @@ export interface StatementNode {
|
|||||||
attributes: Record<string, any>;
|
attributes: Record<string, any>;
|
||||||
userId: string;
|
userId: string;
|
||||||
space?: string;
|
space?: string;
|
||||||
|
recallCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
276
pnpm-lock.yaml
generated
276
pnpm-lock.yaml
generated
@ -463,6 +463,9 @@ importers:
|
|||||||
'@trigger.dev/sdk':
|
'@trigger.dev/sdk':
|
||||||
specifier: ^4.0.0-v4-beta.22
|
specifier: ^4.0.0-v4-beta.22
|
||||||
version: 4.0.0-v4-beta.22(ai@4.3.14(react@18.3.1)(zod@3.23.8))(zod@3.23.8)
|
version: 4.0.0-v4-beta.22(ai@4.3.14(react@18.3.1)(zod@3.23.8))(zod@3.23.8)
|
||||||
|
'@types/react-calendar-heatmap':
|
||||||
|
specifier: ^1.9.0
|
||||||
|
version: 1.9.0
|
||||||
ai:
|
ai:
|
||||||
specifier: 4.3.14
|
specifier: 4.3.14
|
||||||
version: 4.3.14(react@18.3.1)(zod@3.23.8)
|
version: 4.3.14(react@18.3.1)(zod@3.23.8)
|
||||||
@ -571,6 +574,9 @@ importers:
|
|||||||
react:
|
react:
|
||||||
specifier: ^18.2.0
|
specifier: ^18.2.0
|
||||||
version: 18.3.1
|
version: 18.3.1
|
||||||
|
react-calendar-heatmap:
|
||||||
|
specifier: ^1.10.0
|
||||||
|
version: 1.10.0(react@18.3.1)
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^18.2.0
|
specifier: ^18.2.0
|
||||||
version: 18.3.1(react@18.3.1)
|
version: 18.3.1(react@18.3.1)
|
||||||
@ -747,253 +753,6 @@ importers:
|
|||||||
specifier: ^4.2.1
|
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))
|
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:
|
packages/database:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@prisma/client':
|
'@prisma/client':
|
||||||
@ -5218,6 +4977,9 @@ packages:
|
|||||||
'@types/range-parser@1.2.7':
|
'@types/range-parser@1.2.7':
|
||||||
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
|
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
|
||||||
|
|
||||||
|
'@types/react-calendar-heatmap@1.9.0':
|
||||||
|
resolution: {integrity: sha512-BH8M/nsXoLGa3hxWbrq3guPwlK0cV+w1i4c/ktrTxTzN5fBths6WbeUZ4dK0+tE76qiGoVSo9Tse8WVVuMIV+w==}
|
||||||
|
|
||||||
'@types/react-dom@18.2.18':
|
'@types/react-dom@18.2.18':
|
||||||
resolution: {integrity: sha512-TJxDm6OfAX2KJWJdMEVTwWke5Sc/E/RlnPGvGfS0W7+6ocy2xhDVQVh/KvC2Uf7kACs+gDytdusDSdWfWkaNzw==}
|
resolution: {integrity: sha512-TJxDm6OfAX2KJWJdMEVTwWke5Sc/E/RlnPGvGfS0W7+6ocy2xhDVQVh/KvC2Uf7kACs+gDytdusDSdWfWkaNzw==}
|
||||||
|
|
||||||
@ -8519,6 +8281,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
|
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
memoize-one@5.2.1:
|
||||||
|
resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
|
||||||
|
|
||||||
memorystream@0.3.1:
|
memorystream@0.3.1:
|
||||||
resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==}
|
resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==}
|
||||||
engines: {node: '>= 0.10.0'}
|
engines: {node: '>= 0.10.0'}
|
||||||
@ -9902,6 +9667,11 @@ packages:
|
|||||||
rc9@2.1.2:
|
rc9@2.1.2:
|
||||||
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
|
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
|
||||||
|
|
||||||
|
react-calendar-heatmap@1.10.0:
|
||||||
|
resolution: {integrity: sha512-e5vcrzMWzKIF710egr1FpjWyuDEFeZm39nvV25muc8Wtqqi8iDOfqREELeQ9Wouqf9hhj939gq0i+iAxo7KdSw==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=0.14.0'
|
||||||
|
|
||||||
react-css-styled@1.1.9:
|
react-css-styled@1.1.9:
|
||||||
resolution: {integrity: sha512-M7fJZ3IWFaIHcZEkoFOnkjdiUFmwd8d+gTh2bpqMOcnxy/0Gsykw4dsL4QBiKsxcGow6tETUa4NAUcmJF+/nfw==}
|
resolution: {integrity: sha512-M7fJZ3IWFaIHcZEkoFOnkjdiUFmwd8d+gTh2bpqMOcnxy/0Gsykw4dsL4QBiKsxcGow6tETUa4NAUcmJF+/nfw==}
|
||||||
|
|
||||||
@ -16153,6 +15923,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/range-parser@1.2.7': {}
|
'@types/range-parser@1.2.7': {}
|
||||||
|
|
||||||
|
'@types/react-calendar-heatmap@1.9.0':
|
||||||
|
dependencies:
|
||||||
|
'@types/react': 18.2.69
|
||||||
|
|
||||||
'@types/react-dom@18.2.18':
|
'@types/react-dom@18.2.18':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/react': 18.2.69
|
'@types/react': 18.2.69
|
||||||
@ -20145,6 +19919,8 @@ snapshots:
|
|||||||
|
|
||||||
media-typer@1.1.0: {}
|
media-typer@1.1.0: {}
|
||||||
|
|
||||||
|
memoize-one@5.2.1: {}
|
||||||
|
|
||||||
memorystream@0.3.1: {}
|
memorystream@0.3.1: {}
|
||||||
|
|
||||||
meow@12.1.1: {}
|
meow@12.1.1: {}
|
||||||
@ -21735,6 +21511,12 @@ snapshots:
|
|||||||
defu: 6.1.4
|
defu: 6.1.4
|
||||||
destr: 2.0.5
|
destr: 2.0.5
|
||||||
|
|
||||||
|
react-calendar-heatmap@1.10.0(react@18.3.1):
|
||||||
|
dependencies:
|
||||||
|
memoize-one: 5.2.1
|
||||||
|
prop-types: 15.8.1
|
||||||
|
react: 18.3.1
|
||||||
|
|
||||||
react-css-styled@1.1.9:
|
react-css-styled@1.1.9:
|
||||||
dependencies:
|
dependencies:
|
||||||
css-styled: 1.0.8
|
css-styled: 1.0.8
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user