feat: Step 10 - Node Editor & AI-Powered Linking

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 <noreply@anthropic.com>
This commit is contained in:
2025-11-09 02:05:32 +00:00
parent af00f29bd4
commit 013575d6d5
3 changed files with 304 additions and 0 deletions

View File

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