From dd7ba8d4de0f9e1b57c4e6d0b2bae84b3eb80da8 Mon Sep 17 00:00:00 2001 From: Albert Date: Sun, 9 Nov 2025 02:05:32 +0000 Subject: [PATCH] feat: Step 10 - Node Editor & AI-Powered Linking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented the node editor page and AI-powered link suggestions: 1. Node Editor Page (/editor/[id]): - Form with title and body fields using Mantine - Pre-fill support from query params (for chat redirects) - "Find Related" button to discover similar nodes - "Publish Node" button to save to ATproto + SurrealDB - Display of suggested links with similarity scores - Mantine notifications for success/error feedback 2. Suggest Links API (/api/suggest-links): - Authenticates using SurrealDB JWT cookie - Generates embedding for draft text using Google AI - Performs vector similarity search using SurrealDB - Returns top 5 most similar nodes with cosine scores - Enforces row-level security (users only see their nodes) 3. Magnitude Tests: - Editor page rendering - Pre-filled form from query params - Publishing new nodes - Form validation The editor integrates with the existing /api/nodes write-through cache from Step 6, completing the node creation workflow. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/api/suggest-links/route.ts | 74 ++++++++++++ app/editor/[id]/page.tsx | 187 ++++++++++++++++++++++++++++++ tests/magnitude/10-linking.mag.ts | 43 +++++++ 3 files changed, 304 insertions(+) create mode 100644 app/api/suggest-links/route.ts create mode 100644 app/editor/[id]/page.tsx create mode 100644 tests/magnitude/10-linking.mag.ts diff --git a/app/api/suggest-links/route.ts b/app/api/suggest-links/route.ts new file mode 100644 index 0000000..5085924 --- /dev/null +++ b/app/api/suggest-links/route.ts @@ -0,0 +1,74 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { connectToDB } from '@/lib/db'; +import { generateEmbedding } from '@/lib/ai'; + +/** + * POST /api/suggest-links + * + * Uses vector similarity search to find related nodes. + * Takes the body text of a draft node, generates an embedding, + * and returns the top 5 most similar nodes using cosine similarity. + */ +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 }); + } + + const { body } = (await request.json()) as { body: string }; + + if (!body) { + return NextResponse.json({ error: 'Body text is required' }, { status: 400 }); + } + + try { + // 1. Generate embedding for the current draft + const draftEmbedding = await generateEmbedding(body); + + // 2. Connect to DB (as the user) + // This enforces row-level security - user can only search their own nodes + const db = await connectToDB(surrealJwt); + + // 3. Run the vector similarity search query + // This query finds the 5 closest nodes in the 'node' table + // using cosine similarity on the 'embedding' field. + // It only searches nodes WHERE user_did = $token.did, + // which is enforced by the table's PERMISSIONS. + const query = ` + SELECT + id, + title, + body, + atp_uri, + vector::similarity::cosine(embedding, $draft_embedding) AS score + FROM node + ORDER BY score DESC + LIMIT 5; + `; + + const results = await db.query<[Array<{ + id: string; + title: string; + body: string; + atp_uri: string; + score: number; + }>]>(query, { + draft_embedding: draftEmbedding, + }); + + // The query returns an array of result sets. We want the first one. + const suggestions = results[0] || []; + + return NextResponse.json(suggestions); + + } catch (error) { + console.error('[Suggest Links] Error:', error); + return NextResponse.json( + { error: 'Failed to suggest links' }, + { status: 500 } + ); + } +} diff --git a/app/editor/[id]/page.tsx b/app/editor/[id]/page.tsx new file mode 100644 index 0000000..9c63ebe --- /dev/null +++ b/app/editor/[id]/page.tsx @@ -0,0 +1,187 @@ +'use client'; + +import { + Container, + Title, + TextInput, + Textarea, + Button, + Stack, + Paper, + Text, + LoadingOverlay, + Group, +} from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { useSearchParams, useRouter, useParams } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import { notifications } from '@mantine/notifications'; + +// Define the shape of a suggested link +interface SuggestedNode { + id: string; + title: string; + body: string; + score: number; +} + +export default function EditorPage() { + const router = useRouter(); + const params = useParams(); + const searchParams = useSearchParams(); + + const [isPublishing, setIsPublishing] = useState(false); + const [isFinding, setIsFinding] = useState(false); + const [suggestions, setSuggestions] = useState([]); + + const form = useForm({ + initialValues: { + title: '', + body: '', + links: [] as string[], // Array of at-uri strings + }, + }); + + // Pre-fill form from search params (from AI chat redirect) + useEffect(() => { + if (params.id === 'new') { + const title = searchParams.get('title') || ''; + const body = searchParams.get('body') || ''; + form.setValues({ title, body }); + } else { + // TODO: Load existing node from /api/nodes/[id] + } + }, [params.id, searchParams]); + + // Handler for the "Publish" button (calls Commit 06 API) + const handlePublish = async (values: typeof form.values) => { + setIsPublishing(true); + try { + const response = await fetch('/api/nodes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(values), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to publish node'); + } + + const newNode = await response.json(); + + notifications.show({ + title: 'Success', + message: 'Node published successfully!', + color: 'green', + }); + + // On success, go to the chat (galaxy not implemented yet) + router.push('/chat'); + + } catch (error) { + console.error(error); + notifications.show({ + title: 'Error', + message: error instanceof Error ? error.message : 'Failed to publish node', + color: 'red', + }); + } finally { + setIsPublishing(false); + } + }; + + // Handler for the "Find related" button + const handleFindRelated = async () => { + setIsFinding(true); + setSuggestions([]); + try { + const response = await fetch('/api/suggest-links', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ body: form.values.body }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to find links'); + } + + const relatedNodes = await response.json(); + setSuggestions(relatedNodes); + + } catch (error) { + console.error(error); + notifications.show({ + title: 'Error', + message: error instanceof Error ? error.message : 'Failed to find related links', + color: 'red', + }); + } finally { + setIsFinding(false); + } + }; + + return ( + +
+ + + {params.id === 'new' ? 'Create New Node' : 'Edit Node'} + + + + +