Merge pull request #1 from RedPlanetHQ/harshith/graph

enh: memory graph visualisation
This commit is contained in:
Harshith Mullapudi 2025-06-19 14:01:49 +05:30 committed by GitHub
commit cae5470c70
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 113 additions and 106 deletions

2
.gitignore vendored
View File

@ -36,3 +36,5 @@ yarn-error.log*
# Misc # Misc
.DS_Store .DS_Store
*.pem *.pem
docker-compose.dev.yaml

View File

@ -53,6 +53,7 @@ export const Ingest = () => {
placeholder="Tell what you want to add" placeholder="Tell what you want to add"
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
disabled={isLoading} disabled={isLoading}
className="max-h-[400px]"
/> />
<div className="mt-2 flex justify-end"> <div className="mt-2 flex justify-end">

View File

@ -366,7 +366,25 @@ export const Graph = forwardRef<GraphRef, GraphProps>(
} }
}); });
// Create simulation with custom forces // Enhanced simulation for improved aesthetics and readability
// Parameters tuned for clear separation of clusters and minimal overlap,
// as seen in the provided image (distinct, well-separated groups).
const LINK_DISTANCE = 120; // Slightly shorter for tighter clusters
const LINK_STRENGTH = 0.6; // Stronger to keep clusters compact
const CHARGE_ISOLATED = -200; // Less repulsion for isolated nodes (keeps them closer)
const CHARGE_CONNECTED = -1000; // Strong repulsion for connected nodes (prevents crowding)
const COLLIDE_RADIUS = 32; // Smaller collision radius for less overlap
const COLLIDE_STRENGTH = 0.9; // Stronger collision to avoid overlap
const COLLIDE_ITER = 10; // More iterations for better separation
const CENTER_STRENGTH = 0.18; // Pull clusters more to center
const ISOLATED_RADIAL_DIST = 260; // Place isolated nodes further from center
const ISOLATED_RADIAL_STRENGTH = 0.28; // Stronger pull for isolated nodes
const NONISOLATED_RADIAL_STRENGTH = 0.06; // Slight pull for non-isolated
const VELOCITY_DECAY = 0.28; // Smoother, more stable layout
const ALPHA_DECAY = 0.035;
const ALPHA_MIN = 0.001;
const simulation = d3 const simulation = d3
.forceSimulation(nodes as d3.SimulationNodeDatum[]) .forceSimulation(nodes as d3.SimulationNodeDatum[])
.force( .force(
@ -374,41 +392,46 @@ export const Graph = forwardRef<GraphRef, GraphProps>(
d3 d3
.forceLink(links) .forceLink(links)
.id((d: any) => d.id) .id((d: any) => d.id)
.distance(200) .distance(LINK_DISTANCE)
.strength(0.2), .strength(LINK_STRENGTH),
) )
.force( .force(
"charge", "charge",
d3 d3
.forceManyBody() .forceManyBody()
.strength((d: any) => { .strength((d: any) =>
// Use a less negative strength for isolated nodes isolatedNodeIds.has(d.id) ? CHARGE_ISOLATED : CHARGE_CONNECTED,
// to pull them closer to the center )
return isolatedNodeIds.has(d.id) ? -500 : -3000; .distanceMin(20)
}) .distanceMax(600)
.distanceMin(20) .theta(0.9),
.distanceMax(500) )
.theta(0.8), .force(
"center",
d3.forceCenter(width / 2, height / 2).strength(CENTER_STRENGTH),
) )
.force("center", d3.forceCenter(width / 2, height / 2).strength(0.05))
.force( .force(
"collide", "collide",
d3.forceCollide().radius(50).strength(0.3).iterations(5), d3
.forceCollide()
.radius(COLLIDE_RADIUS)
.strength(COLLIDE_STRENGTH)
.iterations(COLLIDE_ITER),
) )
// Add a special gravity force for isolated nodes to pull them toward the center // Special gravity for isolated nodes to keep them separated and visible
.force( .force(
"isolatedGravity", "isolatedGravity",
d3 d3
.forceRadial( .forceRadial(ISOLATED_RADIAL_DIST, width / 2, height / 2)
100, // distance from center .strength((d: any) =>
width / 2, // center x isolatedNodeIds.has(d.id)
height / 2, // center y ? ISOLATED_RADIAL_STRENGTH
: NONISOLATED_RADIAL_STRENGTH,
),
) )
.strength((d: any) => (isolatedNodeIds.has(d.id) ? 0.15 : 0.01)), .velocityDecay(VELOCITY_DECAY)
) .alphaDecay(ALPHA_DECAY)
.velocityDecay(0.4) .alphaMin(ALPHA_MIN);
.alphaDecay(0.05)
.alphaMin(0.001);
simulationRef.current = simulation; simulationRef.current = simulation;

View File

@ -78,7 +78,7 @@ export function getUserQueue(userId: string) {
export const IngestBodyRequest = z.object({ export const IngestBodyRequest = z.object({
episodeBody: z.string(), episodeBody: z.string(),
referenceTime: z.string(), referenceTime: z.string(),
metadata: z.record(z.union([z.string(), z.number()])), metadata: z.record(z.union([z.string(), z.number(), z.boolean()])).optional(),
source: z.string(), source: z.string(),
spaceId: z.string().optional(), spaceId: z.string().optional(),
sessionId: z.string().optional(), sessionId: z.string().optional(),

View File

@ -6,7 +6,7 @@ import {
import { parse } from "@conform-to/zod"; import { parse } from "@conform-to/zod";
import { json } from "@remix-run/node"; import { json } from "@remix-run/node";
import { useState } from "react"; import { useState, useEffect } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { Ingest } from "~/components/dashboard/ingest"; import { Ingest } from "~/components/dashboard/ingest";
import { import {
@ -23,6 +23,7 @@ import { Search } from "~/components/dashboard";
import { SearchBodyRequest } from "./search"; import { SearchBodyRequest } from "./search";
import { SearchService } from "~/services/search.server"; import { SearchService } from "~/services/search.server";
// --- Only return userId in loader, fetch nodeLinks on client ---
export async function action({ request }: ActionFunctionArgs) { export async function action({ request }: ActionFunctionArgs) {
const userId = await requireUserId(request); const userId = await requireUserId(request);
const formData = await request.formData(); const formData = await request.formData();
@ -52,17 +53,46 @@ export async function action({ request }: ActionFunctionArgs) {
} }
export async function loader({ request }: LoaderFunctionArgs) { export async function loader({ request }: LoaderFunctionArgs) {
// Only return userId, not the heavy nodeLinks
const userId = await requireUserId(request); const userId = await requireUserId(request);
const nodeLinks = await getNodeLinks(userId); return { userId };
return nodeLinks;
} }
export default function Dashboard() { export default function Dashboard() {
const nodeLinks = useTypedLoaderData<typeof loader>(); const { userId } = useTypedLoaderData<typeof loader>();
const [size, setSize] = useState(15); const [size, setSize] = useState(15);
// State for nodeLinks and loading
const [nodeLinks, setNodeLinks] = useState<any[] | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
async function fetchNodeLinks() {
setLoading(true);
try {
const res = await fetch(
"/node-links?userId=" + encodeURIComponent(userId),
);
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();
return () => {
cancelled = true;
};
}, [userId]);
return ( return (
<ResizablePanelGroup direction="horizontal"> <ResizablePanelGroup direction="horizontal">
<ResizablePanel <ResizablePanel
@ -75,9 +105,15 @@ export default function Dashboard() {
<h3 className="text-lg font-medium">Graph</h3> <h3 className="text-lg font-medium">Graph</h3>
<p className="text-muted-foreground"> Your memory graph </p> <p className="text-muted-foreground"> Your memory graph </p>
<div className="bg-background-3 mt-2 grow rounded"> <div className="bg-background-3 mt-2 flex grow items-center justify-center rounded">
{typeof window !== "undefined" && ( {loading ? (
<GraphVisualization triplets={nodeLinks} /> <div className="flex h-full w-full flex-col items-center justify-center">
<div className="mb-2 h-8 w-8 animate-spin rounded-full border-b-2 border-gray-400" />
<span className="text-muted-foreground">Loading graph...</span>
</div>
) : (
typeof window !== "undefined" &&
nodeLinks && <GraphVisualization triplets={nodeLinks} />
)} )}
</div> </div>
</div> </div>
@ -85,7 +121,7 @@ export default function Dashboard() {
<ResizableHandle className="bg-border w-[0.5px]" /> <ResizableHandle className="bg-border w-[0.5px]" />
<ResizablePanel <ResizablePanel
className="rounded-md" className="overflow-auto"
collapsible={false} collapsible={false}
maxSize={50} maxSize={50}
minSize={25} minSize={25}

View File

@ -0,0 +1,10 @@
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);
}

View File

@ -126,9 +126,7 @@ export class KnowledgeGraphService {
statement: { ...statement, factEmbedding: undefined }, statement: { ...statement, factEmbedding: undefined },
provenance, provenance,
}; };
console.log("Triple (no embedding):", JSON.stringify(safeTriple));
} }
// console.log("Invalidated statements", invalidatedStatements);
// Save triples sequentially to avoid parallel processing issues // Save triples sequentially to avoid parallel processing issues
for (const triple of updatedTriples) { for (const triple of updatedTriples) {
@ -739,9 +737,6 @@ export class KnowledgeGraphService {
})), })),
}; };
console.log("entityTypes", JSON.stringify(entityTypes));
console.log("entities", JSON.stringify(context.entities));
// Create a prompt for the LLM to extract attributes // Create a prompt for the LLM to extract attributes
const messages = extractAttributes(context); const messages = extractAttributes(context);

View File

@ -623,9 +623,12 @@ async function wrapResponse(
response: Response, response: Response,
useCors: boolean, useCors: boolean,
): Promise<Response> { ): Promise<Response> {
return useCors // Prevent double CORS headers by checking if already present
? await apiCors(request, response, { if (useCors && !response.headers.has("access-control-allow-origin")) {
return await apiCors(request, response, {
exposedHeaders: ["x-sol-jwt", "x-sol-jwt-claims"], exposedHeaders: ["x-sol-jwt", "x-sol-jwt-claims"],
}) });
: response; }
return response;
} }

View File

@ -36,10 +36,6 @@ export function useMatchesData(
): UIMatch | undefined { ): UIMatch | undefined {
const matchingRoutes = useMatches(); const matchingRoutes = useMatches();
if (debug) {
console.log("matchingRoutes", matchingRoutes);
}
const paths = Array.isArray(id) ? id : [id]; const paths = Array.isArray(id) ? id : [id];
// Get the first matching route // Get the first matching route

View File

@ -1,59 +0,0 @@
version: "3.8"
services:
core:
container_name: core-app
image: redplanethq/core:${VERSION}
environment:
- NODE_ENV=${NODE_ENV}
- DATABASE_URL=${DATABASE_URL}
- DIRECT_URL=${DIRECT_URL}
- SESSION_SECRET=${SESSION_SECRET}
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
- MAGIC_LINK_SECRET=${MAGIC_LINK_SECRET}
- LOGIN_ORIGIN=${LOGIN_ORIGIN}
- APP_ORIGIN=${APP_ORIGIN}
- REDIS_HOST=${REDIS_HOST}
- REDIS_PORT=${REDIS_PORT}
- REDIS_TLS_DISABLED=${REDIS_TLS_DISABLED}
- NEO4J_URI=${NEO4J_URI}
- NEO4J_USERNAME=${NEO4J_USERNAME}
- NEO4J_PASSWORD=${NEO4J_PASSWORD}
- OPENAI_API_KEY=${OPENAI_API_KEY}
- AUTH_GOOGLE_CLIENT_ID=${AUTH_GOOGLE_CLIENT_ID}
- AUTH_GOOGLE_CLIENT_SECRET=${AUTH_GOOGLE_CLIENT_SECRET}
- ENABLE_EMAIL_LOGIN=${ENABLE_EMAIL_LOGIN}
ports:
- "3033:3000"
depends_on:
- redis
- neo4j
networks:
- core
redis:
container_name: core-redis
image: redis:7
ports:
- "6379:6379"
networks:
- core
neo4j:
container_name: core-neo4j
image: neo4j:5
environment:
- NEO4J_AUTH=${NEO4J_AUTH}
ports:
- "7474:7474"
- "7687:7687"
volumes:
- type: bind
source: /efs/neo4j
target: /data
networks:
- core
networks:
core:
driver: bridge