feat(insights): fix DBSCAN, Persian embeddings crash, D3 physics layouts, and D3 node not found runtime error
Some checks failed
CI / Lint, Test & Build (push) Failing after 1m7s
CI / Deploy production (on server) (push) Has been skipped

This commit is contained in:
Antigravity
2026-05-24 18:57:33 +00:00
parent e2672cd2c2
commit e881004c77
63 changed files with 5729 additions and 563 deletions

View File

@@ -0,0 +1,101 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { extractArticleFromHtml } from '@/lib/clip/extract-article'
import { analyzeClipContent } from '@/lib/clip/analyze-clip'
import { resolveClipLocale, wrapClipPlainParagraph } from '@/lib/clip/rtl-content'
function isBlockedUrl(url: string): boolean {
try {
const parsed = new URL(url)
const hostname = parsed.hostname.toLowerCase()
const blocked = ['localhost', '127.0.0.1', '0.0.0.0', '::1', '169.254.169.254']
if (blocked.includes(hostname)) return true
if (hostname.startsWith('10.') || hostname.startsWith('172.') || hostname.startsWith('192.168.')) return true
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return true
return false
} catch {
return true
}
}
async function fetchPageHtml(url: string): Promise<string | null> {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 15000)
try {
const response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; MementoClipper/1.0)',
Accept: 'text/html,application/xhtml+xml',
},
signal: controller.signal,
redirect: 'follow',
})
if (!response.ok) return null
const ct = response.headers.get('content-type') || ''
if (!ct.includes('text/html') && !ct.includes('application/xhtml')) return null
return await response.text()
} catch {
return null
} finally {
clearTimeout(timeoutId)
}
}
export async function POST(request: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const url = typeof body.url === 'string' ? body.url.trim() : ''
const htmlInput = typeof body.html === 'string' ? body.html : ''
const selection = typeof body.selection === 'string' ? body.selection.trim() : ''
if (!url || isBlockedUrl(url)) {
return NextResponse.json({ error: 'Invalid URL' }, { status: 400 })
}
let title = ''
let textContent = ''
let contentHtml = ''
if (body.mode === 'link') {
title = new URL(url).hostname
textContent = url
contentHtml = `<p><a href="${url}" rel="noopener noreferrer">${url}</a></p>`
} else if (body.mode === 'selection' && selection) {
title = typeof body.title === 'string' ? body.title : new URL(url).hostname
textContent = selection
const locale = resolveClipLocale(url, title, selection)
contentHtml = wrapClipPlainParagraph(selection, locale)
} else {
const html = htmlInput || (await fetchPageHtml(url))
if (!html) {
return NextResponse.json({ error: 'Could not fetch page content' }, { status: 422 })
}
const extracted = extractArticleFromHtml(html, url)
if (!extracted) {
return NextResponse.json({ error: 'Could not extract readable article' }, { status: 422 })
}
title = extracted.title || (typeof body.title === 'string' ? body.title : '')
textContent = extracted.textContent
contentHtml = extracted.content
}
const analysis = await analyzeClipContent({ url, title, textContent })
return NextResponse.json({
title: analysis.title,
summary: analysis.summary,
tags: analysis.tags,
readingTime: analysis.readingTimeMinutes,
content: contentHtml,
excerpt: textContent.slice(0, 500),
})
} catch (error) {
console.error('[POST /api/clip/analyze]', error)
return NextResponse.json({ error: 'Analysis failed' }, { status: 500 })
}
}

View File

@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
/** Liste hiérarchique des carnets pour le clipper (extension). */
export async function GET(_request: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const notebooks = await prisma.notebook.findMany({
where: { userId: session.user.id, trashedAt: null },
select: { id: true, name: true, parentId: true, color: true },
orderBy: [{ order: 'asc' }, { name: 'asc' }],
})
return NextResponse.json({ notebooks })
} catch (error) {
console.error('[GET /api/clip/notebooks]', error)
return NextResponse.json({ error: 'Failed to load notebooks' }, { status: 500 })
}
}

View File

@@ -0,0 +1,120 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { syncNoteLabels } from '@/app/actions/notes'
import { createNotification } from '@/app/actions/notifications'
import { buildClipSourceFooter, clipFooterLocaleTag } from '@/lib/clip/extract-article'
import { resolveClipLocale, wrapClipArticleHtml, applyRtlToHtmlBlocks } from '@/lib/clip/rtl-content'
import { embeddingService } from '@/lib/ai/services/embedding.service'
import { upsertNoteEmbedding } from '@/lib/embeddings'
import DOMPurify from 'isomorphic-dompurify'
function isBlockedUrl(url: string): boolean {
try {
const parsed = new URL(url)
const hostname = parsed.hostname.toLowerCase()
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return true
return ['localhost', '127.0.0.1', '0.0.0.0', '::1'].includes(hostname)
} catch {
return true
}
}
export async function POST(request: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const url = typeof body.url === 'string' ? body.url.trim() : ''
const title = typeof body.title === 'string' ? body.title.trim().slice(0, 300) : null
const rawContent = typeof body.content === 'string' ? body.content : ''
const summary = typeof body.summary === 'string' ? body.summary.trim() : ''
const notebookId = typeof body.notebookId === 'string' ? body.notebookId : null
const tags = Array.isArray(body.tags)
? body.tags.filter((t: unknown): t is string => typeof t === 'string').slice(0, 5)
: []
if (!url || isBlockedUrl(url)) {
return NextResponse.json({ error: 'Invalid URL' }, { status: 400 })
}
if (!rawContent.trim()) {
return NextResponse.json({ error: 'Content required' }, { status: 400 })
}
await prisma.user.upsert({
where: { id: session.user.id },
update: {
...(session.user.email ? { email: session.user.email } : {}),
...(session.user.name !== undefined ? { name: session.user.name } : {}),
},
create: {
id: session.user.id,
email: session.user.email || `user-${session.user.id}@local.momento`,
name: session.user.name || null,
},
})
const domain = new URL(url).hostname.replace(/^www\./, '')
const locale = resolveClipLocale(url, title || '', summary, rawContent.replace(/<[^>]+>/g, ' '))
const sanitizedContent = DOMPurify.sanitize(rawContent)
const rtlBlocks = applyRtlToHtmlBlocks(sanitizedContent, locale)
const summaryBlock = summary
? `<p dir="${locale.direction}"${locale.lang ? ` lang="${locale.lang}"` : ''}><em>${DOMPurify.sanitize(summary)}</em></p>`
: ''
const footer = buildClipSourceFooter(domain, new Date(), clipFooterLocaleTag(locale.lang))
const bodyHtml = rtlBlocks.includes('clip-article--rtl')
? rtlBlocks
: wrapClipArticleHtml(rtlBlocks, locale)
const fullContent = `${summaryBlock}${bodyHtml}${footer}`
const note = await prisma.note.create({
data: {
userId: session.user.id,
title: title || domain,
content: fullContent,
type: 'richtext',
notebookId,
sourceUrl: url,
autoGenerated: true,
...(locale.lang ? { language: locale.lang } : {}),
},
})
void (async () => {
try {
const { embedding } = await embeddingService.generateNoteEmbedding(
title || domain,
fullContent,
)
if (embedding?.length) {
await upsertNoteEmbedding(note.id, embedding)
}
} catch (error) {
console.error('[clip/save] embedding generation failed:', error)
}
})()
if (tags.length > 0) {
await syncNoteLabels(note.id, tags, notebookId, session.user.id)
}
const noteUrl = `/home?openNote=${encodeURIComponent(note.id)}`
await createNotification({
userId: session.user.id,
type: 'clip',
title: title || domain,
message: summary || undefined,
actionUrl: noteUrl,
relatedId: note.id,
})
return NextResponse.json({ noteId: note.id, noteUrl })
} catch (error) {
console.error('[POST /api/clip/save]', error)
return NextResponse.json({ error: 'Save failed' }, { status: 500 })
}
}