diff --git a/app/api/calculate-graph/route.ts b/app/api/calculate-graph/route.ts new file mode 100644 index 0000000..1fc0e88 --- /dev/null +++ b/app/api/calculate-graph/route.ts @@ -0,0 +1,99 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { UMAP } from 'umap-js'; + +/** + * POST /api/calculate-graph + * + * Calculates 3D coordinates for all nodes using UMAP dimensionality reduction. + * This route: + * 1. Fetches all nodes with embeddings but no 3D coordinates + * 2. Runs UMAP to reduce embeddings from 768-D to 3-D + * 3. Updates each node with its calculated 3D coordinates + */ +export async function POST(request: NextRequest) { + const cookieStore = await cookies(); + const surrealJwt = cookieStore.get('ponderants-auth')?.value; + + if (!surrealJwt) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + try { + // NOTE: For the hackathon, we use root credentials instead of JWT auth for simplicity. + // In production, this should use user-scoped authentication with proper SCOPE configuration. + const db = new (await import('surrealdb')).default(); + await db.connect(process.env.SURREALDB_URL!); + await db.signin({ + username: process.env.SURREALDB_USER!, + password: process.env.SURREALDB_PASS!, + }); + await db.use({ + namespace: process.env.SURREALDB_NS!, + database: process.env.SURREALDB_DB!, + }); + + // Get the user's DID from the JWT to filter nodes + const jwt = require('jsonwebtoken'); + const decoded = jwt.decode(surrealJwt) as { did: string }; + const userDid = decoded?.did; + + if (!userDid) { + return NextResponse.json({ error: 'Invalid authentication token' }, { status: 401 }); + } + + // 1. Fetch all nodes that have an embedding but no coords_3d (filtered by user_did) + const query = `SELECT id, embedding FROM node WHERE user_did = $userDid AND embedding != NONE AND coords_3d = NONE`; + const results = await db.query<[Array<{ id: string; embedding: number[] }>]>(query, { userDid }); + + const nodes = results[0] || []; + + if (nodes.length < 3) { + // UMAP needs at least 3 points to work well + return NextResponse.json( + { message: 'Not enough nodes to map. Create at least 3 nodes with content.' }, + { status: 200 } + ); + } + + console.log(`[Calculate Graph] Processing ${nodes.length} nodes for UMAP projection`); + + // 2. Prepare data for UMAP + const embeddings = nodes.map((n) => n.embedding); + + // 3. Run UMAP to reduce 768-D (or 1536-D) to 3-D + const umap = new UMAP({ + nComponents: 3, + nNeighbors: Math.min(15, nodes.length - 1), // nNeighbors must be < sample size + minDist: 0.1, + spread: 1.0, + }); + + console.log('[Calculate Graph] Running UMAP dimensionality reduction...'); + const coords_3d_array = await umap.fitAsync(embeddings); + console.log('[Calculate Graph] ✓ UMAP projection complete'); + + // 4. Update nodes in SurrealDB with their new 3D coords + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + const coords = coords_3d_array[i]; + + await db.merge(node.id, { + coords_3d: [coords[0], coords[1], coords[2]], + }); + } + + console.log(`[Calculate Graph] ✓ Updated ${nodes.length} nodes with 3D coordinates`); + + return NextResponse.json({ + success: true, + nodes_mapped: nodes.length, + }); + } catch (error) { + console.error('[Calculate Graph] Error:', error); + return NextResponse.json( + { error: 'Failed to calculate graph' }, + { status: 500 } + ); + } +} diff --git a/app/galaxy/page.tsx b/app/galaxy/page.tsx new file mode 100644 index 0000000..15f851e --- /dev/null +++ b/app/galaxy/page.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { Button, Box } from '@mantine/core'; +import { Suspense, useState } from 'react'; +import { ThoughtGalaxy } from '@/components/ThoughtGalaxy'; +import { notifications } from '@mantine/notifications'; + +export default function GalaxyPage() { + const [isCalculating, setIsCalculating] = useState(false); + // This key forces a re-render of the galaxy component + const [galaxyKey, setGalaxyKey] = useState(Date.now()); + + const handleCalculateGraph = async () => { + setIsCalculating(true); + try { + const response = await fetch('/api/calculate-graph', { method: 'POST' }); + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to calculate graph'); + } + + notifications.show({ + title: 'Success', + message: data.message || `Mapped ${data.nodes_mapped} nodes to 3D space`, + color: 'green', + }); + + // Refresh the galaxy component by changing its key + setGalaxyKey(Date.now()); + } catch (error) { + console.error(error); + notifications.show({ + title: 'Error', + message: error instanceof Error ? error.message : 'Failed to calculate graph', + color: 'red', + }); + } finally { + setIsCalculating(false); + } + }; + + return ( + + + + {/* R3F Canvas for the 3D visualization */} + Loading 3D Scene...}> + + + + ); +} diff --git a/components/ThoughtGalaxy.tsx b/components/ThoughtGalaxy.tsx new file mode 100644 index 0000000..474d606 --- /dev/null +++ b/components/ThoughtGalaxy.tsx @@ -0,0 +1,163 @@ +'use client'; + +import { Canvas } from '@react-three/fiber'; +import { + CameraControls, + Line, + Text, +} from '@react-three/drei'; +import { Suspense, useEffect, useRef, useState } from 'react'; +import Surreal from 'surrealdb'; + +// Define the shape of nodes and links from DB +interface NodeData { + id: string; + title: string; + coords_3d: [number, number, number]; +} + +interface LinkData { + in: string; // from node id + out: string; // to node id +} + +// 1. The 3D Node Component +function Node({ node, onNodeClick }: { node: NodeData; onNodeClick: (node: NodeData) => void }) { + const [hovered, setHovered] = useState(false); + const [clicked, setClicked] = useState(false); + + return ( + { + e.stopPropagation(); + onNodeClick(node); + setClicked(!clicked); + }} + onPointerOver={(e) => { + e.stopPropagation(); + setHovered(true); + }} + onPointerOut={() => setHovered(false)} + > + + + {/* Show title on hover or click */} + {(hovered || clicked) && ( + + {node.title} + + )} + + ); +} + +// 2. The Main Scene Component +export function ThoughtGalaxy() { + const [nodes, setNodes] = useState([]); + const [links, setLinks] = useState([]); + const cameraControlsRef = useRef(null); + + // Fetch data from SurrealDB on mount + useEffect(() => { + async function fetchData() { + // Client-side connection + const db = new Surreal(); + await db.connect(process.env.NEXT_PUBLIC_SURREALDB_WSS_URL!); + + // Get the token from the cookie + const tokenCookie = document.cookie + .split('; ') + .find((row) => row.startsWith('ponderants-auth=')); + + if (!tokenCookie) { + console.error('[ThoughtGalaxy] No auth token found'); + return; + } + + const token = tokenCookie.split('=')[1]; + await db.authenticate(token); + + // Fetch nodes that have coordinates + const nodeResults = await db.query<[NodeData[]]>( + 'SELECT id, title, coords_3d FROM node WHERE coords_3d != NONE' + ); + setNodes(nodeResults[0] || []); + + // Fetch links + const linkResults = await db.query<[LinkData[]]>('SELECT in, out FROM links_to'); + setLinks(linkResults[0] || []); + + console.log(`[ThoughtGalaxy] Loaded ${nodeResults[0]?.length || 0} nodes and ${linkResults[0]?.length || 0} links`); + } + fetchData(); + }, []); + + // Map links to node positions + const linkLines = links + .map((link) => { + const startNode = nodes.find((n) => n.id === link.in); + const endNode = nodes.find((n) => n.id === link.out); + if (startNode && endNode) { + return { + start: startNode.coords_3d, + end: endNode.coords_3d, + }; + } + return null; + }) + .filter(Boolean) as { start: [number, number, number]; end: [number, number, number] }[]; + + // Camera animation + const handleNodeClick = (node: NodeData) => { + if (cameraControlsRef.current) { + cameraControlsRef.current.smoothTime = 0.8; + cameraControlsRef.current.setLookAt( + node.coords_3d[0] + 1, + node.coords_3d[1] + 1, + node.coords_3d[2] + 1, + node.coords_3d[0], + node.coords_3d[1], + node.coords_3d[2], + true // Enable smooth transition + ); + } + }; + + return ( + + + + + + + + {/* Render all nodes */} + {nodes.map((node) => ( + + ))} + + {/* Render all links */} + {linkLines.map((line, i) => ( + + ))} + + + + ); +} diff --git a/lib/db.ts b/lib/db.ts index 2b42e1b..41f95ab 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -1,7 +1,5 @@ import Surreal from 'surrealdb'; -const db = new Surreal(); - /** * Connects to the SurrealDB instance and authenticates with the user's JWT. * This enforces row-level security defined in the schema. @@ -18,10 +16,11 @@ export async function connectToDB(token: string): Promise { throw new Error('SurrealDB configuration is missing'); } - // Connect if not already connected - if (!db.status) { - await db.connect(SURREALDB_URL); - } + // Create a new instance for each request to avoid connection state issues + const db = new Surreal(); + + // Connect to SurrealDB + await db.connect(SURREALDB_URL); // Authenticate as the user for this request. // This enforces the row-level security (PERMISSIONS) diff --git a/tests/magnitude/11-viz.mag.ts b/tests/magnitude/11-viz.mag.ts new file mode 100644 index 0000000..ad6ad18 --- /dev/null +++ b/tests/magnitude/11-viz.mag.ts @@ -0,0 +1,35 @@ +import { test } from 'magnitude-test'; + +test('Galaxy page renders correctly', async (agent) => { + await agent.act('Navigate to /galaxy'); + await agent.check('The text "Calculate My Graph" is visible on the screen'); + await agent.check('The page displays a dark background (the 3D canvas area)'); +}); + +test('[Happy Path] User can navigate to galaxy from chat', async (agent) => { + await agent.act('Navigate to /chat'); + // In the future, there should be a navigation link to /galaxy + // For now, we just verify direct navigation works + await agent.act('Navigate to /galaxy'); + await agent.check('The page URL contains "/galaxy"'); + await agent.check('The text "Calculate My Graph" is visible on the screen'); +}); + +test('[Happy Path] Calculate Graph button shows loading state', async (agent) => { + await agent.act('Navigate to /galaxy'); + await agent.act('Click the "Calculate My Graph" button'); + + // The button should show a loading state while processing + // Note: This may be very fast if there are no nodes or few nodes + await agent.check('The "Calculate My Graph" button is in a loading state or has completed'); +}); + +test('[Unhappy Path] Calculate Graph with no nodes shows appropriate feedback', async (agent) => { + // This test verifies the button completes without errors + await agent.act('Navigate to /galaxy'); + await agent.check('The text "Calculate My Graph" is visible on the screen'); + await agent.act('Click the "Calculate My Graph" button'); + + // The button should complete (with or without auth, it handles the request) + await agent.check('The "Calculate My Graph" button is visible and clickable'); +});