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 e34ecb813d
commit dd7ba8d4de
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 }
);
}
}

187
app/editor/[id]/page.tsx Normal file
View File

@@ -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<SuggestedNode[]>([]);
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 (
<Container size="md" py="xl">
<form onSubmit={form.onSubmit(handlePublish)}>
<Stack gap="md">
<Title order={2}>
{params.id === 'new' ? 'Create New Node' : 'Edit Node'}
</Title>
<TextInput
label="Title"
placeholder="Your node title"
required
{...form.getInputProps('title')}
/>
<Textarea
label="Body"
placeholder="Your node content..."
required
minRows={10}
autosize
{...form.getInputProps('body')}
/>
<Group>
<Button
type="button"
variant="outline"
onClick={handleFindRelated}
loading={isFinding}
>
Find Related
</Button>
<Button type="submit" loading={isPublishing}>
Publish Node
</Button>
</Group>
{/* Related Links Section */}
<Stack>
{isFinding && <LoadingOverlay visible />}
{suggestions.length > 0 && <Title order={4}>Suggested Links</Title>}
{suggestions.map((node) => (
<Paper key={node.id} withBorder p="sm">
<Text fw={700}>{node.title}</Text>
<Text size="sm" lineClamp={2}>{node.body}</Text>
<Text size="xs" c="dimmed">Similarity: {(node.score * 100).toFixed(0)}%</Text>
</Paper>
))}
{!isFinding && suggestions.length === 0 && form.values.body && (
<Text size="sm" c="dimmed">
Click "Find Related" to discover similar nodes
</Text>
)}
</Stack>
</Stack>
</form>
</Container>
);
}

View File

@@ -0,0 +1,43 @@
import { test } from 'magnitude-test';
test('Editor page renders correctly', async (agent) => {
await agent.act('Navigate to /editor/new');
await agent.check('The text "Create New Node" is visible on the screen');
await agent.check('A text input field labeled "Title" is visible');
await agent.check('A textarea labeled "Body" is visible');
await agent.check('A button labeled "Find Related" is visible');
await agent.check('A button labeled "Publish Node" is visible');
});
test('[Happy Path] User can navigate to editor from chat', async (agent) => {
// Simulate pre-filled form from chat redirect
await agent.act('Navigate to /editor/new?title=My%20Idea&body=This%20is%20my%20thought');
await agent.check('The "Title" input contains "My Idea"');
await agent.check('The "Body" textarea contains "This is my thought"');
});
test('[Happy Path] User can fill out and publish a new node', async (agent) => {
// Navigate to the editor
await agent.act('Navigate to /editor/new');
// Fill out the form
await agent.act('Type "My First Published Node" into the "Title" input');
await agent.act('Type "This is the body of my first node. It contains interesting thoughts about technology and innovation." into the "Body" textarea');
// Click Publish
await agent.act('Click the "Publish Node" button');
// Check: User is redirected to chat (galaxy not implemented yet)
await agent.check('The page URL contains "/chat"');
});
test('[Unhappy Path] Publishing requires both title and body', async (agent) => {
await agent.act('Navigate to /editor/new');
// Try to publish without filling in the form
await agent.act('Click the "Publish Node" button');
// The form should prevent submission (Mantine form validation)
// The URL should still be /editor/new
await agent.check('The page URL contains "/editor/new"');
});