init
This commit is contained in:
346
docs/steps/step-11.md
Normal file
346
docs/steps/step-11.md
Normal file
@@ -0,0 +1,346 @@
|
||||
# **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"'
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user