diff --git a/.example.env b/.example.env
index ff6d714..7c6c28c 100644
--- a/.example.env
+++ b/.example.env
@@ -11,6 +11,8 @@ SURREALDB_JWT_SECRET=your-secret-key-here-change-in-production
# Google AI API Key (for Gemini embeddings and chat)
GOOGLE_GENERATIVE_AI_API_KEY=your-google-ai-api-key
GOOGLE_AI_MODEL=gemini-pro-latest
+GOOGLE_EMBEDDING_MODEL=gemini-embedding-001
+GOOGLE_EMBEDDING_DIMENSIONS=3072
# Deepgram API Key (for voice-to-text)
DEEPGRAM_API_KEY=your-deepgram-api-key
diff --git a/app/api/galaxy/route.ts b/app/api/galaxy/route.ts
index c4b5234..ed82065 100644
--- a/app/api/galaxy/route.ts
+++ b/app/api/galaxy/route.ts
@@ -6,6 +6,9 @@ import { verifySurrealJwt } from '@/lib/auth/jwt';
interface NodeData {
id: string;
title: string;
+ body: string;
+ user_did: string;
+ atp_uri: string;
coords_3d: [number, number, number];
}
@@ -41,7 +44,7 @@ export async function GET(request: NextRequest) {
// Fetch nodes that have 3D coordinates
const nodesQuery = `
- SELECT id, title, coords_3d
+ SELECT id, title, body, user_did, atp_uri, coords_3d
FROM node
WHERE user_did = $userDid AND coords_3d != NONE
`;
diff --git a/app/api/nodes/route.ts b/app/api/nodes/route.ts
index 7e2e33c..a499ad6 100644
--- a/app/api/nodes/route.ts
+++ b/app/api/nodes/route.ts
@@ -42,6 +42,10 @@ export async function POST(request: NextRequest) {
const createdAt = new Date().toISOString();
+ // Generate a unique node ID upfront (we'll use this for the detail page link)
+ const nodeId = `node:${crypto.randomUUID()}`;
+ const detailUrl = `${process.env.NEXT_PUBLIC_APP_URL || 'https://ponderants.app'}/galaxy/${encodeURIComponent(nodeId)}`;
+
// --- Step 1: Write to Source of Truth (ATproto) ---
let atp_uri: string;
let atp_cid: string;
@@ -60,41 +64,137 @@ export async function POST(request: NextRequest) {
console.log('[POST /api/nodes] Successfully restored OAuth session and created agent');
// Bluesky posts are limited to 300 graphemes
- // Format a concise post with title and truncated body
- const maxLength = 280; // Leave room for ellipsis
+ // We'll create a thread for longer content
const fullText = `${title}\n\n${body}`;
- let postText: string;
- if (fullText.length <= maxLength) {
- postText = fullText;
- } else {
- // Truncate at word boundary
- const truncated = fullText.substring(0, maxLength);
- const lastSpace = truncated.lastIndexOf(' ');
- postText = truncated.substring(0, lastSpace > 0 ? lastSpace : maxLength) + '...';
+ // Helper to count graphemes using RichText
+ function getGraphemeLength(text: string): number {
+ const rt = new RichText({ text });
+ return rt.graphemeLength;
}
- // Format the text as RichText to detect links, mentions, etc.
- const rt = new RichText({ text: postText });
- await rt.detectFacets(agent);
+ // Reserve space for the detail page link in first post
+ const linkSuffix = `\n\nRead more: ${detailUrl}`;
+ const linkGraphemes = getGraphemeLength(linkSuffix);
- // Create the ATproto record using standard Bluesky post collection
- // This works with OAuth scope 'atproto' without requiring granular permissions
- const response = await agent.api.com.atproto.repo.createRecord({
- repo: userDid,
- collection: 'app.bsky.feed.post',
- record: {
+ // For first post, we need room for the content + link
+ const firstPostMaxGraphemes = 300 - linkGraphemes - 5; // 5 char buffer
+
+ // For subsequent thread posts, we need room for the thread indicator like "(2/3) "
+ const threadIndicatorMaxGraphemes = 10; // Max graphemes for "(99/99) "
+ const threadPostMaxGraphemes = 300 - threadIndicatorMaxGraphemes - 5; // 5 char buffer
+
+ // Helper function to split text into chunks at word boundaries using grapheme counting
+ function splitIntoChunks(text: string, firstMaxGraphemes: number, otherMaxGraphemes: number): string[] {
+ const chunks: string[] = [];
+ let remainingText = text;
+ let isFirst = true;
+
+ while (remainingText.length > 0) {
+ const maxGraphemes = isFirst ? firstMaxGraphemes : otherMaxGraphemes;
+ const graphemeCount = getGraphemeLength(remainingText);
+
+ if (graphemeCount <= maxGraphemes) {
+ chunks.push(remainingText);
+ break;
+ }
+
+ // Find last space within maxGraphemes
+ let testText = remainingText;
+
+ // Binary search for the right split point
+ while (getGraphemeLength(testText) > maxGraphemes) {
+ const lastSpace = testText.lastIndexOf(' ');
+ if (lastSpace === -1 || lastSpace < testText.length * 0.5) {
+ // No good space found, just hard cut at character boundary
+ // Start from the end and work backwards
+ testText = testText.substring(0, Math.floor(testText.length * 0.9));
+ } else {
+ testText = testText.substring(0, lastSpace);
+ }
+ }
+
+ chunks.push(testText.trim());
+ remainingText = remainingText.substring(testText.length).trim();
+ isFirst = false;
+ }
+
+ return chunks;
+ }
+
+ let chunks: string[];
+ if (getGraphemeLength(fullText) <= firstPostMaxGraphemes) {
+ // Single post
+ chunks = [fullText];
+ } else {
+ // Split into thread, accounting for link on first post and thread indicators on others
+ chunks = splitIntoChunks(fullText, firstPostMaxGraphemes, threadPostMaxGraphemes);
+ }
+
+ // Create the thread posts
+ let previousPost: { uri: string; cid: string } | null = null;
+ const threadUris: string[] = [];
+
+ for (let i = 0; i < chunks.length; i++) {
+ const isFirstPost = i === 0;
+ let postText = chunks[i];
+
+ // Add thread indicator if not first/last post
+ if (chunks.length > 1 && !isFirstPost) {
+ postText = `(${i + 1}/${chunks.length}) ${postText}`;
+ }
+
+ // Add detail page link to first post
+ if (isFirstPost) {
+ postText += linkSuffix;
+ }
+
+ // Validate grapheme count before posting
+ const finalGraphemes = getGraphemeLength(postText);
+ if (finalGraphemes > 300) {
+ console.error(`[POST /api/nodes] Post ${i + 1} exceeds 300 graphemes (${finalGraphemes})`);
+ throw new Error(`Post exceeds 300 grapheme limit: ${finalGraphemes} graphemes`);
+ }
+
+ // Format the text as RichText to detect links, mentions, etc.
+ const rt = new RichText({ text: postText });
+ await rt.detectFacets(agent);
+
+ // Prepare the post record
+ const postRecord: any = {
$type: 'app.bsky.feed.post',
text: rt.text,
facets: rt.facets,
- createdAt,
- // Add a tag to identify this as a Ponderants node
+ createdAt: new Date().toISOString(),
tags: ['ponderants-node'],
- },
- });
+ };
- atp_uri = response.data.uri;
- atp_cid = response.data.cid;
+ // If not first post, add reply reference
+ if (previousPost && threadUris.length > 0) {
+ // Get the first post (root) CID from the stored thread data
+ const rootCid = atp_cid; // First post's CID
+ postRecord.reply = {
+ root: { uri: threadUris[0], cid: rootCid },
+ parent: { uri: previousPost.uri, cid: previousPost.cid },
+ };
+ }
+
+ // Create the post
+ const response = await agent.api.com.atproto.repo.createRecord({
+ repo: userDid,
+ collection: 'app.bsky.feed.post',
+ record: postRecord,
+ });
+
+ threadUris.push(response.data.uri);
+ previousPost = { uri: response.data.uri, cid: response.data.cid };
+
+ // Store the first post's URI as the main atp_uri
+ if (isFirstPost) {
+ atp_uri = response.data.uri;
+ atp_cid = response.data.cid;
+ }
+ }
console.log('[POST /api/nodes] ✓ Published to ATproto PDS as standard post:', atp_uri);
} catch (error) {
@@ -135,7 +235,7 @@ export async function POST(request: NextRequest) {
nodeData.embedding = embedding;
}
- const newNode = await db.create('node', nodeData);
+ const newNode = await db.create(nodeId, nodeData);
// Handle linking
if (links && links.length > 0) {
diff --git a/app/api/suggest-links/route.ts b/app/api/suggest-links/route.ts
index 3d616a2..4d0f439 100644
--- a/app/api/suggest-links/route.ts
+++ b/app/api/suggest-links/route.ts
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { connectToDB } from '@/lib/db';
-import { generateEmbedding } from '@/lib/ai';
+import { generateEmbedding, EMBEDDING_DIMENSIONS } from '@/lib/ai';
import { verifySurrealJwt } from '@/lib/auth/jwt';
/**
@@ -36,6 +36,19 @@ export async function POST(request: NextRequest) {
try {
// 1. Generate embedding for the current draft
const draftEmbedding = await generateEmbedding(body);
+ console.log('[Suggest Links] Draft embedding dimension:', draftEmbedding.length);
+
+ // Validate that the embedding has the expected dimensions
+ if (draftEmbedding.length !== EMBEDDING_DIMENSIONS) {
+ console.error('[Suggest Links] Embedding dimension mismatch:', {
+ expected: EMBEDDING_DIMENSIONS,
+ actual: draftEmbedding.length,
+ });
+ return NextResponse.json(
+ { error: `Embedding dimension mismatch: expected ${EMBEDDING_DIMENSIONS}, got ${draftEmbedding.length}` },
+ { status: 500 }
+ );
+ }
// 2. Connect to DB with root credentials
const db = await connectToDB();
@@ -44,6 +57,7 @@ export async function POST(request: NextRequest) {
// This query finds the 5 closest nodes in the 'node' table
// using cosine similarity on the 'embedding' field.
// We filter by user_did to ensure users only see their own nodes.
+ // Note: All embeddings in the DB should be EMBEDDING_DIMENSIONS length
const query = `
SELECT
id,
@@ -52,7 +66,7 @@ export async function POST(request: NextRequest) {
atp_uri,
vector::similarity::cosine(embedding, $draft_embedding) AS score
FROM node
- WHERE user_did = $user_did
+ WHERE user_did = $user_did AND embedding != NONE
ORDER BY score DESC
LIMIT 5;
`;
diff --git a/app/chat/page.tsx b/app/chat/page.tsx
index d88a9e4..0146459 100644
--- a/app/chat/page.tsx
+++ b/app/chat/page.tsx
@@ -248,9 +248,9 @@ export default function ChatPage() {
}}
w="80%"
>
-
- {m.role === 'user' ? 'You' : 'AI'}
-
+
+ {m.role === 'user' ? 'YOU' : 'INQUISITOR'}
+
{(() => {
if ('parts' in m && Array.isArray((m as any).parts)) {
return (m as any).parts.map((part: any, i: number) => {
@@ -279,9 +279,9 @@ export default function ChatPage() {
style={{ alignSelf: 'flex-start', backgroundColor: '#212529' }}
w="80%"
>
-
- AI
-
+
+ INQUISITOR
+
@@ -301,9 +301,9 @@ export default function ChatPage() {
style={{ alignSelf: 'flex-end', backgroundColor: '#343a40' }}
w="80%"
>
-
- You (speaking...)
-
+
+ YOU (speaking...)
+
{transcript}
)}
diff --git a/app/edit/page.tsx b/app/edit/page.tsx
index 7973d5b..b2d409d 100644
--- a/app/edit/page.tsx
+++ b/app/edit/page.tsx
@@ -21,9 +21,10 @@ import {
Checkbox,
Badge,
Loader,
+ Alert,
} from '@mantine/core';
import { useState, useEffect } from 'react';
-import { IconDeviceFloppy, IconX, IconRefresh } from '@tabler/icons-react';
+import { IconDeviceFloppy, IconX, IconRefresh, IconInfoCircle } from '@tabler/icons-react';
import { useAppMachine } from '@/hooks/useAppMachine';
import { useSelector } from '@xstate/react';
import { notifications } from '@mantine/notifications';
@@ -197,6 +198,10 @@ export default function EditPage() {
+ } title="Public Node" color="blue" variant="light">
+ All nodes published through Ponderants are publicly visible to everyone. Your thoughts will be posted to Bluesky and visible in the public knowledge graph.
+
+
;
+}
+
+export default function NodeDetailPage({ params }: NodeDetailPageProps) {
+ const { 'node-id': nodeId } = use(params);
+
+ return (
+
+ {/* R3F Canvas for the 3D visualization, focused on specific node */}
+
+ Loading your thought galaxy...
+
+ }>
+
+
+
+ );
+}
diff --git a/app/galaxy/page.tsx b/app/galaxy/page.tsx
index 5e14352..0013f66 100644
--- a/app/galaxy/page.tsx
+++ b/app/galaxy/page.tsx
@@ -6,10 +6,10 @@ import { ThoughtGalaxy } from '@/components/ThoughtGalaxy';
export default function GalaxyPage() {
return (
-
+
{/* R3F Canvas for the 3D visualization */}
+
Loading your thought galaxy...
}>
diff --git a/components/Navigation/DesktopSidebar.tsx b/components/Navigation/DesktopSidebar.tsx
index c247f79..0df34da 100644
--- a/components/Navigation/DesktopSidebar.tsx
+++ b/components/Navigation/DesktopSidebar.tsx
@@ -111,11 +111,8 @@ export function DesktopSidebar() {
- }
- variant="filled"
- />
+ {/* User Menu - styled like other nav items */}
+
{/* Development state panel */}
{process.env.NODE_ENV === 'development' && (
diff --git a/components/ThoughtGalaxy.tsx b/components/ThoughtGalaxy.tsx
index 6d3bc32..b265894 100644
--- a/components/ThoughtGalaxy.tsx
+++ b/components/ThoughtGalaxy.tsx
@@ -7,13 +7,17 @@ import {
Text,
} from '@react-three/drei';
import { Suspense, useEffect, useRef, useState } from 'react';
-import { Stack, Text as MantineText } from '@mantine/core';
+import { Stack, Text as MantineText, Paper, Title, Box, CloseButton, Group, Anchor } from '@mantine/core';
+import { useRouter, usePathname } from 'next/navigation';
import * as THREE from 'three';
// Define the shape of nodes and links from API
interface NodeData {
id: string;
title: string;
+ body?: string;
+ user_did: string;
+ atp_uri: string;
coords_3d: [number, number, number];
}
@@ -23,17 +27,27 @@ interface LinkData {
}
// 1. The 3D Node Component
-function Node({ node, onNodeClick }: { node: NodeData; onNodeClick: (node: NodeData) => void }) {
+function Node({
+ node,
+ isFocused,
+ onNodeClick
+}: {
+ node: NodeData;
+ isFocused: boolean;
+ onNodeClick: (node: NodeData) => void;
+}) {
const [hovered, setHovered] = useState(false);
- const [clicked, setClicked] = useState(false);
+
+ const isExpanded = isFocused || hovered;
+ const scale = isFocused ? 2.5 : 1;
return (
{
e.stopPropagation();
onNodeClick(node);
- setClicked(!clicked);
}}
onPointerOver={(e) => {
e.stopPropagation();
@@ -43,15 +57,15 @@ function Node({ node, onNodeClick }: { node: NodeData; onNodeClick: (node: NodeD
>
- {/* Show title on hover or click */}
- {(hovered || clicked) && (
+ {/* Show title on hover or focus */}
+ {isExpanded && (
([]);
const [links, setLinks] = useState([]);
+ const [selectedNode, setSelectedNode] = useState(null);
const cameraControlsRef = useRef(null);
const hasFitCamera = useRef(false);
+ const hasFocusedNode = useRef(null);
// Fetch data from API on mount and poll for updates
useEffect(() => {
@@ -148,14 +166,42 @@ export function ThoughtGalaxy() {
// Fit camera when nodes change and we haven't fitted yet
useEffect(() => {
- if (!hasFitCamera.current && nodes.length > 0) {
+ if (!hasFitCamera.current && nodes.length > 0 && !focusNodeId) {
+ // Only auto-fit if we're not focusing on a specific node
// Try to fit after a short delay to ensure Canvas is ready
const timer = setTimeout(() => {
fitCameraToNodes();
}, 100);
return () => clearTimeout(timer);
}
- }, [nodes]);
+ }, [nodes, focusNodeId]);
+
+ // Auto-focus on specific node if focusNodeId is provided
+ useEffect(() => {
+ if (focusNodeId && nodes.length > 0) {
+ const focusNode = nodes.find((n) => n.id === focusNodeId);
+ if (focusNode) {
+ console.log('[ThoughtGalaxy] Focusing on node:', focusNodeId);
+
+ // Always update selected node when focusNodeId changes (don't wait for camera ref)
+ setSelectedNode(focusNode);
+
+ // Move camera if ref is available and we haven't focused this specific node yet
+ if (cameraControlsRef.current && (!hasFocusedNode.current || hasFocusedNode.current !== focusNodeId)) {
+ cameraControlsRef.current.setLookAt(
+ focusNode.coords_3d[0],
+ focusNode.coords_3d[1],
+ focusNode.coords_3d[2] + 2, // Position camera 2 units in front
+ focusNode.coords_3d[0],
+ focusNode.coords_3d[1],
+ focusNode.coords_3d[2],
+ hasFocusedNode.current ? true : false // Animate if not initial load
+ );
+ hasFocusedNode.current = focusNodeId;
+ }
+ }
+ }
+ }, [focusNodeId, nodes]);
// Map links to node positions
const linkLines = links
@@ -174,14 +220,30 @@ export function ThoughtGalaxy() {
// Camera animation on node click
const handleNodeClick = (node: NodeData) => {
- if (cameraControlsRef.current) {
- // Smoothly move to look at the clicked node
- cameraControlsRef.current.moveTo(
- node.coords_3d[0],
- node.coords_3d[1],
- node.coords_3d[2],
- true // Animate
- );
+ const targetPath = `/galaxy/${encodeURIComponent(node.id)}`;
+
+ // Set selected node immediately for responsive UI
+ setSelectedNode(node);
+
+ // Only navigate if we're not already on this node's page
+ if (pathname !== targetPath) {
+ // Clear the focused node ref to ensure camera animates on next render
+ hasFocusedNode.current = null;
+ // Use replace instead of push to avoid page reload issues
+ router.replace(targetPath);
+ } else {
+ // Already on this page, just animate camera to node
+ if (cameraControlsRef.current) {
+ cameraControlsRef.current.setLookAt(
+ node.coords_3d[0],
+ node.coords_3d[1],
+ node.coords_3d[2] + 2,
+ node.coords_3d[0],
+ node.coords_3d[1],
+ node.coords_3d[2],
+ true // Animate
+ );
+ }
}
};
@@ -202,18 +264,73 @@ export function ThoughtGalaxy() {
}
return (
-