feat: add slides generation tool with multiple slide types
Some checks failed
CI / Lint, Test & Build (push) Failing after 17s
CI / Deploy production (on server) (push) Has been skipped

- Add slides.tool.ts with support for title, bullets, chart, stats, table, cards, timeline, quote, comparison, equation, image, summary slide types
- Chart types: bar, horizontal-bar, line, donut, radar
- Integrate with agent executor and canvas system
- Add multilingual support (en/fr)
- Various UI improvements and bug fixes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Antigravity
2026-05-22 17:18:48 +00:00
parent 0f6b9509da
commit 5728452b4a
68 changed files with 6990 additions and 2584 deletions

View File

@@ -10,9 +10,9 @@ const TYPE_DEFAULTS: Record<GenerateType, {
maxSteps: number
}> = {
'slide-generator': {
role: 'Crée une présentation PowerPoint professionnelle et visuelle à partir du contenu de la note fournie.',
tools: ['note_search', 'note_read', 'generate_pptx'],
maxSteps: 8,
role: 'Génère une présentation professionnelle à partir du contenu de la note fournie. Appelle generate_slides avec un objet JSON structuré {title, theme, slides:[...]}.',
tools: ['note_search', 'note_read', 'generate_slides'],
maxSteps: 6,
},
'excalidraw-generator': {
role: 'Génère un diagramme Excalidraw clair et professionnel à partir du contenu de la note fournie.',
@@ -30,12 +30,13 @@ export async function POST(req: NextRequest) {
const userId = session.user.id
const body = await req.json()
const { noteId, type, theme, style, language } = body as {
const { noteId, type, theme, style, language, template } = body as {
noteId: string
type: GenerateType
theme?: string
style?: string
language?: string
template?: string
}
if (!noteId || !type || !TYPE_DEFAULTS[type]) {
@@ -56,10 +57,14 @@ export async function POST(req: NextRequest) {
let role = defaults.role
if (isEn) {
if (type === 'slide-generator') {
role = 'Create a professional and visual PowerPoint presentation from the provided note content.'
const recipeHint = (theme && theme !== 'auto') ? ` Use theme:"${theme}".` : ''
role = `Generate a professional presentation from the provided note content.${recipeHint} Call generate_slides with structured JSON {title, theme, slides:[...]}.`
} else {
role = 'Generate a clear and professional Excalidraw diagram from the provided note content.'
}
} else if (type === 'slide-generator') {
const recipeHint = (theme && theme !== 'auto') ? ` Utilise le thème "${theme}".` : ''
role = `Génère une présentation professionnelle à partir du contenu de la note fournie.${recipeHint} Appelle generate_slides avec le JSON structuré {title, theme, slides:[...]}.`
}
const agentName = type === 'slide-generator'
@@ -71,13 +76,14 @@ export async function POST(req: NextRequest) {
name: agentName,
type,
role,
description: (type === 'slide-generator' && template && template !== 'auto') ? `template:${template}` : undefined,
tools: JSON.stringify(defaults.tools),
maxSteps: defaults.maxSteps,
frequency: 'one-shot',
isEnabled: true,
sourceNoteIds: JSON.stringify([noteId]),
targetNotebookId: note.notebookId ?? undefined,
slideTheme: theme ?? 'vibrant_tech',
slideTheme: theme ?? 'keynote',
slideStyle: style ?? 'soft',
userId,
},
@@ -118,7 +124,7 @@ export async function GET(req: NextRequest) {
const action = await prisma.agentAction.findFirst({
where: { agentId },
orderBy: { createdAt: 'desc' },
select: { id: true, status: true, result: true, log: true },
select: { id: true, status: true, result: true, log: true, createdAt: true },
})
if (!action) {
@@ -126,6 +132,19 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ status: 'running' })
}
// Detect stale running actions (killed by process restart or hot-reload)
const GENERATION_TIMEOUT_MS = 8 * 60 * 1000 // 8 minutes
if (action.status === 'running') {
const ageMs = Date.now() - new Date(action.createdAt).getTime()
if (ageMs > GENERATION_TIMEOUT_MS) {
await prisma.agentAction.update({
where: { id: action.id },
data: { status: 'failure', log: 'Generation timed out (process restart)' },
}).catch(() => { /* best-effort */ })
return NextResponse.json({ status: 'failure', actionId: action.id, error: 'La génération a expiré. Veuillez réessayer.' })
}
}
// If success, find canvasId from the related canvas (result stores canvas id)
let canvasId: string | null = null
let noteId: string | null = null

View File

@@ -0,0 +1,130 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { getSystemConfig } from '@/lib/config'
import { getTagsProvider } from '@/lib/ai/factory'
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
export type PersonaId = 'engineer' | 'financial' | 'customer' | 'skeptic' | 'optimist'
interface PersonaDef {
id: PersonaId
label: string
description: string
systemPrompt: string
}
const PERSONAS: PersonaDef[] = [
{
id: 'engineer',
label: 'Ingénieur',
description: 'Faisabilité technique, complexité, risques d\'implémentation',
systemPrompt: `Tu es un ingénieur senior pragmatique et rigoureux. Analyse les notes ci-dessous en te concentrant sur :
la faisabilité technique, les risques d'implémentation, la complexité cachée, les dépendances et contraintes techniques.
Fournis 3 à 5 commentaires concis et 2 à 3 questions techniques précises qui obligent à réfléchir plus profondément.
Réponds dans la même langue que la note. Format : commentaires en liste puis questions en liste.`,
},
{
id: 'financial',
label: 'Financier',
description: 'ROI, coûts, viabilité économique, risques financiers',
systemPrompt: `Tu es un analyste financier exigeant. Analyse les notes ci-dessous sous l'angle économique :
ROI potentiel, coûts cachés, modèle de revenus, risques financiers, allocation de ressources, viabilité à long terme.
Fournis 3 à 5 observations financières et 2 à 3 questions qui remettent en cause les hypothèses économiques.
Réponds dans la même langue que la note. Format : observations en liste puis questions en liste.`,
},
{
id: 'customer',
label: 'Client',
description: 'Expérience utilisateur, besoins réels, valeur perçue',
systemPrompt: `Tu es un client exigeant qui recherche de la valeur concrète. Analyse les notes ci-dessous du point de vue de l'utilisateur final :
clarté de la valeur ajoutée, frictions dans l'expérience, besoins non satisfaits, adéquation au problème réel.
Fournis 3 à 5 réactions authentiques de client et 2 à 3 questions sur l'utilité réelle.
Réponds dans la même langue que la note. Format : réactions en liste puis questions en liste.`,
},
{
id: 'skeptic',
label: 'Sceptique',
description: 'Hypothèses contestables, points faibles, angles morts',
systemPrompt: `Tu es un expert sceptique et critique constructif. Analyse les notes ci-dessous en cherchant :
les hypothèses non validées, les points faibles, les angles morts, les contradictions internes, les risques ignorés.
Sois direct et précis. Fournis 3 à 5 critiques fondées et 2 à 3 questions qui remettent en cause les fondements.
Réponds dans la même langue que la note. Format : critiques en liste puis questions en liste.`,
},
{
id: 'optimist',
label: 'Optimiste',
description: 'Opportunités sous-estimées, potentiel inexploité, synergies',
systemPrompt: `Tu es un stratège visionnaire et enthousiaste. Analyse les notes ci-dessous pour identifier :
les opportunités sous-estimées, le potentiel inexploité, les synergies possibles, les effets de levier, les succès rapides atteignables.
Fournis 3 à 5 opportunités concrètes et 2 à 3 questions qui ouvrent de nouvelles perspectives.
Réponds dans la même langue que la note. Format : opportunités en liste puis questions en liste.`,
},
]
export async function POST(request: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { content, title, personaId } = await request.json()
if (!content || typeof content !== 'string') {
return NextResponse.json({ error: 'Content is required' }, { status: 400 })
}
if (!personaId) {
return NextResponse.json({ error: 'personaId is required' }, { status: 400 })
}
const persona = PERSONAS.find(p => p.id === personaId)
if (!persona) {
return NextResponse.json({ error: 'Invalid personaId' }, { status: 400 })
}
// Quota check (reuse reformulate quota)
try {
await checkEntitlementOrThrow(session.user.id, 'reformulate')
} catch (err) {
if (err instanceof QuotaExceededError) {
const isTierLocked = err.currentQuota === 0
return NextResponse.json({
error: isTierLocked ? 'feature_locked' : 'quota_exceeded',
errorKey: isTierLocked ? 'ai.featureLocked' : 'ai.quotaExceeded',
upgradeTier: err.upgradeTier,
quotaExceeded: true,
}, { status: 402 })
}
throw err
}
const config = await getSystemConfig()
const provider = getTagsProvider(config)
const noteText = title ? `Titre : ${title}\n\n${content}` : content
// Strip HTML tags for cleaner analysis
const plainText = noteText.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
const fullPrompt = `${persona.systemPrompt}\n\n---\nNOTE À ANALYSER :\n${plainText}`
const result = await provider.generateText(fullPrompt)
incrementUsageAsync(session.user.id, 'reformulate')
return NextResponse.json({
personaId: persona.id,
personaLabel: persona.label,
analysis: result,
})
} catch (error) {
console.error('Personas API error:', error)
return NextResponse.json({ error: 'Failed to generate persona analysis' }, { status: 500 })
}
}
// GET — return available personas (no auth needed)
export async function GET() {
return NextResponse.json({
personas: PERSONAS.map(({ id, label, description }) => ({ id, label, description })),
})
}

View File

@@ -95,6 +95,16 @@ async function getParentContext(
return { notes, noteIds }
}
function localeToLanguageName(locale?: string): string {
const map: Record<string, string> = {
en: 'English', fr: 'French', es: 'Spanish', de: 'German',
it: 'Italian', pt: 'Portuguese', nl: 'Dutch', ru: 'Russian',
zh: 'Chinese', ja: 'Japanese', ko: 'Korean', ar: 'Arabic',
fa: 'Farsi', hi: 'Hindi', pl: 'Polish',
}
return map[locale ?? ''] ?? 'English'
}
function buildExpandPromptV2(
parentTitle: string,
parentDesc: string,
@@ -157,7 +167,7 @@ RESPOND ONLY with a valid JSON array of 9 objects:
CRITICAL: Each idea MUST have at least 1 noteRef when notes are provided.
LANGUAGE: You MUST write ALL titles, descriptions, connectionToSeed, and explanation fields in ${locale === 'fr' ? 'French' : locale === 'es' ? 'Spanish' : locale === 'de' ? 'German' : locale === 'it' ? 'Italian' : locale === 'pt' ? 'Portuguese' : locale === 'nl' ? 'Dutch' : locale === 'ru' ? 'Russian' : locale === 'zh' ? 'Chinese' : locale === 'ja' ? 'Japanese' : locale === 'ko' ? 'Korean' : locale === 'ar' ? 'Arabic' : locale === 'fa' ? 'Farsi' : locale === 'hi' ? 'Hindi' : locale === 'pl' ? 'Polish' : 'the same language as the seed idea'}.`
LANGUAGE: You MUST write ALL titles, descriptions, connectionToSeed, and explanation fields in ${localeToLanguageName(locale)}.`
}
export async function POST(

View File

@@ -161,6 +161,7 @@ export async function PATCH(
const updates: Record<string, any> = {}
if (typeof body.isPublic === 'boolean') updates.isPublic = body.isPublic
if (typeof body.guestCanEdit === 'boolean') updates.guestCanEdit = body.guestCanEdit
if (typeof body.seedIdea === 'string' && body.seedIdea.trim()) updates.seedIdea = body.seedIdea.trim()
if (Object.keys(updates).length === 0) {
return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 })

View File

@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import { z } from 'zod'
import { verifyParticipant } from '@/lib/brainstorm-collab'
import { emitToSession } from '@/lib/socket-emit'
const starSchema = z.object({
ideaId: z.string().min(1),
})
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ sessionId: string }> }
) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const { sessionId } = await params
const body = await request.json()
const { ideaId } = starSchema.parse(body)
const brainstormSession = await prisma.brainstormSession.findFirst({
where: { id: sessionId },
})
if (!brainstormSession) {
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
}
const { isParticipant } = await verifyParticipant(sessionId, session.user.id, 'editor')
if (!isParticipant) {
return NextResponse.json({ error: 'No edit permission' }, { status: 403 })
}
const idea = await prisma.brainstormIdea.findFirst({
where: { id: ideaId, sessionId },
})
if (!idea) {
return NextResponse.json({ error: 'Idea not found' }, { status: 404 })
}
const updated = await prisma.brainstormIdea.update({
where: { id: ideaId },
data: { isStarred: !idea.isStarred },
})
await emitToSession(sessionId, 'idea:starred', {
ideaId,
isStarred: updated.isStarred,
userId: session.user.id,
})
return NextResponse.json({ success: true, data: { ideaId, isStarred: updated.isStarred } })
} catch (error) {
console.error('Error starring idea:', error)
return NextResponse.json({ error: 'Failed to star idea' }, { status: 500 })
}
}

View File

@@ -0,0 +1,94 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import { runLaneWithBillingUser } from '@/lib/ai/provider-for-user'
import { getSystemConfig } from '@/lib/config'
import { verifyParticipant } from '@/lib/brainstorm-collab'
function localeToLanguageName(locale?: string): string {
const map: Record<string, string> = {
en: 'English', fr: 'French', es: 'Spanish', de: 'German',
it: 'Italian', pt: 'Portuguese', nl: 'Dutch', ru: 'Russian',
zh: 'Chinese', ja: 'Japanese', ko: 'Korean', ar: 'Arabic',
fa: 'Farsi', hi: 'Hindi', pl: 'Polish',
}
return map[locale ?? ''] ?? 'English'
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ sessionId: string }> }
) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const { sessionId } = await params
const body = await request.json().catch(() => ({}))
const locale: string = body.locale || 'en'
const brainstormSession = await prisma.brainstormSession.findFirst({
where: { id: sessionId },
include: {
ideas: {
where: { status: { not: 'dismissed' } },
orderBy: [{ waveNumber: 'asc' }, { createdAt: 'asc' }],
},
},
})
if (!brainstormSession) {
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
}
const { isParticipant } = await verifyParticipant(sessionId, session.user.id, 'viewer')
if (!isParticipant) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
const ideas = brainstormSession.ideas
if (ideas.length === 0) {
return NextResponse.json({ error: 'No ideas to summarize' }, { status: 400 })
}
const ideasText = ideas
.map((idea, i) => `${i + 1}. [Wave ${idea.waveNumber}] ${idea.title}: ${idea.description}`)
.join('\n')
const starredIdeas = ideas.filter(i => i.isStarred)
const starredText = starredIdeas.length > 0
? `\nStarred/favorite ideas: ${starredIdeas.map(i => i.title).join(', ')}`
: ''
const language = localeToLanguageName(locale)
const prompt = `You are summarizing a brainstorming session. Respond in ${language}.
Seed idea: "${brainstormSession.seedIdea}"
Ideas generated (${ideas.length} total):
${ideasText}${starredText}
Write a concise synthesis (4-6 sentences) that:
1. Captures the main themes and directions explored
2. Highlights the most promising ideas or clusters
3. Suggests a concrete next step
Be direct and action-oriented. No bullet points, just flowing prose.`
const config = await getSystemConfig()
const { result: summary } = await runLaneWithBillingUser(
'tags',
config,
session.user.id,
(provider) => provider.generateText(prompt),
)
return NextResponse.json({ success: true, data: { summary: summary.trim() } })
} catch (error) {
console.error('Error summarizing brainstorm:', error)
return NextResponse.json({ error: 'Failed to summarize' }, { status: 500 })
}
}

View File

@@ -109,6 +109,16 @@ Respond ONLY with a valid JSON array of objects:
}
}
function localeToLanguageName(locale?: string): string {
const map: Record<string, string> = {
en: 'English', fr: 'French', es: 'Spanish', de: 'German',
it: 'Italian', pt: 'Portuguese', nl: 'Dutch', ru: 'Russian',
zh: 'Chinese', ja: 'Japanese', ko: 'Korean', ar: 'Arabic',
fa: 'Farsi', hi: 'Hindi', pl: 'Polish',
}
return map[locale ?? ''] ?? 'English'
}
function buildPromptV2(seedIdea: string, classifiedNotes: ClassifiedNote[], locale?: string): string {
const supportNotes = classifiedNotes.filter(n => n.category === 'SUPPORT')
const tensionNotes = classifiedNotes.filter(n => n.category === 'TENSION')
@@ -168,7 +178,7 @@ RESPOND ONLY with a valid JSON array of 9 objects:
CRITICAL: Each idea MUST have at least 1 noteRef. Only use null noteId if genuinely no note connects to that idea.
LANGUAGE: You MUST write ALL titles, descriptions, connectionToSeed, and explanation fields in ${locale === 'fr' ? 'French' : locale === 'es' ? 'Spanish' : locale === 'de' ? 'German' : locale === 'it' ? 'Italian' : locale === 'pt' ? 'Portuguese' : locale === 'nl' ? 'Dutch' : locale === 'ru' ? 'Russian' : locale === 'zh' ? 'Chinese' : locale === 'ja' ? 'Japanese' : locale === 'ko' ? 'Korean' : locale === 'ar' ? 'Arabic' : locale === 'fa' ? 'Farsi' : locale === 'hi' ? 'Hindi' : locale === 'pl' ? 'Polish' : 'the same language as the seed idea'}.`
LANGUAGE: You MUST write ALL titles, descriptions, connectionToSeed, and explanation fields in ${localeToLanguageName(locale)}.`
}
function buildFallbackIdeas(classifiedNotes: ClassifiedNote[]): any[] {

View File

@@ -0,0 +1,528 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { auth } from '@/auth'
import PptxGenJS from 'pptxgenjs'
// ─── Color helpers ─────────────────────────────────────────────────────────
const hex = (c: string) => c.replace('#', '').toUpperCase()
interface PptxPalette {
primary: string; secondary: string; accent: string
bg: string; text: string; muted: string; isDark: boolean
}
function resolvePalette(theme?: string): PptxPalette {
const palettes: Record<string, PptxPalette> = {
'midnight-cathedral': { primary: '0A0F1E', secondary: 'C9A84C', accent: 'E8D5B5', bg: '0A0F1E', text: 'F1F5F9', muted: '94A3B8', isDark: true },
'aurora-borealis': { primary: '0F0A2A', secondary: '7C3AED', accent: '06B6D4', bg: '0F0A2A', text: 'F1F5F9', muted: '94A3B8', isDark: true },
'tokyo-neon': { primary: '0A0A0F', secondary: 'FF006E', accent: '3A86FF', bg: '0A0A0F', text: 'F1F5F9', muted: '94A3B8', isDark: true },
'venture-pitch': { primary: '18181B', secondary: 'F97316', accent: '14B8A6', bg: '18181B', text: 'F1F5F9', muted: '94A3B8', isDark: true },
'forest-floor': { primary: '0D1B0E', secondary: '22C55E', accent: 'A3B18A', bg: '0D1B0E', text: 'F1F5F9', muted: '94A3B8', isDark: true },
'steel-glass': { primary: '292524', secondary: 'D4C5A9', accent: '94A3B8', bg: '292524', text: 'F1F5F9', muted: '94A3B8', isDark: true },
'cyberpunk-terminal': { primary: '0A0A0A', secondary: '00FF41', accent: 'FFA500', bg: '0A0A0A', text: 'F1F5F9', muted: '94A3B8', isDark: true },
'sunlit-gallery': { primary: 'FAF7F0', secondary: 'D4A574', accent: '5B9BD5', bg: 'FAF7F0', text: '1C1C1C', muted: '64748B', isDark: false },
'clinical-precision': { primary: 'F8FAFC', secondary: '0891B2', accent: '34D399', bg: 'F8FAFC', text: '0F172A', muted: '64748B', isDark: false },
'editorial-ink': { primary: 'FFFCF5', secondary: '1A1A2E', accent: '800020', bg: 'FFFCF5', text: '1A1A2E', muted: '64748B', isDark: false },
'coastal-morning': { primary: 'F0F7FF', secondary: '2563EB', accent: 'F97066', bg: 'F0F7FF', text: '0F172A', muted: '64748B', isDark: false },
'paper-studio': { primary: 'FEFCF8', secondary: '1E293B', accent: 'C2410C', bg: 'FEFCF8', text: '1E293B', muted: '64748B', isDark: false },
'architectural-saas': { primary: 'F2F0E9', secondary: 'A47148', accent: '4A4E69', bg: 'F2F0E9', text: '1C1C1C', muted: '64748B', isDark: false },
}
const key = (theme || 'architectural-saas').toLowerCase().replace(/[^a-z]/g, '-').replace(/-+/g, '-')
return palettes[key] ?? palettes['architectural-saas']
}
// ─── PPTX Builder (new JSON format) ────────────────────────────────────────
async function buildPptx(spec: any): Promise<Buffer> {
const pptx = new PptxGenJS()
pptx.layout = 'LAYOUT_WIDE'
pptx.title = spec.title || 'Presentation'
pptx.subject = spec.title || 'Presentation'
const p = resolvePalette(spec.theme)
const W = 13.33, H = 7.5
for (const slide of (spec.slides ?? [])) {
const s = pptx.addSlide()
switch (slide.type) {
case 'title': {
s.background = { color: p.isDark ? p.primary : p.secondary }
s.addShape(pptx.ShapeType.rect, {
x: 0, y: 0, w: W, h: 0.08,
fill: { color: p.accent }, line: { type: 'none' },
})
s.addText(slide.title || spec.title, {
x: 1, y: 2, w: W - 2, h: 2,
fontSize: 42, color: p.isDark ? 'FFFFFF' : 'FFFFFF', bold: true, align: 'center',
})
if (slide.subtitle) {
s.addText(slide.subtitle, {
x: 1, y: 4.3, w: W - 2, h: 1,
fontSize: 20, color: 'FFFFFF', align: 'center', transparency: 20,
})
}
s.addShape(pptx.ShapeType.rect, {
x: 0, y: H - 0.08, w: W, h: 0.08,
fill: { color: p.accent }, line: { type: 'none' },
})
break
}
case 'bullets': {
s.background = { color: p.bg }
s.addShape(pptx.ShapeType.rect, {
x: 0, y: 0, w: 0.06, h: H,
fill: { color: p.secondary }, line: { type: 'none' },
})
s.addText(slide.title, {
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
})
s.addShape(pptx.ShapeType.rect, {
x: 0.5, y: 1.35, w: 0.5, h: 0.06,
fill: { color: p.accent }, line: { type: 'none' },
})
const bullets = (slide.items ?? []).map((item: string) => ({
text: item,
options: { bullet: { type: 'bullet' as const }, paraSpaceAfter: 6 },
}))
if (bullets.length) {
s.addText(bullets, {
x: 0.5, y: 1.6, w: W - 1, h: H - 2,
fontSize: 17, color: p.text,
})
}
break
}
case 'chart': {
s.background = { color: p.bg }
s.addShape(pptx.ShapeType.rect, {
x: 0, y: 0, w: 0.06, h: H,
fill: { color: p.secondary }, line: { type: 'none' },
})
s.addText(slide.title, {
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
})
if (slide.subtitle) {
s.addText(slide.subtitle, {
x: 0.5, y: 1.2, w: W - 1, h: 0.5,
fontSize: 12, color: p.muted,
})
}
const data = slide.data ?? []
if (data.length > 0) {
const chartType = slide.chartType === 'line' ? pptx.ChartType.line
: slide.chartType === 'donut' ? pptx.ChartType.doughnut
: slide.chartType === 'radar' ? pptx.ChartType.radar
: pptx.ChartType.bar
const chartData = [{
name: slide.title,
labels: data.map((d: any) => d.label),
values: data.map((d: any) => d.value),
}]
s.addChart(chartType, chartData, {
x: 0.8, y: 1.8, w: W - 1.6, h: H - 2.5,
showValue: true,
showTitle: false,
showLegend: false,
chartColors: [p.secondary, p.accent, '10B981', 'F59E0B', 'EF4444', '6366F1'],
valAxisHidden: slide.chartType === 'donut' || slide.chartType === 'radar',
catAxisHidden: slide.chartType === 'donut',
})
}
break
}
case 'stats': {
s.background = { color: p.bg }
s.addShape(pptx.ShapeType.rect, {
x: 0, y: 0, w: 0.06, h: H,
fill: { color: p.secondary }, line: { type: 'none' },
})
s.addText(slide.title, {
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
})
const stats = (slide.stats ?? []).slice(0, 4)
const statW = (W - 1 - (stats.length - 1) * 0.3) / stats.length
stats.forEach((st: any, i: number) => {
const x = 0.5 + i * (statW + 0.3)
s.addShape(pptx.ShapeType.rect, {
x, y: 2, w: statW, h: 0.06,
fill: { color: p.accent }, line: { type: 'none' },
})
s.addText(st.value, {
x, y: 2.3, w: statW, h: 2,
fontSize: 48, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true, align: 'center',
})
s.addText(st.label, {
x, y: 4.4, w: statW, h: 0.7,
fontSize: 13, color: p.muted, align: 'center',
})
})
break
}
case 'table': {
s.background = { color: p.bg }
s.addText(slide.title, {
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
})
const headers = slide.headers ?? []
const rows = slide.rows ?? []
if (headers.length > 0) {
const tableRows = [
headers.map((h: string) => ({ text: h, options: { bold: true, color: 'FFFFFF', fill: { color: p.secondary } } })),
...rows.map((row: string[], ri: number) =>
row.map((cell: string) => ({ text: cell, options: { fill: { color: ri % 2 === 0 ? 'F8F8F8' : 'FFFFFF' } } }))
),
]
s.addTable(tableRows, {
x: 0.5, y: 1.5, w: W - 1, h: H - 2,
fontSize: 13, color: p.text,
border: { type: 'solid', pt: 0.5, color: 'DDDDDD' },
colW: Array(headers.length).fill((W - 1) / headers.length),
})
}
break
}
case 'cards': {
s.background = { color: p.bg }
s.addShape(pptx.ShapeType.rect, {
x: 0, y: 0, w: 0.06, h: H,
fill: { color: p.secondary }, line: { type: 'none' },
})
s.addText(slide.title, {
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
fontSize: 26, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
})
const cards = (slide.cards ?? []).slice(0, 6)
const cols = cards.length <= 2 ? 2 : cards.length <= 3 ? 3 : cards.length === 4 ? 2 : 3
const cardW = (W - 1 - (cols - 1) * 0.2) / cols
const cardH = cards.length > cols ? 2.3 : 3.5
cards.forEach((card: any, i: number) => {
const col = i % cols
const row = Math.floor(i / cols)
const x = 0.5 + col * (cardW + 0.2)
const y = 1.5 + row * (cardH + 0.15)
s.addShape(pptx.ShapeType.roundRect, {
x, y, w: cardW, h: cardH,
fill: { color: p.isDark ? '1E1E2E' : 'FFFFFF' },
line: { color: p.isDark ? '333333' : 'E5E7EB', pt: 1 },
rectRadius: 0.08,
})
s.addShape(pptx.ShapeType.rect, {
x, y, w: cardW, h: 0.05,
fill: { color: p.accent }, line: { type: 'none' },
})
s.addText(card.title, {
x: x + 0.15, y: y + 0.25, w: cardW - 0.3, h: 0.6,
fontSize: 14, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
})
s.addText(card.description, {
x: x + 0.15, y: y + 0.8, w: cardW - 0.3, h: cardH - 1,
fontSize: 12, color: p.text,
})
})
break
}
case 'timeline': {
s.background = { color: p.bg }
s.addText(slide.title, {
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
})
const events = (slide.events ?? []).slice(0, 6)
const evH = Math.min(0.9, (H - 2) / events.length)
events.forEach((ev: any, i: number) => {
const y = 1.6 + i * evH
// Dot
s.addShape(pptx.ShapeType.ellipse, {
x: 0.7, y: y + 0.1, w: 0.2, h: 0.2,
fill: { color: p.accent }, line: { type: 'none' },
})
// Line
if (i < events.length - 1) {
s.addShape(pptx.ShapeType.rect, {
x: 0.78, y: y + 0.32, w: 0.04, h: evH - 0.2,
fill: { color: p.accent }, line: { type: 'none' },
})
}
s.addText(ev.date, {
x: 1.1, y, w: 2, h: 0.4,
fontSize: 10, color: p.accent, bold: true,
})
s.addText(ev.title, {
x: 1.1, y: y + 0.3, w: W - 2, h: 0.4,
fontSize: 14, color: p.text, bold: true,
})
if (ev.description) {
s.addText(ev.description, {
x: 1.1, y: y + 0.6, w: W - 2, h: 0.3,
fontSize: 11, color: p.muted,
})
}
})
break
}
case 'quote': {
const qBg = p.isDark ? '0D1117' : '1A1A2E'
s.background = { color: qBg }
s.addText('\u201C', {
x: 0.5, y: 0.2, w: 2, h: 2,
fontSize: 96, color: p.accent, bold: true, transparency: 60,
})
s.addText(slide.quote, {
x: 1, y: 1.5, w: W - 2, h: 3.5,
fontSize: 24, color: 'FFFFFF', italic: true, align: 'left',
})
if (slide.author) {
s.addText(`\u2014 ${slide.author}`, {
x: 1, y: 5.2, w: W - 2, h: 0.8,
fontSize: 14, color: p.accent, align: 'left',
})
}
if (slide.context) {
s.addText(slide.context, {
x: 1, y: 6, w: W - 2, h: 0.8,
fontSize: 12, color: 'AAAAAA',
})
}
break
}
case 'comparison': {
s.background = { color: p.bg }
s.addText(slide.title, {
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
})
const colW = (W - 1.5) / 2
const colY = 1.5, colH = H - 2
// Left
if (slide.left) {
s.addShape(pptx.ShapeType.roundRect, {
x: 0.5, y: colY, w: colW, h: colH,
fill: { color: p.isDark ? '1E1E2E' : 'F8FAFC' },
line: { color: p.secondary, pt: 1 },
rectRadius: 0.08,
})
s.addText(slide.left.title, {
x: 0.7, y: colY + 0.15, w: colW - 0.4, h: 0.5,
fontSize: 14, color: p.secondary, bold: true,
})
const lBullets = (slide.left.points ?? []).map((pt: string) => ({
text: pt, options: { bullet: { type: 'bullet' as const }, paraSpaceAfter: 4 },
}))
s.addText(lBullets, {
x: 0.7, y: colY + 0.7, w: colW - 0.4, h: colH - 1.4,
fontSize: 13, color: p.text,
})
if (slide.left.score) {
s.addText(slide.left.score, {
x: 0.7, y: colY + colH - 0.6, w: colW - 0.4, h: 0.5,
fontSize: 18, color: p.secondary, bold: true, align: 'center',
})
}
}
// Right
if (slide.right) {
const rx = 0.5 + colW + 0.5
s.addShape(pptx.ShapeType.roundRect, {
x: rx, y: colY, w: colW, h: colH,
fill: { color: p.isDark ? '1E1E2E' : 'F8FAFC' },
line: { color: p.accent, pt: 1 },
rectRadius: 0.08,
})
s.addText(slide.right.title, {
x: rx + 0.2, y: colY + 0.15, w: colW - 0.4, h: 0.5,
fontSize: 14, color: p.accent, bold: true,
})
const rBullets = (slide.right.points ?? []).map((pt: string) => ({
text: pt, options: { bullet: { type: 'bullet' as const }, paraSpaceAfter: 4 },
}))
s.addText(rBullets, {
x: rx + 0.2, y: colY + 0.7, w: colW - 0.4, h: colH - 1.4,
fontSize: 13, color: p.text,
})
if (slide.right.score) {
s.addText(slide.right.score, {
x: rx + 0.2, y: colY + colH - 0.6, w: colW - 0.4, h: 0.5,
fontSize: 18, color: p.accent, bold: true, align: 'center',
})
}
}
break
}
case 'equation': {
s.background = { color: p.bg }
s.addText(slide.title, {
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
})
const eqs = (slide.equations ?? []).slice(0, 4)
const eqH = Math.min(1.5, (H - 2.5) / eqs.length)
eqs.forEach((eq: any, i: number) => {
const y = 1.8 + i * eqH
s.addText(eq.latex, {
x: 1, y, w: W - 2, h: eqH * 0.6,
fontSize: 28, color: p.text, align: 'center', fontFace: 'Cambria Math',
})
if (eq.label) {
s.addText(eq.label, {
x: 1, y: y + eqH * 0.6, w: W - 2, h: eqH * 0.3,
fontSize: 11, color: p.muted, align: 'center',
})
}
})
if (slide.explanation) {
s.addText(slide.explanation, {
x: 1, y: H - 1.2, w: W - 2, h: 0.8,
fontSize: 12, color: p.muted, align: 'center',
})
}
break
}
case 'image': {
s.background = { color: p.bg }
s.addText(slide.title, {
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
})
if (slide.url) {
try {
s.addImage({
path: slide.url,
x: 2, y: 1.5, w: W - 4, h: H - 2.5,
sizing: { type: 'contain', w: W - 4, h: H - 2.5 },
})
} catch { /* image unavailable */ }
}
if (slide.caption) {
s.addText(slide.caption, {
x: 0.5, y: H - 0.9, w: W - 1, h: 0.7,
fontSize: 12, color: p.muted, align: 'center',
})
}
break
}
case 'summary': {
s.background = { color: p.bg }
s.addShape(pptx.ShapeType.rect, {
x: 0, y: 0, w: 0.06, h: H,
fill: { color: p.secondary }, line: { type: 'none' },
})
s.addText(slide.title || 'En résumé', {
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
})
s.addShape(pptx.ShapeType.rect, {
x: 0.5, y: 1.35, w: 0.5, h: 0.06,
fill: { color: p.accent }, line: { type: 'none' },
})
const items = (slide.items ?? []).slice(0, 8)
items.forEach((item: string, i: number) => {
const y = 1.6 + i * 0.7
s.addShape(pptx.ShapeType.rect, {
x: 0.5, y, w: W - 1, h: 0.55,
fill: { color: i % 2 === 0 ? (p.isDark ? '1E1E2E' : 'F5F5F5') : (p.isDark ? '151525' : 'EBEBEB') },
line: { type: 'none' },
})
s.addText('\u2713', {
x: 0.7, y, w: 0.5, h: 0.55,
fontSize: 14, color: p.accent, bold: true, valign: 'middle',
})
s.addText(item, {
x: 1.3, y, w: W - 2, h: 0.55,
fontSize: 14, color: p.text, valign: 'middle',
})
})
break
}
// Fallback: treat as bullets
default: {
s.background = { color: p.bg }
s.addText(slide.title || '', {
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
})
const content = slide.items || slide.content || []
if (Array.isArray(content) && content.length) {
const defBullets = content.map((item: string) => ({
text: String(item),
options: { bullet: { type: 'bullet' as const }, paraSpaceAfter: 6 },
}))
s.addText(defBullets, {
x: 0.5, y: 1.5, w: W - 1, h: H - 2,
fontSize: 17, color: p.text,
})
}
}
}
}
return pptx.write({ outputType: 'nodebuffer' }) as Promise<Buffer>
}
// ─── API Route ─────────────────────────────────────────────────────────────
export async function GET(req: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const canvasId = req.nextUrl.searchParams.get('id')
if (!canvasId) {
return NextResponse.json({ error: 'Missing id' }, { status: 400 })
}
const canvas = await prisma.canvas.findUnique({
where: { id: canvasId, userId: session.user.id },
})
if (!canvas) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
let parsed: any
try {
parsed = JSON.parse(canvas.data)
} catch {
return NextResponse.json({ error: 'Invalid canvas data' }, { status: 500 })
}
if (parsed.type !== 'slides') {
return NextResponse.json({ error: 'Not a slides canvas' }, { status: 400 })
}
// Support both new format (spec embedded) and legacy (spec at root)
const spec = parsed.spec || parsed
if (!spec || !Array.isArray(spec.slides)) {
return NextResponse.json({ error: 'No slide data found — please regenerate the presentation' }, { status: 400 })
}
const buffer = await buildPptx(spec)
const filename = `${(canvas.name || 'presentation').replace(/[^a-zA-Z0-9-_ ]/g, '_').trim()}.pptx`
return new NextResponse(new Uint8Array(buffer), {
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Length': String(buffer.length),
},
})
} catch (err: any) {
console.error('[PPTX Export]', err)
return NextResponse.json({ error: err.message || 'Export failed' }, { status: 500 })
}
}

View File

@@ -220,10 +220,11 @@ Momento is an intelligent note-taking application. Key features include:
- **Lab**: Experimental AI tools for data analysis and deeper insights.
## Available tools
You have access to: note_search, note_read, document_search, task_extract, web_search, web_scrape.
You have access to: note_search, note_read, note_find_and_update, document_search, task_extract, web_search, web_scrape.
Only use tools if you need more information. Never invent note IDs or URLs.
- document_search: Searches attached PDF documents for the current note/notebook. Use when the user asks about documents or files.
- task_extract: Extracts action items from notes and creates a synthesis note. Use when the user asks to extract tasks or TODOs.`,
- task_extract: Extracts action items from notes and creates a synthesis note. Use when the user asks to extract tasks or TODOs.
- note_find_and_update: Finds a note by search query and appends/prepends/replaces content. Use when the user says "find the note about X and add Y to it".`,
},
fr: {
contextWithNotes: `## Notes et documents de l'utilisateur\n\n${contextNotes}\n\nQuand tu utilises une info venant des notes ci-dessus, cite le titre de la note source entre parenthèses, ex: "Le déploiement se fait via Docker (💻 Development Guide)". Pour les documents PDF, cite le nom du fichier et la page, ex: "Le chiffre d'affaires est de 5M$ (📄 rapport.pdf p.12)". Ne recopie pas mot pour mot — reformule.`,
@@ -254,9 +255,10 @@ Only use tools if you need more information. Never invent note IDs or URLs.
Momento est une application de prise de notes intelligente. Ses fonctionnalités : Éditeur Markdown riche, Copilot IA, Organisation par Carnets, Recherche sémantique, Agents IA, Lab.
## Outils disponibles
Tu as accès à : note_search, note_read, document_search, task_extract, web_search, web_scrape.
Tu as accès à : note_search, note_read, note_find_and_update, document_search, task_extract, web_search, web_scrape.
- document_search : Recherche dans les documents PDF attachés à la note/au carnet.
- task_extract : Extrait les tâches/action items des notes et crée une note de synthèse.`,
- task_extract : Extrait les tâches/action items des notes et crée une note de synthèse.
- note_find_and_update : Trouve une note par recherche textuelle et ajoute/prépose/remplace du contenu. Utilise quand l'utilisateur dit "trouve la note sur X et ajoute-y Y".`,
},
fa: {
contextWithNotes: `## یادداشت‌های کاربر\n\n${contextNotes}\n\nهنگام استفاده از اطلاعات یادداشت‌های بالا، عنوان یادداشت منبع را در پرانتز ذکر کنید.`,

View File

@@ -0,0 +1,138 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
// ── Stopwords FR + EN ─────────────────────────────────────────────────────────
const STOPWORDS = new Set([
'le','la','les','de','du','des','un','une','et','en','au','aux','ce','se',
'sa','son','ses','mon','ma','mes','ton','ta','tes','que','qui','quoi','dont',
'il','elle','ils','elles','nous','vous','je','tu','on','par','pour','sur',
'sous','avec','dans','est','sont','pas','ne','plus','très','tout','comme',
'mais','donc','car','cet','cette','ces','leur','leurs','note','notes',
'the','a','an','and','or','but','in','on','at','to','for','of','with','by',
'from','is','are','was','were','be','been','have','has','had','do','does',
'did','will','would','could','should','may','might','this','that','these',
'those','it','its','they','them','their','he','she','we','you','not','no',
'so','if','as','up','out','about','also','just','can','all','any','get',
])
function stripHtml(html: string): string {
return html.replace(/<[^>]+>/g, ' ').replace(/&\w+;/g, ' ')
}
function extractKeywords(text: string): Set<string> {
return new Set(
stripHtml(text)
.toLowerCase()
.split(/[\s\p{P}]+/u)
.filter(w => w.length >= 3 && !STOPWORDS.has(w) && !/^\d+$/.test(w))
)
}
function jaccardSimilarity(a: Set<string>, b: Set<string>): number {
if (a.size === 0 || b.size === 0) return 0
let intersection = 0
for (const w of a) if (b.has(w)) intersection++
return intersection / (a.size + b.size - intersection)
}
type EdgeType = 'title_mention' | 'shared_label' | 'jaccard'
interface GraphEdge { source: string; target: string; weight: number; type: EdgeType }
// GET /api/graph — connexions automatiques à 3 niveaux
export async function GET(request: NextRequest) {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const userId = session.user.id
const { searchParams } = new URL(request.url)
const notebookId = searchParams.get('notebookId') || undefined
const notes = await prisma.note.findMany({
where: { userId, trashedAt: null, ...(notebookId ? { notebookId } : {}) },
select: {
id: true, title: true, content: true, notebookId: true, createdAt: true,
labelRelations: { select: { id: true } },
notebook: { select: { id: true, name: true } },
},
})
if (notes.length === 0) return NextResponse.json({ nodes: [], edges: [] })
const ids = notes.map(n => n.id)
// Pré-calcul
const keywordsMap = new Map<string, Set<string>>()
const labelMap = new Map<string, Set<string>>()
for (const note of notes) {
keywordsMap.set(note.id, extractKeywords(`${note.title ?? ''} ${note.content}`))
labelMap.set(note.id, new Set(note.labelRelations.map((l: any) => l.id)))
}
const edgeMap = new Map<string, GraphEdge>()
function upsertEdge(a: string, b: string, weight: number, type: EdgeType) {
const key = a < b ? `${a}--${b}` : `${b}--${a}`
const ex = edgeMap.get(key)
if (!ex || ex.weight < weight) edgeMap.set(key, { source: a < b ? a : b, target: a < b ? b : a, weight, type })
}
// ── Niveau 1 : Title Mention (comme Obsidian "unlinked mentions") ──────────
for (const noteA of notes) {
const title = (noteA.title ?? '').trim().toLowerCase()
if (title.length < 3) continue
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const re = new RegExp(`\\b${escaped}\\b`, 'i')
for (const noteB of notes) {
if (noteA.id === noteB.id) continue
if (re.test(stripHtml(noteB.content))) upsertEdge(noteA.id, noteB.id, 1.0, 'title_mention')
}
}
// ── Niveau 2 : Labels partagés ────────────────────────────────────────────
for (let i = 0; i < ids.length; i++) {
for (let j = i + 1; j < ids.length; j++) {
const la = labelMap.get(ids[i])!
const lb = labelMap.get(ids[j])!
const shared = [...la].filter(l => lb.has(l)).length
if (shared > 0) upsertEdge(ids[i], ids[j], Math.min(0.5 + shared * 0.15, 0.9), 'shared_label')
}
}
// ── Niveau 3 : Jaccard (désactivé > 500 notes) ───────────────────────────
if (notes.length <= 500) {
for (let i = 0; i < ids.length; i++) {
const kwI = keywordsMap.get(ids[i])!
const candidates: { j: number; score: number }[] = []
for (let j = i + 1; j < ids.length; j++) {
const score = jaccardSimilarity(kwI, keywordsMap.get(ids[j])!)
if (score >= 0.12) candidates.push({ j, score })
}
candidates.sort((a, b) => b.score - a.score).slice(0, 10)
.forEach(({ j, score }) => upsertEdge(ids[i], ids[j], score * 0.8, 'jaccard'))
}
}
const degreeMap = new Map<string, number>()
for (const e of edgeMap.values()) {
degreeMap.set(e.source, (degreeMap.get(e.source) ?? 0) + 1)
degreeMap.set(e.target, (degreeMap.get(e.target) ?? 0) + 1)
}
const nodes = notes.map(n => ({
id: n.id,
title: n.title || 'Sans titre',
notebookId: n.notebookId,
createdAt: n.createdAt,
degree: degreeMap.get(n.id) ?? 0,
}))
// Build clusters (notebooks)
const notebookMap = new Map<string, string>()
for (const n of notes) {
if (n.notebook) notebookMap.set(n.notebook.id, n.notebook.name)
}
const clusters = [...notebookMap.entries()].map(([id, name]) => ({ id, name }))
return NextResponse.json({ nodes, edges: Array.from(edgeMap.values()), clusters })
}

View File

@@ -0,0 +1,84 @@
import { NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
const WIKILINK_RE = /\[\[([^\]|#]+?)(?:[|#][^\]]+)?\]\]/g
function extractWikilinks(content: string): { title: string; snippet: string }[] {
const plain = content.replace(/<[^>]+>/g, ' ')
const results: { title: string; snippet: string }[] = []
const seen = new Set<string>()
let match: RegExpExecArray | null
WIKILINK_RE.lastIndex = 0
while ((match = WIKILINK_RE.exec(plain)) !== null) {
const title = match[1].trim()
if (!title || seen.has(title.toLowerCase())) continue
seen.add(title.toLowerCase())
const start = Math.max(0, match.index - 50)
const end = Math.min(plain.length, match.index + match[0].length + 50)
const snippet = plain.slice(start, end).replace(/\s+/g, ' ').trim()
results.push({ title, snippet })
}
return results
}
/**
* POST /api/graph/sync-all
* Batch-sync [[wikilinks]] for ALL notes of the authenticated user.
* Call once to populate the NoteLink table from existing notes.
*/
export async function POST() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
// Get all non-trashed notes with content
const notes = await prisma.note.findMany({
where: { userId, trashedAt: null, content: { not: '' } },
select: { id: true, content: true, notebookId: true },
})
let totalLinks = 0
for (const note of notes) {
if (!note.content.includes('[[')) continue
const wikilinks = extractWikilinks(note.content)
if (wikilinks.length === 0) continue
for (const { title, snippet } of wikilinks) {
let targetNote = await prisma.note.findFirst({
where: {
userId,
title: { equals: title, mode: 'insensitive' },
trashedAt: null,
},
select: { id: true },
})
if (!targetNote) {
// Skip stubs in batch sync — we only link existing notes
continue
}
if (targetNote.id === note.id) continue
try {
await (prisma as any).noteLink.upsert({
where: { sourceNoteId_targetNoteId: { sourceNoteId: note.id, targetNoteId: targetNote.id } },
update: { contextSnippet: snippet },
create: { sourceNoteId: note.id, targetNoteId: targetNote.id, contextSnippet: snippet },
})
totalLinks++
} catch {
// ignore duplicate constraint errors
}
}
}
return NextResponse.json({ synced: notes.length, links: totalLinks })
}

View File

@@ -1,6 +1,8 @@
import { NextRequest, NextResponse } from 'next/server'
import { Prisma } from '@prisma/client'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import { CreateLabelSchema } from '@/lib/validators'
const COLORS = ['red', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'pink', 'gray'];
@@ -12,11 +14,10 @@ export async function GET(request: NextRequest) {
}
try {
const searchParams = request.nextUrl.searchParams
const notebookId = searchParams.get('notebookId')
const notebookId = request.nextUrl.searchParams.get('notebookId')
// Build where clause
const where: any = {}
// userId is always required — prevents reading another user's labels by notebookId
const where: Prisma.LabelWhereInput = { userId: session.user.id }
if (notebookId === 'null' || notebookId === '') {
// Get labels without a notebook (backward compatibility)
@@ -24,9 +25,6 @@ export async function GET(request: NextRequest) {
} else if (notebookId) {
// Get labels for a specific notebook
where.notebookId = notebookId
} else {
// Get all labels for the user
where.userId = session.user.id
}
const labels = await prisma.label.findMany({
@@ -61,21 +59,14 @@ export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { name, color, notebookId } = body
if (!name || typeof name !== 'string') {
const parsed = CreateLabelSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ success: false, error: 'Label name is required' },
{ status: 400 }
)
}
if (!notebookId || typeof notebookId !== 'string') {
return NextResponse.json(
{ success: false, error: 'notebookId is required' },
{ success: false, error: 'Invalid request body', details: parsed.error.flatten() },
{ status: 400 }
)
}
const { name, color, notebookId } = parsed.data
// Verify notebook ownership
const notebook = await prisma.notebook.findUnique({

View File

@@ -1,7 +1,9 @@
import { NextRequest, NextResponse } from 'next/server'
import { Prisma } from '@prisma/client'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import { revalidatePath } from 'next/cache'
import { PatchNotebookSchema } from '@/lib/validators'
async function getDescendantIds(notebookId: string): Promise<string[]> {
const ids: string[] = []
@@ -28,7 +30,14 @@ export async function PATCH(
try {
const { id } = await params
const body = await request.json()
const { name, icon, color, order, trashedAt, parentId } = body
const parsed = PatchNotebookSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ success: false, error: 'Invalid request body', details: parsed.error.flatten() },
{ status: 400 }
)
}
const { name, icon, color, order, trashedAt, parentId } = parsed.data
const existing = await prisma.notebook.findUnique({
where: { id },
@@ -76,12 +85,12 @@ export async function PATCH(
}
}
const updateData: any = {}
if (name !== undefined) updateData.name = name.trim()
const updateData: Prisma.NotebookUpdateInput = {}
if (name !== undefined) updateData.name = name
if (icon !== undefined) updateData.icon = icon
if (color !== undefined) updateData.color = color
if (order !== undefined) updateData.order = order
if (trashedAt !== undefined) updateData.trashedAt = trashedAt
if (trashedAt !== undefined) updateData.trashedAt = trashedAt ? new Date(trashedAt) : null
if (parentId !== undefined) updateData.parentId = parentId
if (trashedAt !== undefined) {

View File

@@ -1,7 +1,9 @@
import { NextRequest, NextResponse } from 'next/server'
import { Prisma } from '@prisma/client'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import { revalidatePath } from 'next/cache'
import { CreateNotebookSchema } from '@/lib/validators'
const DEFAULT_COLORS = ['#3B82F6', '#8B5CF6', '#EC4899', '#F59E0B', '#10B981', '#06B6D4']
const DEFAULT_ICONS = ['📁', '📚', '💼', '🎯', '📊', '🎨', '💡', '🔧']
@@ -63,11 +65,14 @@ export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { name, icon, color, parentId } = body
if (!name || typeof name !== 'string') {
return NextResponse.json({ success: false, error: 'Notebook name is required' }, { status: 400 })
const parsed = CreateNotebookSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ success: false, error: 'Invalid request body', details: parsed.error.flatten() },
{ status: 400 }
)
}
const { name, icon, color, parentId } = parsed.data
if (parentId) {
const parent = await prisma.notebook.findFirst({
@@ -78,9 +83,7 @@ export async function POST(request: NextRequest) {
}
}
const whereClause: any = { userId: session.user.id }
if (parentId) whereClause.parentId = parentId
else whereClause.parentId = null
const whereClause: Prisma.NotebookWhereInput = { userId: session.user.id, parentId: parentId ?? null }
const highestOrder = await prisma.notebook.findFirst({
where: whereClause,

View File

@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
// GET /api/notes/[id]/backlinks
// Returns all notes that link TO this note via [[wikilinks]]
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
// Verify the note belongs to the user
const note = await prisma.note.findUnique({ where: { id }, select: { userId: true } })
if (!note || note.userId !== session.user.id) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
const backlinks = await (prisma as any).noteLink.findMany({
where: { targetNoteId: id },
include: {
sourceNote: {
select: { id: true, title: true, updatedAt: true, notebookId: true },
},
},
orderBy: { createdAt: 'desc' },
})
return NextResponse.json({
backlinks: backlinks.map((bl: any) => ({
id: bl.id,
sourceNote: bl.sourceNote,
contextSnippet: bl.contextSnippet,
createdAt: bl.createdAt,
})),
})
}

View File

@@ -167,6 +167,11 @@ export async function PUT(
console.error('[HISTORY] Failed to create snapshot from /api/notes/[id] PUT:', snapshotError)
}
// Fire-and-forget: sync [[wikilinks]] in background after content change
if ('content' in updateData) {
syncNoteLinksBackground(id, session.user.id, note.content).catch(() => {})
}
return NextResponse.json({
success: true,
data: parseNote(note)
@@ -180,6 +185,56 @@ export async function PUT(
}
}
/** Background job: parse [[wikilinks]] and sync NoteLink table */
async function syncNoteLinksBackground(noteId: string, userId: string, content: string) {
const WIKILINK_RE = /\[\[([^\]|#]+?)(?:[|#][^\]]+)?\]\]/g
const plain = content.replace(/<[^>]+>/g, ' ')
const wikilinks: { title: string; snippet: string }[] = []
const seen = new Set<string>()
let match: RegExpExecArray | null
WIKILINK_RE.lastIndex = 0
while ((match = WIKILINK_RE.exec(plain)) !== null) {
const title = match[1].trim()
if (!title || seen.has(title.toLowerCase())) continue
seen.add(title.toLowerCase())
const start = Math.max(0, match.index - 50)
const end = Math.min(plain.length, match.index + match[0].length + 50)
const snippet = plain.slice(start, end).replace(/\s+/g, ' ').trim()
wikilinks.push({ title, snippet })
}
const upsertedIds: string[] = []
for (const { title, snippet } of wikilinks) {
let targetNote = await prisma.note.findFirst({
where: { userId, title: { equals: title, mode: 'insensitive' }, trashedAt: null },
select: { id: true },
})
if (!targetNote) {
targetNote = await prisma.note.create({
data: { title, content: '', userId, type: 'richtext', color: 'default', isMarkdown: true, order: 0 },
select: { id: true },
})
}
if (targetNote.id === noteId) continue
await (prisma as any).noteLink.upsert({
where: { sourceNoteId_targetNoteId: { sourceNoteId: noteId, targetNoteId: targetNote.id } },
update: { contextSnippet: snippet.slice(0, 200) },
create: { id: crypto.randomUUID(), sourceNoteId: noteId, targetNoteId: targetNote.id, contextSnippet: snippet.slice(0, 200) },
})
upsertedIds.push(targetNote.id)
}
if (upsertedIds.length > 0) {
await (prisma as any).noteLink.deleteMany({
where: { sourceNoteId: noteId, targetNoteId: { notIn: upsertedIds } },
})
} else {
await (prisma as any).noteLink.deleteMany({ where: { sourceNoteId: noteId } })
}
}
// DELETE /api/notes/[id] - Delete a note
export async function DELETE(
request: NextRequest,

View File

@@ -0,0 +1,130 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
const WIKILINK_RE = /\[\[([^\]|#]+?)(?:[|#][^\]]+)?\]\]/g
/**
* Extract [[wikilink]] targets from markdown/html content.
* Returns deduplicated list of linked note titles.
*/
function extractWikilinks(content: string): { title: string; snippet: string }[] {
// Strip HTML tags
const plain = content.replace(/<[^>]+>/g, ' ')
const results: { title: string; snippet: string }[] = []
const seen = new Set<string>()
let match: RegExpExecArray | null
WIKILINK_RE.lastIndex = 0
while ((match = WIKILINK_RE.exec(plain)) !== null) {
const title = match[1].trim()
if (!title || seen.has(title.toLowerCase())) continue
seen.add(title.toLowerCase())
// Extract snippet: 50 chars before + after the match
const start = Math.max(0, match.index - 50)
const end = Math.min(plain.length, match.index + match[0].length + 50)
const snippet = plain.slice(start, end).replace(/\s+/g, ' ').trim()
results.push({ title, snippet })
}
return results
}
// POST /api/notes/[id]/sync-links
// Parse [[wikilinks]] in note content and sync the NoteLink table.
// Called automatically after note save.
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const userId = session.user.id
const note = await prisma.note.findUnique({
where: { id },
select: { id: true, userId: true, content: true },
})
if (!note || note.userId !== userId) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
const wikilinks = extractWikilinks(note.content)
// For each wikilink, find or create the target note
const upsertedLinks: string[] = []
for (const { title, snippet } of wikilinks) {
// Find target note by title (case-insensitive) belonging to same user
let targetNote = await prisma.note.findFirst({
where: {
userId,
title: { equals: title, mode: 'insensitive' },
trashedAt: null,
},
select: { id: true },
})
if (!targetNote) {
// Create a stub note so the link resolves
targetNote = await prisma.note.create({
data: {
title,
content: '',
userId,
type: 'richtext',
color: 'default',
isMarkdown: true,
order: 0,
},
select: { id: true },
})
}
// Skip self-links
if (targetNote.id === id) continue
// Upsert the NoteLink
await (prisma as any).noteLink.upsert({
where: {
sourceNoteId_targetNoteId: {
sourceNoteId: id,
targetNoteId: targetNote.id,
},
},
update: { contextSnippet: snippet.slice(0, 200) },
create: {
id: crypto.randomUUID(),
sourceNoteId: id,
targetNoteId: targetNote.id,
contextSnippet: snippet.slice(0, 200),
},
})
upsertedLinks.push(targetNote.id)
}
// Delete obsolete links (links that existed before but wikilink was removed)
if (upsertedLinks.length > 0) {
await (prisma as any).noteLink.deleteMany({
where: {
sourceNoteId: id,
targetNoteId: { notIn: upsertedLinks },
},
})
} else {
// No wikilinks at all — remove all outgoing links from this note
await (prisma as any).noteLink.deleteMany({
where: { sourceNoteId: id },
})
}
return NextResponse.json({ synced: upsertedLinks.length })
}

View File

@@ -1,7 +1,9 @@
import { NextRequest, NextResponse } from 'next/server'
import { Prisma } from '@prisma/client'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import { parseNote } from '@/lib/utils'
import { CreateNoteSchema, UpdateNoteSchema, GetNotesQuerySchema } from '@/lib/validators'
// GET /api/notes - Get all notes
export async function GET(request: NextRequest) {
@@ -14,13 +16,23 @@ export async function GET(request: NextRequest) {
}
try {
const searchParams = request.nextUrl.searchParams
const includeArchived = searchParams.get('archived') === 'true'
const search = searchParams.get('search')
const notebookId = searchParams.get('notebookId')
const limit = searchParams.get('limit') ? parseInt(searchParams.get('limit')!) : undefined
const rawQuery = {
archived: request.nextUrl.searchParams.get('archived') ?? undefined,
search: request.nextUrl.searchParams.get('search') ?? undefined,
notebookId: request.nextUrl.searchParams.get('notebookId') ?? undefined,
limit: request.nextUrl.searchParams.get('limit') ?? undefined,
}
const queryResult = GetNotesQuerySchema.safeParse(rawQuery)
if (!queryResult.success) {
return NextResponse.json(
{ success: false, error: 'Invalid query parameters', details: queryResult.error.flatten() },
{ status: 400 }
)
}
const { archived, search, notebookId, limit } = queryResult.data
const includeArchived = archived === 'true'
const where: any = {
const where: Prisma.NoteWhereInput = {
userId: session.user.id,
trashedAt: null
}
@@ -75,25 +87,25 @@ export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { title, content, color, type, checkItems, labels, images } = body
if (!content && type !== 'checklist') {
const parsed = CreateNoteSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ success: false, error: 'Content is required' },
{ success: false, error: 'Invalid request body', details: parsed.error.flatten() },
{ status: 400 }
)
}
const { title, content, color, type, checkItems, labels, images } = parsed.data
const note = await prisma.note.create({
data: {
userId: session.user.id,
title: title || null,
content: content || '',
color: color || 'default',
type: type || 'text',
checkItems: checkItems ?? null,
labels: labels ?? null,
images: images ?? null,
title: title ?? null,
content: content ?? '',
color: color ?? 'default',
type: type ?? 'text',
checkItems: checkItems != null ? JSON.stringify(checkItems) : null,
labels: labels != null ? JSON.stringify(labels) : null,
images: images != null ? JSON.stringify(images) : null,
}
})
@@ -122,34 +134,16 @@ export async function PUT(request: NextRequest) {
try {
const body = await request.json()
const { id, title, content, color, type, checkItems, labels, isPinned, isArchived, images } = body
if (!id) {
const parsed = UpdateNoteSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ success: false, error: 'Note ID is required' },
{ success: false, error: 'Invalid request body', details: parsed.error.flatten() },
{ status: 400 }
)
}
const { id, title, content, color, type, checkItems, labels, isPinned, isArchived, images } = parsed.data
const existingNote = await prisma.note.findUnique({
where: { id }
})
if (!existingNote) {
return NextResponse.json(
{ success: false, error: 'Note not found' },
{ status: 404 }
)
}
if (existingNote.userId !== session.user.id) {
return NextResponse.json(
{ success: false, error: 'Forbidden' },
{ status: 403 }
)
}
const updateData: any = {}
const updateData: Prisma.NoteUpdateInput = {}
if (title !== undefined) updateData.title = title
if (content !== undefined) updateData.content = content
@@ -161,10 +155,21 @@ export async function PUT(request: NextRequest) {
if (isArchived !== undefined) updateData.isArchived = isArchived
if (images !== undefined) updateData.images = images ?? null
const note = await prisma.note.update({
where: { id },
data: updateData
})
let note
try {
note = await prisma.note.update({
where: { id, userId: session.user.id },
data: updateData
})
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
return NextResponse.json(
{ success: false, error: 'Note not found or access denied' },
{ status: 404 }
)
}
throw e
}
return NextResponse.json({
success: true,
@@ -200,29 +205,21 @@ export async function DELETE(request: NextRequest) {
)
}
const existingNote = await prisma.note.findUnique({
where: { id }
})
if (!existingNote) {
return NextResponse.json(
{ success: false, error: 'Note not found' },
{ status: 404 }
)
try {
await prisma.note.update({
where: { id, userId: session.user.id },
data: { trashedAt: new Date() }
})
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
return NextResponse.json(
{ success: false, error: 'Note not found or access denied' },
{ status: 404 }
)
}
throw e
}
if (existingNote.userId !== session.user.id) {
return NextResponse.json(
{ success: false, error: 'Forbidden' },
{ status: 403 }
)
}
await prisma.note.update({
where: { id },
data: { trashedAt: new Date() }
})
return NextResponse.json({
success: true,
message: 'Note moved to trash'