10 KiB
File: COMMIT_10_LINKING.md
Commit 10: Node Editor & AI-Powered Linking
Objective
Build the node editor UI and the AI-powered "Find related" feature. This commit will:
- Create the editor page (/editor/[id]) that is pre-filled by the chat (Commit 07) or loaded from the DB.
- Implement the "Publish" button, which calls the /api/nodes route (from Commit 06).
- Implement the "Find related" button, which calls a new /api/suggest-links route.
- Implement the /api/suggest-links route, which generates an embedding for the current draft and uses SurrealDB's vector search to find similar nodes.15
Implementation Specification
1. Create Editor Page (app/editor/[id]/page.tsx)
Create a file at /app/editor/[id]/page.tsx:
TypeScript
'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';
// 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 = 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) {
throw new Error('Failed to publish node');
}
const newNode \= await response.json();
// On success, go to the graph
router.push('/galaxy');
} catch (error) {
console.error(error);
// TODO: Show notification
} 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) {
throw new Error('Failed to find links');
}
const relatedNodes \= await response.json();
setSuggestions(relatedNodes);
} catch (error) {
console.error(error);
// TODO: Show notification
} 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 && (
\<Text size="sm" c="dimmed"\>
{/\* Placeholder text \*/}
\</Text\>
)}
\</Stack\>
\</Stack\>
\</form\>
\</Container\>
);
}
2. Create Link Suggestion API (app/api/suggest-links/route.ts)
Create a file at /app/api/suggest-links/route.ts:
TypeScript
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { connectToDB } from '@/lib/db';
import { generateEmbedding } from '@/lib/ai';
export async function POST(request: NextRequest) {
const surrealJwt = cookies().get('ponderants-auth')?.value;
if (!surrealJwt) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { body } = await request.json();
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)
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(query, {
draft\_embedding: draftEmbedding,
});
// The query returns an array of result sets. We want the first one.
return NextResponse.json(results.result);
} catch (error) {
console.error('Link suggestion error:', error);
return NextResponse.json(
{ error: 'Failed to suggest links' },
{ status: 500 }
);
}
}
Test Specification
1. Create Test File (tests/magnitude/10-linking.mag.ts)
Create a file at /tests/magnitude/10-linking.mag.ts:
TypeScript
import { test } from 'magnitude-test';
// Helper function to seed the database for this test
async function seedDatabase(agent) {
// This would use a custom magnitude.run command or API
// to pre-populate the SurrealDB instance with mock nodes.
await agent.act('Seed the database with 3 nodes: "Node A", "Node B", "Node C"');
// "Node A" is about "dogs and cats"
// "Node B" is about "vector databases"
// "Node C" is about "ATproto"
}
test('[Happy Path] User can find related links for a draft', async (agent) => {
// Setup: Seed the DB
await seedDatabase(agent);
// Act: Navigate to the editor
await agent.act('Navigate to /editor/new');
// Act: Fill out the form with a related idea
await agent.act(
'Enter "My New Post" into the "Title" input'
);
await agent.act(
'Enter "This idea is about vectors and databases, and how they work." into the "Body" textarea'
);
// Act: Click the find related button
// (Mock the /api/suggest-links route to return "Node B")
await agent.act('Click the "Find Related" button');
// Check: The related node appears in the suggestions
await agent.check('A list of suggested links appears');
await agent.check('The suggested node "Node B" is visible in the list');
await agent.check('The suggested node "Node A" is NOT visible in the list');
});
test('[Unhappy Path] User sees empty state when no links found', async (agent) => {
// Setup: Seed the DB
await seedDatabase(agent);
// Act: Navigate to the editor
await agent.act('Navigate to /editor/new');
// Act: Fill out the form with an unrelated idea
await agent.act(
'Enter "Zebras" into the "Title" input'
);
await agent.act(
'Enter "Zebras are striped equines." into the "Body" textarea'
);
// Act: Click the find related button
// (Mock the /api/suggest-links route to return an empty array)
await agent.act('Click the "Find Related" button');
// Check: An empty state is shown
await agent.check('The text "No related nodes found" is visible');
});
test('[Happy Path] User can publish a new node', async (agent) => {
// Act: Navigate to the editor
await agent.act('Navigate to /editor/new');
// Act: Fill out the form
await agent.act(
'Enter "My First Published Node" into the "Title" input'
);
await agent.act(
'Enter "This is the body of my first node." into the "Body" textarea'
);
// Act: Click Publish
// (Mock the /api/nodes route (Commit 06) to return success)
await agent.act('Click the "Publish Node" button');
// Check: User is redirected to the galaxy
await agent.check(
'The browser URL is now "http://localhost:3000/galaxy"'
);
});