Files
app/docs/steps/step-10.md
2025-11-08 12:44:39 +00:00

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:

  1. Create the editor page (/editor/[id]) that is pre-filled by the chat (Commit 07) or loaded from the DB.
  2. Implement the "Publish" button, which calls the /api/nodes route (from Commit 06).
  3. Implement the "Find related" button, which calls a new /api/suggest-links route.
  4. 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"'
);
});