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:
74
app/api/suggest-links/route.ts
Normal file
74
app/api/suggest-links/route.ts
Normal 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
187
app/editor/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
tests/magnitude/10-linking.mag.ts
Normal file
43
tests/magnitude/10-linking.mag.ts
Normal 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"');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user