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:
2025-11-09 18:26:39 +00:00
parent 4b3da74f79
commit 9aa9035d78
14 changed files with 453 additions and 102 deletions

View File

@@ -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

View File

@@ -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
`;

View File

@@ -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) {

View File

@@ -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;
`;

View File

@@ -248,9 +248,9 @@ export default function ChatPage() {
}}
w="80%"
>
<Text fw={700} size="sm">
{m.role === 'user' ? 'You' : 'AI'}
</Text>
<Title order={6} size="sm">
{m.role === 'user' ? 'YOU' : 'INQUISITOR'}
</Title>
{(() => {
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%"
>
<Text fw={700} size="sm">
AI
</Text>
<Title order={6} size="sm">
INQUISITOR
</Title>
<Group gap="xs" mt="xs">
<Loader size="xs" />
<Text size="sm" c="dimmed">
@@ -301,9 +301,9 @@ export default function ChatPage() {
style={{ alignSelf: 'flex-end', backgroundColor: '#343a40' }}
w="80%"
>
<Text fw={700} size="sm">
You (speaking...)
</Text>
<Title order={6} size="sm">
YOU (speaking...)
</Title>
<Text style={{ whiteSpace: 'pre-wrap' }}>{transcript}</Text>
</Paper>
)}

View File

@@ -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() {
</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 }}>
<Stack gap="lg">
<TextInput

View 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>
);
}

View File

@@ -6,10 +6,10 @@ import { ThoughtGalaxy } from '@/components/ThoughtGalaxy';
export default function GalaxyPage() {
return (
<Box style={{ height: '100vh', width: '100vw', position: 'relative' }}>
<Box style={{ height: '100%', width: '100%', position: 'relative' }}>
{/* R3F Canvas for the 3D visualization */}
<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>
</Stack>
}>

View File

@@ -111,11 +111,8 @@ export function DesktopSidebar() {
<Divider my="md" color="#373A40" />
<NavLink
label="Profile"
leftSection={<Box style={{ width: '40px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}><UserMenu /></Box>}
variant="filled"
/>
{/* User Menu - styled like other nav items */}
<UserMenu showLabel={true} />
{/* Development state panel */}
{process.env.NODE_ENV === 'development' && (

View File

@@ -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 (
<mesh
position={node.coords_3d}
scale={scale}
onClick={(e) => {
e.stopPropagation();
onNodeClick(node);
setClicked(!clicked);
}}
onPointerOver={(e) => {
e.stopPropagation();
@@ -43,15 +57,15 @@ function Node({ node, onNodeClick }: { node: NodeData; onNodeClick: (node: NodeD
>
<sphereGeometry args={[0.1, 32, 32]} />
<meshStandardMaterial
color={hovered ? '#90c0ff' : '#e9ecef'}
emissive={hovered ? '#90c0ff' : '#e9ecef'}
emissiveIntensity={hovered ? 0.5 : 0.1}
color={isFocused ? '#4dabf7' : (hovered ? '#90c0ff' : '#e9ecef')}
emissive={isFocused ? '#4dabf7' : (hovered ? '#90c0ff' : '#e9ecef')}
emissiveIntensity={isFocused ? 0.8 : (hovered ? 0.5 : 0.1)}
/>
{/* Show title on hover or click */}
{(hovered || clicked) && (
{/* Show title on hover or focus */}
{isExpanded && (
<Text
position={[0, 0.2, 0]}
fontSize={0.1}
position={[0, 0.3 / scale, 0]}
fontSize={0.1 / scale}
color="white"
anchorX="center"
anchorY="middle"
@@ -64,11 +78,15 @@ function Node({ node, onNodeClick }: { node: NodeData; onNodeClick: (node: NodeD
}
// 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 [links, setLinks] = useState<LinkData[]>([]);
const [selectedNode, setSelectedNode] = useState<NodeData | null>(null);
const cameraControlsRef = useRef<CameraControls>(null);
const hasFitCamera = useRef(false);
const hasFocusedNode = useRef<string | null>(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 (
<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);
}
}}
>
<>
{/* Floating content overlay for selected node */}
{selectedNode && (
<Box
style={{
position: 'absolute',
top: '10px',
left: '10px',
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} />
<pointLight position={[10, 10, 10]} intensity={1} />
<CameraControls ref={cameraControlsRef} />
@@ -222,7 +339,12 @@ export function ThoughtGalaxy() {
<group>
{/* Render all nodes */}
{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 */}
@@ -237,5 +359,6 @@ export function ThoughtGalaxy() {
</group>
</Suspense>
</Canvas>
</>
);
}

View File

@@ -1,7 +1,7 @@
'use client';
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';
interface UserProfile {
@@ -11,7 +11,7 @@ interface UserProfile {
avatar: string | null;
}
export function UserMenu() {
export function UserMenu({ showLabel = false }: { showLabel?: boolean } = {}) {
const router = useRouter();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [loading, setLoading] = useState(true);
@@ -43,10 +43,30 @@ export function UserMenu() {
};
if (loading || !profile) {
return (
<Avatar radius="xl" size="md" color="gray">
?
</Avatar>
return showLabel ? (
<NavLink
label="Profile"
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 (
<Menu shadow="md" width={200} position="bottom-end">
<Menu.Target>
<UnstyledButton>
<Group gap="xs">
{showLabel ? (
<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
src={profile.avatar}
alt={displayText}
radius="xl"
size="md"
style={{ cursor: 'pointer' }}
size={24}
>
{initials}
</Avatar>
</Group>
</UnstyledButton>
</ActionIcon>
)}
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>
<Text size="xs" fw={600}>
{displayText}
</Text>
<Text size="xs" c="dimmed">
{displayText}
<br />
<span style={{ color: 'var(--mantine-color-dimmed)', fontSize: '0.75rem' }}>
@{profile.handle}
</Text>
</span>
</Menu.Label>
<Menu.Divider />
<Menu.Item onClick={handleLogout} c="red">

View File

@@ -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)');
}
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 embeddingModel = genAI.getGenerativeModel({
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.
*

View File

@@ -30,12 +30,17 @@ export async function getOAuthClient(): Promise<NodeOAuthClient> {
const isDev = process.env.NODE_ENV === 'development';
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) {
// Development: Use localhost loopback client
// Per ATproto spec, we encode metadata in the client_id query params
// Request 'transition:generic' scope for repository write access
// Development: Use localhost loopback client exception
// Encode metadata in client_id query params as per spec
const clientId = `http://localhost/?${new URLSearchParams({
redirect_uri: callbackUrl,
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] client_id:', clientId);
console.log('[OAuth] redirect_uri:', callbackUrl);
clientInstance = new NodeOAuthClient({
clientMetadata: {

23
scripts/clear-nodes.ts Normal file
View 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();