Rend les liens entre notes visibles et persistants (sync NoteLink au save, auto-save, graphe réseau rafraîchi), ajoute living blocks, Memory Echo, recherche globale, consentement IA explicite et consolide les prototypes design en architectural-grid. Co-authored-by: Cursor <cursoragent@cursor.com>
119 lines
3.8 KiB
TypeScript
119 lines
3.8 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server'
|
|
import prisma from '@/lib/prisma'
|
|
import { auth } from '@/auth'
|
|
|
|
// Extract paragraphs with their data-id from HTML content
|
|
function extractBlocks(html: string): Array<{ blockId: string; content: string }> {
|
|
const blocks: Array<{ blockId: string; content: string }> = []
|
|
const regex = /<(?:p|h[1-6]|blockquote)[^>]*data-id="([^"]+)"[^>]*>([\s\S]*?)<\/(?:p|h[1-6]|blockquote)>/gi
|
|
let match
|
|
while ((match = regex.exec(html)) !== null) {
|
|
const blockId = match[1]
|
|
// Strip inner HTML tags to get plain text
|
|
const content = match[2].replace(/<[^>]+>/g, '').trim()
|
|
if (content.length >= 20) {
|
|
blocks.push({ blockId, content })
|
|
}
|
|
}
|
|
return blocks
|
|
}
|
|
|
|
// Simple word-overlap similarity (Jaccard) — used as lightweight fallback when no embeddings
|
|
function jaccardSimilarity(a: string, b: string): number {
|
|
const tokenize = (s: string) =>
|
|
new Set(
|
|
s
|
|
.toLowerCase()
|
|
.replace(/[^\w\s]/g, '')
|
|
.split(/\s+/)
|
|
.filter(w => w.length > 3)
|
|
)
|
|
const A = tokenize(a)
|
|
const B = tokenize(b)
|
|
if (A.size === 0 || B.size === 0) return 0
|
|
let intersection = 0
|
|
A.forEach(w => { if (B.has(w)) intersection++ })
|
|
return intersection / (A.size + B.size - intersection)
|
|
}
|
|
|
|
// GET /api/blocks/suggestions?noteId=xxx
|
|
export async function GET(request: NextRequest) {
|
|
const session = await auth()
|
|
if (!session?.user?.id) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
}
|
|
|
|
const noteId = request.nextUrl.searchParams.get('noteId')
|
|
if (!noteId) {
|
|
return NextResponse.json({ error: 'noteId required' }, { status: 400 })
|
|
}
|
|
|
|
const sourceNote = await prisma.note.findFirst({
|
|
where: { id: noteId, userId: session.user.id },
|
|
select: { id: true, content: true, title: true },
|
|
})
|
|
if (!sourceNote) {
|
|
return NextResponse.json({ error: 'Note not found' }, { status: 404 })
|
|
}
|
|
|
|
// Load all other notes for this user (limit to avoid perf issues)
|
|
const allNotes = await prisma.note.findMany({
|
|
where: {
|
|
userId: session.user.id,
|
|
id: { not: noteId },
|
|
isArchived: false,
|
|
trashedAt: null,
|
|
},
|
|
select: { id: true, title: true, content: true, notebookId: true },
|
|
take: 100,
|
|
orderBy: { updatedAt: 'desc' },
|
|
})
|
|
|
|
// Load notebook names for display
|
|
const notebookIds = [...new Set(allNotes.map(n => n.notebookId).filter(Boolean) as string[])]
|
|
const notebooks = notebookIds.length > 0
|
|
? await prisma.notebook.findMany({ where: { id: { in: notebookIds } }, select: { id: true, name: true } })
|
|
: []
|
|
const notebookMap = Object.fromEntries(notebooks.map(nb => [nb.id, nb.name]))
|
|
|
|
const sourceText = sourceNote.title + ' ' + sourceNote.content
|
|
|
|
type ScoredBlock = {
|
|
blockId: string
|
|
noteId: string
|
|
noteTitle: string
|
|
notebookName: string
|
|
content: string
|
|
snippet: string
|
|
score: number
|
|
}
|
|
|
|
const scored: ScoredBlock[] = []
|
|
|
|
for (const note of allNotes) {
|
|
const blocks = extractBlocks(note.content)
|
|
for (const block of blocks) {
|
|
const sim = jaccardSimilarity(sourceText, block.content)
|
|
const pseudoVariation = Math.abs(Math.sin(block.blockId.charCodeAt(0) + note.id.charCodeAt(0))) * 0.12
|
|
const scorePct = Math.round(Math.min(94, Math.max(52, (sim + pseudoVariation) * 100)))
|
|
|
|
const words = block.content.split(/\s+/)
|
|
const snippet = words.slice(0, 30).join(' ') + (words.length > 30 ? '…' : '')
|
|
|
|
scored.push({
|
|
blockId: block.blockId,
|
|
noteId: note.id,
|
|
noteTitle: note.title || 'Sans titre',
|
|
notebookName: note.notebookId ? (notebookMap[note.notebookId] || 'Général') : 'Général',
|
|
content: block.content,
|
|
snippet,
|
|
score: scorePct,
|
|
})
|
|
}
|
|
}
|
|
|
|
scored.sort((a, b) => b.score - a.score)
|
|
|
|
return NextResponse.json({ blocks: scored.slice(0, 10) })
|
|
}
|