fix: Correct OAuth localhost/127.0.0.1 config and fix grapheme counting for Bluesky posts
- Fixed OAuth client configuration to properly use localhost for client_id and 127.0.0.1 for redirect_uris per RFC 8252 and ATproto spec - Added proper grapheme counting using RichText API instead of character length - Fixed thread splitting to account for link suffix and thread indicators in grapheme limits - Added GOOGLE_EMBEDDING_DIMENSIONS env var to all env files - Added clear-nodes.ts utility script for database management - Added galaxy node detail page route 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,8 @@ SURREALDB_JWT_SECRET=your-secret-key-here-change-in-production
|
|||||||
# Google AI API Key (for Gemini embeddings and chat)
|
# Google AI API Key (for Gemini embeddings and chat)
|
||||||
GOOGLE_GENERATIVE_AI_API_KEY=your-google-ai-api-key
|
GOOGLE_GENERATIVE_AI_API_KEY=your-google-ai-api-key
|
||||||
GOOGLE_AI_MODEL=gemini-pro-latest
|
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 (for voice-to-text)
|
||||||
DEEPGRAM_API_KEY=your-deepgram-api-key
|
DEEPGRAM_API_KEY=your-deepgram-api-key
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { verifySurrealJwt } from '@/lib/auth/jwt';
|
|||||||
interface NodeData {
|
interface NodeData {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
body: string;
|
||||||
|
user_did: string;
|
||||||
|
atp_uri: string;
|
||||||
coords_3d: [number, number, number];
|
coords_3d: [number, number, number];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +44,7 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
// Fetch nodes that have 3D coordinates
|
// Fetch nodes that have 3D coordinates
|
||||||
const nodesQuery = `
|
const nodesQuery = `
|
||||||
SELECT id, title, coords_3d
|
SELECT id, title, body, user_did, atp_uri, coords_3d
|
||||||
FROM node
|
FROM node
|
||||||
WHERE user_did = $userDid AND coords_3d != NONE
|
WHERE user_did = $userDid AND coords_3d != NONE
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const createdAt = new Date().toISOString();
|
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) ---
|
// --- Step 1: Write to Source of Truth (ATproto) ---
|
||||||
let atp_uri: string;
|
let atp_uri: string;
|
||||||
let atp_cid: 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');
|
console.log('[POST /api/nodes] Successfully restored OAuth session and created agent');
|
||||||
|
|
||||||
// Bluesky posts are limited to 300 graphemes
|
// Bluesky posts are limited to 300 graphemes
|
||||||
// Format a concise post with title and truncated body
|
// We'll create a thread for longer content
|
||||||
const maxLength = 280; // Leave room for ellipsis
|
|
||||||
const fullText = `${title}\n\n${body}`;
|
const fullText = `${title}\n\n${body}`;
|
||||||
|
|
||||||
let postText: string;
|
// Helper to count graphemes using RichText
|
||||||
if (fullText.length <= maxLength) {
|
function getGraphemeLength(text: string): number {
|
||||||
postText = fullText;
|
const rt = new RichText({ text });
|
||||||
} else {
|
return rt.graphemeLength;
|
||||||
// Truncate at word boundary
|
|
||||||
const truncated = fullText.substring(0, maxLength);
|
|
||||||
const lastSpace = truncated.lastIndexOf(' ');
|
|
||||||
postText = truncated.substring(0, lastSpace > 0 ? lastSpace : maxLength) + '...';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format the text as RichText to detect links, mentions, etc.
|
// Reserve space for the detail page link in first post
|
||||||
const rt = new RichText({ text: postText });
|
const linkSuffix = `\n\nRead more: ${detailUrl}`;
|
||||||
await rt.detectFacets(agent);
|
const linkGraphemes = getGraphemeLength(linkSuffix);
|
||||||
|
|
||||||
// Create the ATproto record using standard Bluesky post collection
|
// For first post, we need room for the content + link
|
||||||
// This works with OAuth scope 'atproto' without requiring granular permissions
|
const firstPostMaxGraphemes = 300 - linkGraphemes - 5; // 5 char buffer
|
||||||
const response = await agent.api.com.atproto.repo.createRecord({
|
|
||||||
repo: userDid,
|
// For subsequent thread posts, we need room for the thread indicator like "(2/3) "
|
||||||
collection: 'app.bsky.feed.post',
|
const threadIndicatorMaxGraphemes = 10; // Max graphemes for "(99/99) "
|
||||||
record: {
|
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',
|
$type: 'app.bsky.feed.post',
|
||||||
text: rt.text,
|
text: rt.text,
|
||||||
facets: rt.facets,
|
facets: rt.facets,
|
||||||
createdAt,
|
createdAt: new Date().toISOString(),
|
||||||
// Add a tag to identify this as a Ponderants node
|
|
||||||
tags: ['ponderants-node'],
|
tags: ['ponderants-node'],
|
||||||
},
|
};
|
||||||
});
|
|
||||||
|
|
||||||
atp_uri = response.data.uri;
|
// If not first post, add reply reference
|
||||||
atp_cid = response.data.cid;
|
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);
|
console.log('[POST /api/nodes] ✓ Published to ATproto PDS as standard post:', atp_uri);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -135,7 +235,7 @@ export async function POST(request: NextRequest) {
|
|||||||
nodeData.embedding = embedding;
|
nodeData.embedding = embedding;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newNode = await db.create('node', nodeData);
|
const newNode = await db.create(nodeId, nodeData);
|
||||||
|
|
||||||
// Handle linking
|
// Handle linking
|
||||||
if (links && links.length > 0) {
|
if (links && links.length > 0) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { connectToDB } from '@/lib/db';
|
import { connectToDB } from '@/lib/db';
|
||||||
import { generateEmbedding } from '@/lib/ai';
|
import { generateEmbedding, EMBEDDING_DIMENSIONS } from '@/lib/ai';
|
||||||
import { verifySurrealJwt } from '@/lib/auth/jwt';
|
import { verifySurrealJwt } from '@/lib/auth/jwt';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,6 +36,19 @@ export async function POST(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
// 1. Generate embedding for the current draft
|
// 1. Generate embedding for the current draft
|
||||||
const draftEmbedding = await generateEmbedding(body);
|
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
|
// 2. Connect to DB with root credentials
|
||||||
const db = await connectToDB();
|
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
|
// This query finds the 5 closest nodes in the 'node' table
|
||||||
// using cosine similarity on the 'embedding' field.
|
// using cosine similarity on the 'embedding' field.
|
||||||
// We filter by user_did to ensure users only see their own nodes.
|
// 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 = `
|
const query = `
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
@@ -52,7 +66,7 @@ export async function POST(request: NextRequest) {
|
|||||||
atp_uri,
|
atp_uri,
|
||||||
vector::similarity::cosine(embedding, $draft_embedding) AS score
|
vector::similarity::cosine(embedding, $draft_embedding) AS score
|
||||||
FROM node
|
FROM node
|
||||||
WHERE user_did = $user_did
|
WHERE user_did = $user_did AND embedding != NONE
|
||||||
ORDER BY score DESC
|
ORDER BY score DESC
|
||||||
LIMIT 5;
|
LIMIT 5;
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -248,9 +248,9 @@ export default function ChatPage() {
|
|||||||
}}
|
}}
|
||||||
w="80%"
|
w="80%"
|
||||||
>
|
>
|
||||||
<Text fw={700} size="sm">
|
<Title order={6} size="sm">
|
||||||
{m.role === 'user' ? 'You' : 'AI'}
|
{m.role === 'user' ? 'YOU' : 'INQUISITOR'}
|
||||||
</Text>
|
</Title>
|
||||||
{(() => {
|
{(() => {
|
||||||
if ('parts' in m && Array.isArray((m as any).parts)) {
|
if ('parts' in m && Array.isArray((m as any).parts)) {
|
||||||
return (m as any).parts.map((part: any, i: number) => {
|
return (m as any).parts.map((part: any, i: number) => {
|
||||||
@@ -279,9 +279,9 @@ export default function ChatPage() {
|
|||||||
style={{ alignSelf: 'flex-start', backgroundColor: '#212529' }}
|
style={{ alignSelf: 'flex-start', backgroundColor: '#212529' }}
|
||||||
w="80%"
|
w="80%"
|
||||||
>
|
>
|
||||||
<Text fw={700} size="sm">
|
<Title order={6} size="sm">
|
||||||
AI
|
INQUISITOR
|
||||||
</Text>
|
</Title>
|
||||||
<Group gap="xs" mt="xs">
|
<Group gap="xs" mt="xs">
|
||||||
<Loader size="xs" />
|
<Loader size="xs" />
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
@@ -301,9 +301,9 @@ export default function ChatPage() {
|
|||||||
style={{ alignSelf: 'flex-end', backgroundColor: '#343a40' }}
|
style={{ alignSelf: 'flex-end', backgroundColor: '#343a40' }}
|
||||||
w="80%"
|
w="80%"
|
||||||
>
|
>
|
||||||
<Text fw={700} size="sm">
|
<Title order={6} size="sm">
|
||||||
You (speaking...)
|
YOU (speaking...)
|
||||||
</Text>
|
</Title>
|
||||||
<Text style={{ whiteSpace: 'pre-wrap' }}>{transcript}</Text>
|
<Text style={{ whiteSpace: 'pre-wrap' }}>{transcript}</Text>
|
||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -21,9 +21,10 @@ import {
|
|||||||
Checkbox,
|
Checkbox,
|
||||||
Badge,
|
Badge,
|
||||||
Loader,
|
Loader,
|
||||||
|
Alert,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useState, useEffect } from 'react';
|
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 { useAppMachine } from '@/hooks/useAppMachine';
|
||||||
import { useSelector } from '@xstate/react';
|
import { useSelector } from '@xstate/react';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
@@ -197,6 +198,10 @@ export default function EditPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
<Alert icon={<IconInfoCircle />} 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.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
<Paper p="xl" withBorder style={{ flex: 1 }}>
|
<Paper p="xl" withBorder style={{ flex: 1 }}>
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<TextInput
|
<TextInput
|
||||||
|
|||||||
28
app/galaxy/[node-id]/page.tsx
Normal file
28
app/galaxy/[node-id]/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Box, Text, Stack } from '@mantine/core';
|
||||||
|
import { Suspense, use } from 'react';
|
||||||
|
import { ThoughtGalaxy } from '@/components/ThoughtGalaxy';
|
||||||
|
|
||||||
|
interface NodeDetailPageProps {
|
||||||
|
params: Promise<{
|
||||||
|
'node-id': string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NodeDetailPage({ params }: NodeDetailPageProps) {
|
||||||
|
const { 'node-id': nodeId } = use(params);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box style={{ height: '100%', width: '100%', position: 'relative' }}>
|
||||||
|
{/* R3F Canvas for the 3D visualization, focused on specific node */}
|
||||||
|
<Suspense fallback={
|
||||||
|
<Stack align="center" justify="center" style={{ height: '100%' }}>
|
||||||
|
<Text c="dimmed">Loading your thought galaxy...</Text>
|
||||||
|
</Stack>
|
||||||
|
}>
|
||||||
|
<ThoughtGalaxy focusNodeId={nodeId} />
|
||||||
|
</Suspense>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,10 +6,10 @@ import { ThoughtGalaxy } from '@/components/ThoughtGalaxy';
|
|||||||
|
|
||||||
export default function GalaxyPage() {
|
export default function GalaxyPage() {
|
||||||
return (
|
return (
|
||||||
<Box style={{ height: '100vh', width: '100vw', position: 'relative' }}>
|
<Box style={{ height: '100%', width: '100%', position: 'relative' }}>
|
||||||
{/* R3F Canvas for the 3D visualization */}
|
{/* R3F Canvas for the 3D visualization */}
|
||||||
<Suspense fallback={
|
<Suspense fallback={
|
||||||
<Stack align="center" justify="center" style={{ height: '100vh' }}>
|
<Stack align="center" justify="center" style={{ height: '100%' }}>
|
||||||
<Text c="dimmed">Loading your thought galaxy...</Text>
|
<Text c="dimmed">Loading your thought galaxy...</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
}>
|
}>
|
||||||
|
|||||||
@@ -111,11 +111,8 @@ export function DesktopSidebar() {
|
|||||||
|
|
||||||
<Divider my="md" color="#373A40" />
|
<Divider my="md" color="#373A40" />
|
||||||
|
|
||||||
<NavLink
|
{/* User Menu - styled like other nav items */}
|
||||||
label="Profile"
|
<UserMenu showLabel={true} />
|
||||||
leftSection={<Box style={{ width: '40px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}><UserMenu /></Box>}
|
|
||||||
variant="filled"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Development state panel */}
|
{/* Development state panel */}
|
||||||
{process.env.NODE_ENV === 'development' && (
|
{process.env.NODE_ENV === 'development' && (
|
||||||
|
|||||||
@@ -7,13 +7,17 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
} from '@react-three/drei';
|
} from '@react-three/drei';
|
||||||
import { Suspense, useEffect, useRef, useState } from 'react';
|
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';
|
import * as THREE from 'three';
|
||||||
|
|
||||||
// Define the shape of nodes and links from API
|
// Define the shape of nodes and links from API
|
||||||
interface NodeData {
|
interface NodeData {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
body?: string;
|
||||||
|
user_did: string;
|
||||||
|
atp_uri: string;
|
||||||
coords_3d: [number, number, number];
|
coords_3d: [number, number, number];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,17 +27,27 @@ interface LinkData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1. The 3D Node Component
|
// 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 [hovered, setHovered] = useState(false);
|
||||||
const [clicked, setClicked] = useState(false);
|
|
||||||
|
const isExpanded = isFocused || hovered;
|
||||||
|
const scale = isFocused ? 2.5 : 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh
|
<mesh
|
||||||
position={node.coords_3d}
|
position={node.coords_3d}
|
||||||
|
scale={scale}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onNodeClick(node);
|
onNodeClick(node);
|
||||||
setClicked(!clicked);
|
|
||||||
}}
|
}}
|
||||||
onPointerOver={(e) => {
|
onPointerOver={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -43,15 +57,15 @@ function Node({ node, onNodeClick }: { node: NodeData; onNodeClick: (node: NodeD
|
|||||||
>
|
>
|
||||||
<sphereGeometry args={[0.1, 32, 32]} />
|
<sphereGeometry args={[0.1, 32, 32]} />
|
||||||
<meshStandardMaterial
|
<meshStandardMaterial
|
||||||
color={hovered ? '#90c0ff' : '#e9ecef'}
|
color={isFocused ? '#4dabf7' : (hovered ? '#90c0ff' : '#e9ecef')}
|
||||||
emissive={hovered ? '#90c0ff' : '#e9ecef'}
|
emissive={isFocused ? '#4dabf7' : (hovered ? '#90c0ff' : '#e9ecef')}
|
||||||
emissiveIntensity={hovered ? 0.5 : 0.1}
|
emissiveIntensity={isFocused ? 0.8 : (hovered ? 0.5 : 0.1)}
|
||||||
/>
|
/>
|
||||||
{/* Show title on hover or click */}
|
{/* Show title on hover or focus */}
|
||||||
{(hovered || clicked) && (
|
{isExpanded && (
|
||||||
<Text
|
<Text
|
||||||
position={[0, 0.2, 0]}
|
position={[0, 0.3 / scale, 0]}
|
||||||
fontSize={0.1}
|
fontSize={0.1 / scale}
|
||||||
color="white"
|
color="white"
|
||||||
anchorX="center"
|
anchorX="center"
|
||||||
anchorY="middle"
|
anchorY="middle"
|
||||||
@@ -64,11 +78,15 @@ function Node({ node, onNodeClick }: { node: NodeData; onNodeClick: (node: NodeD
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. The Main Scene Component
|
// 2. The Main Scene Component
|
||||||
export function ThoughtGalaxy() {
|
export function ThoughtGalaxy({ focusNodeId }: { focusNodeId?: string } = {}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
const [nodes, setNodes] = useState<NodeData[]>([]);
|
const [nodes, setNodes] = useState<NodeData[]>([]);
|
||||||
const [links, setLinks] = useState<LinkData[]>([]);
|
const [links, setLinks] = useState<LinkData[]>([]);
|
||||||
|
const [selectedNode, setSelectedNode] = useState<NodeData | null>(null);
|
||||||
const cameraControlsRef = useRef<CameraControls>(null);
|
const cameraControlsRef = useRef<CameraControls>(null);
|
||||||
const hasFitCamera = useRef(false);
|
const hasFitCamera = useRef(false);
|
||||||
|
const hasFocusedNode = useRef<string | null>(null);
|
||||||
|
|
||||||
// Fetch data from API on mount and poll for updates
|
// Fetch data from API on mount and poll for updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -148,14 +166,42 @@ export function ThoughtGalaxy() {
|
|||||||
|
|
||||||
// Fit camera when nodes change and we haven't fitted yet
|
// Fit camera when nodes change and we haven't fitted yet
|
||||||
useEffect(() => {
|
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
|
// Try to fit after a short delay to ensure Canvas is ready
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
fitCameraToNodes();
|
fitCameraToNodes();
|
||||||
}, 100);
|
}, 100);
|
||||||
return () => clearTimeout(timer);
|
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
|
// Map links to node positions
|
||||||
const linkLines = links
|
const linkLines = links
|
||||||
@@ -174,14 +220,30 @@ export function ThoughtGalaxy() {
|
|||||||
|
|
||||||
// Camera animation on node click
|
// Camera animation on node click
|
||||||
const handleNodeClick = (node: NodeData) => {
|
const handleNodeClick = (node: NodeData) => {
|
||||||
if (cameraControlsRef.current) {
|
const targetPath = `/galaxy/${encodeURIComponent(node.id)}`;
|
||||||
// Smoothly move to look at the clicked node
|
|
||||||
cameraControlsRef.current.moveTo(
|
// Set selected node immediately for responsive UI
|
||||||
node.coords_3d[0],
|
setSelectedNode(node);
|
||||||
node.coords_3d[1],
|
|
||||||
node.coords_3d[2],
|
// Only navigate if we're not already on this node's page
|
||||||
true // Animate
|
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 (
|
return (
|
||||||
<Canvas
|
<>
|
||||||
camera={{ position: [0, 5, 10], fov: 60 }}
|
{/* Floating content overlay for selected node */}
|
||||||
style={{ width: '100%', height: '100%' }}
|
{selectedNode && (
|
||||||
gl={{ preserveDrawingBuffer: true }}
|
<Box
|
||||||
onCreated={(state) => {
|
style={{
|
||||||
console.log('[ThoughtGalaxy] Canvas created successfully');
|
position: 'absolute',
|
||||||
// Try to fit camera now that scene is ready
|
top: '10px',
|
||||||
if (!hasFitCamera.current && nodes.length > 0) {
|
left: '10px',
|
||||||
setTimeout(() => fitCameraToNodes(), 50);
|
right: '10px',
|
||||||
}
|
zIndex: 1000,
|
||||||
}}
|
maxWidth: '600px',
|
||||||
>
|
margin: '0 auto',
|
||||||
|
maxHeight: 'calc(100vh - 100px)', // Leave room for top/bottom padding
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Paper p="md" radius="lg" withBorder shadow="xl" style={{ maxHeight: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<Group justify="space-between" align="flex-start" mb="xs" style={{ flexShrink: 0 }}>
|
||||||
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Title order={2} style={{ margin: 0, marginBottom: '0.25rem' }}>
|
||||||
|
{selectedNode.title}
|
||||||
|
</Title>
|
||||||
|
<Anchor
|
||||||
|
href={`https://bsky.app/profile/${selectedNode.user_did}/post/${selectedNode.atp_uri.split('/').pop()}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
size="sm"
|
||||||
|
c="dimmed"
|
||||||
|
>
|
||||||
|
View on Bluesky
|
||||||
|
</Anchor>
|
||||||
|
</Box>
|
||||||
|
<CloseButton
|
||||||
|
size="lg"
|
||||||
|
onClick={() => setSelectedNode(null)}
|
||||||
|
aria-label="Close node details"
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
{selectedNode.body && (
|
||||||
|
<Box style={{ overflowY: 'auto', flex: 1, marginTop: '0.5rem' }}>
|
||||||
|
<MantineText
|
||||||
|
size="md"
|
||||||
|
style={{
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedNode.body}
|
||||||
|
</MantineText>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Canvas
|
||||||
|
camera={{ position: [0, 5, 10], fov: 60 }}
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
gl={{ preserveDrawingBuffer: true }}
|
||||||
|
onCreated={(state) => {
|
||||||
|
console.log('[ThoughtGalaxy] Canvas created successfully');
|
||||||
|
// Try to fit camera now that scene is ready
|
||||||
|
if (!hasFitCamera.current && nodes.length > 0) {
|
||||||
|
setTimeout(() => fitCameraToNodes(), 50);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ambientLight intensity={0.5} />
|
<ambientLight intensity={0.5} />
|
||||||
<pointLight position={[10, 10, 10]} intensity={1} />
|
<pointLight position={[10, 10, 10]} intensity={1} />
|
||||||
<CameraControls ref={cameraControlsRef} />
|
<CameraControls ref={cameraControlsRef} />
|
||||||
@@ -222,7 +339,12 @@ export function ThoughtGalaxy() {
|
|||||||
<group>
|
<group>
|
||||||
{/* Render all nodes */}
|
{/* Render all nodes */}
|
||||||
{nodes.map((node) => (
|
{nodes.map((node) => (
|
||||||
<Node key={node.id} node={node} onNodeClick={handleNodeClick} />
|
<Node
|
||||||
|
key={node.id}
|
||||||
|
node={node}
|
||||||
|
isFocused={focusNodeId === node.id || selectedNode?.id === node.id}
|
||||||
|
onNodeClick={handleNodeClick}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Render all links */}
|
{/* Render all links */}
|
||||||
@@ -237,5 +359,6 @@ export function ThoughtGalaxy() {
|
|||||||
</group>
|
</group>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Menu, Avatar, UnstyledButton, Group, Text } from '@mantine/core';
|
import { Menu, Avatar, NavLink, ActionIcon } from '@mantine/core';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
interface UserProfile {
|
interface UserProfile {
|
||||||
@@ -11,7 +11,7 @@ interface UserProfile {
|
|||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserMenu() {
|
export function UserMenu({ showLabel = false }: { showLabel?: boolean } = {}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -43,10 +43,30 @@ export function UserMenu() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (loading || !profile) {
|
if (loading || !profile) {
|
||||||
return (
|
return showLabel ? (
|
||||||
<Avatar radius="xl" size="md" color="gray">
|
<NavLink
|
||||||
?
|
label="Profile"
|
||||||
</Avatar>
|
leftSection={
|
||||||
|
<Avatar radius="xl" size={20} color="gray">
|
||||||
|
?
|
||||||
|
</Avatar>
|
||||||
|
}
|
||||||
|
variant="filled"
|
||||||
|
color="blue"
|
||||||
|
styles={{
|
||||||
|
root: {
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontWeight: 400,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ActionIcon variant="subtle" color="gray" size={40} radius="md">
|
||||||
|
<Avatar radius="xl" size={24} color="gray">
|
||||||
|
?
|
||||||
|
</Avatar>
|
||||||
|
</ActionIcon>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,29 +85,49 @@ export function UserMenu() {
|
|||||||
return (
|
return (
|
||||||
<Menu shadow="md" width={200} position="bottom-end">
|
<Menu shadow="md" width={200} position="bottom-end">
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<UnstyledButton>
|
{showLabel ? (
|
||||||
<Group gap="xs">
|
<NavLink
|
||||||
|
label="Profile"
|
||||||
|
leftSection={
|
||||||
|
<Avatar
|
||||||
|
src={profile.avatar}
|
||||||
|
alt={displayText}
|
||||||
|
radius="xl"
|
||||||
|
size={20}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</Avatar>
|
||||||
|
}
|
||||||
|
variant="filled"
|
||||||
|
color="blue"
|
||||||
|
styles={{
|
||||||
|
root: {
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontWeight: 400,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ActionIcon variant="subtle" color="gray" size={40} radius="md">
|
||||||
<Avatar
|
<Avatar
|
||||||
src={profile.avatar}
|
src={profile.avatar}
|
||||||
alt={displayText}
|
alt={displayText}
|
||||||
radius="xl"
|
radius="xl"
|
||||||
size="md"
|
size={24}
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
>
|
>
|
||||||
{initials}
|
{initials}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</Group>
|
</ActionIcon>
|
||||||
</UnstyledButton>
|
)}
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
|
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Label>
|
<Menu.Label>
|
||||||
<Text size="xs" fw={600}>
|
{displayText}
|
||||||
{displayText}
|
<br />
|
||||||
</Text>
|
<span style={{ color: 'var(--mantine-color-dimmed)', fontSize: '0.75rem' }}>
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
@{profile.handle}
|
@{profile.handle}
|
||||||
</Text>
|
</span>
|
||||||
</Menu.Label>
|
</Menu.Label>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
<Menu.Item onClick={handleLogout} c="red">
|
<Menu.Item onClick={handleLogout} c="red">
|
||||||
|
|||||||
10
lib/ai.ts
10
lib/ai.ts
@@ -9,12 +9,22 @@ if (!process.env.GOOGLE_EMBEDDING_MODEL) {
|
|||||||
throw new Error('GOOGLE_EMBEDDING_MODEL environment variable is required (e.g., gemini-embedding-001)');
|
throw new Error('GOOGLE_EMBEDDING_MODEL environment variable is required (e.g., gemini-embedding-001)');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!process.env.GOOGLE_EMBEDDING_DIMENSIONS) {
|
||||||
|
throw new Error('GOOGLE_EMBEDDING_DIMENSIONS environment variable is required (e.g., 3072)');
|
||||||
|
}
|
||||||
|
|
||||||
const genAI = new GoogleGenerativeAI(process.env.GOOGLE_GENERATIVE_AI_API_KEY);
|
const genAI = new GoogleGenerativeAI(process.env.GOOGLE_GENERATIVE_AI_API_KEY);
|
||||||
|
|
||||||
const embeddingModel = genAI.getGenerativeModel({
|
const embeddingModel = genAI.getGenerativeModel({
|
||||||
model: process.env.GOOGLE_EMBEDDING_MODEL,
|
model: process.env.GOOGLE_EMBEDDING_MODEL,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The expected dimension size for embeddings from the configured model.
|
||||||
|
* This must match the actual output dimension of the embedding model.
|
||||||
|
*/
|
||||||
|
export const EMBEDDING_DIMENSIONS = parseInt(process.env.GOOGLE_EMBEDDING_DIMENSIONS, 10);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a vector embedding for a given text using the configured Google embedding model.
|
* Generates a vector embedding for a given text using the configured Google embedding model.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -30,12 +30,17 @@ export async function getOAuthClient(): Promise<NodeOAuthClient> {
|
|||||||
|
|
||||||
const isDev = process.env.NODE_ENV === 'development';
|
const isDev = process.env.NODE_ENV === 'development';
|
||||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
|
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
|
||||||
const callbackUrl = `${appUrl}/api/auth/callback`;
|
|
||||||
|
// Per RFC 8252 and ATproto OAuth spec:
|
||||||
|
// - client_id must use 'localhost' hostname (NOT an IP)
|
||||||
|
// - redirect_uris must use '127.0.0.1' loopback IP (NOT 'localhost')
|
||||||
|
const callbackUrl = isDev
|
||||||
|
? 'http://127.0.0.1:3000/api/auth/callback'
|
||||||
|
: `${appUrl}/api/auth/callback`;
|
||||||
|
|
||||||
if (isDev) {
|
if (isDev) {
|
||||||
// Development: Use localhost loopback client
|
// Development: Use localhost loopback client exception
|
||||||
// Per ATproto spec, we encode metadata in the client_id query params
|
// Encode metadata in client_id query params as per spec
|
||||||
// Request 'transition:generic' scope for repository write access
|
|
||||||
const clientId = `http://localhost/?${new URLSearchParams({
|
const clientId = `http://localhost/?${new URLSearchParams({
|
||||||
redirect_uri: callbackUrl,
|
redirect_uri: callbackUrl,
|
||||||
scope: 'atproto transition:generic',
|
scope: 'atproto transition:generic',
|
||||||
@@ -43,6 +48,7 @@ export async function getOAuthClient(): Promise<NodeOAuthClient> {
|
|||||||
|
|
||||||
console.log('[OAuth] Initializing development client with loopback exception');
|
console.log('[OAuth] Initializing development client with loopback exception');
|
||||||
console.log('[OAuth] client_id:', clientId);
|
console.log('[OAuth] client_id:', clientId);
|
||||||
|
console.log('[OAuth] redirect_uri:', callbackUrl);
|
||||||
|
|
||||||
clientInstance = new NodeOAuthClient({
|
clientInstance = new NodeOAuthClient({
|
||||||
clientMetadata: {
|
clientMetadata: {
|
||||||
|
|||||||
23
scripts/clear-nodes.ts
Normal file
23
scripts/clear-nodes.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import Surreal from 'surrealdb';
|
||||||
|
|
||||||
|
async function clearNodes() {
|
||||||
|
const db = new Surreal();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.connect('ws://localhost:8000/rpc');
|
||||||
|
await db.signin({ username: 'root', password: 'root' });
|
||||||
|
await db.use({ namespace: 'ponderants', database: 'main' });
|
||||||
|
|
||||||
|
console.log('Deleting all nodes...');
|
||||||
|
const result = await db.query('DELETE node;');
|
||||||
|
console.log('Result:', result);
|
||||||
|
|
||||||
|
console.log('✓ All nodes deleted successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
} finally {
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearNodes();
|
||||||
Reference in New Issue
Block a user