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

@@ -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'