feat: add AI-powered notebook organization with preview dialog
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m24s

This commit is contained in:
Antigravity
2026-05-10 18:52:05 +00:00
parent 330c0c61b6
commit 6123dcfba4
3 changed files with 713 additions and 0 deletions

View File

@@ -0,0 +1,244 @@
'use server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { getAIProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import { revalidatePath } from 'next/cache'
export interface OrganizationNote {
id: string
title: string
}
export interface OrganizationGroup {
name: string
isNew: boolean
existingId?: string
notes: OrganizationNote[]
}
export interface OrganizationPlan {
notebookId: string
groups: OrganizationGroup[]
}
export interface AnalyzeResult {
success: boolean
plan?: OrganizationPlan
error?: string
}
export interface ExecuteResult {
success: boolean
created: number
moved: number
error?: string
}
/**
* Analyze a notebook's notes with AI and suggest a sub-notebook organization plan.
*/
export async function analyzeNotebookForOrganization(notebookId: string): Promise<AnalyzeResult> {
const session = await auth()
if (!session?.user?.id) return { success: false, error: 'Non autorisé' }
try {
// 1. Fetch the target notebook (ensure ownership)
const notebook = await prisma.notebook.findFirst({
where: { id: notebookId, userId: session.user.id, trashedAt: null },
select: { id: true, name: true },
})
if (!notebook) return { success: false, error: 'Carnet introuvable' }
// 2. Fetch all notes in this notebook
const notes = await prisma.note.findMany({
where: {
notebookId,
userId: session.user.id,
trashedAt: null,
isArchived: false,
},
select: { id: true, title: true, content: true, labels: true },
orderBy: { createdAt: 'asc' },
})
if (notes.length < 2) {
return {
success: false,
error: 'Ce carnet contient moins de 2 notes — il n\'y a rien à organiser.',
}
}
// 3. Fetch existing sub-notebooks
const existingSubs = await prisma.notebook.findMany({
where: { parentId: notebookId, userId: session.user.id, trashedAt: null },
select: { id: true, name: true },
})
// 4. Build prompt context — truncate content to save tokens
const notesContext = notes.map((n, i) => {
const title = n.title || `Note sans titre ${i + 1}`
const content = (n.content || '').slice(0, 300).replace(/\n+/g, ' ')
let labelsStr = ''
if (n.labels) {
try {
const parsed = JSON.parse(n.labels as string)
if (Array.isArray(parsed)) labelsStr = parsed.join(', ')
} catch {}
}
return `[${n.id}] "${title}"${labelsStr ? ` (tags: ${labelsStr})` : ''}${content}`
}).join('\n')
const existingSubsContext = existingSubs.length > 0
? `\nSous-carnets existants:\n${existingSubs.map(s => `- "${s.name}" (id: ${s.id})`).join('\n')}`
: '\nIl n\'y a pas encore de sous-carnets.'
const prompt = `Tu es un assistant d'organisation de notes. Analyse les notes suivantes du carnet "${notebook.name}" et regroupe-les par thème en proposant des sous-carnets.
${existingSubsContext}
Notes à organiser:
${notesContext}
RÈGLES IMPORTANTES:
- Regroupe les notes par thème ou sujet similaire.
- Propose entre 2 et 6 groupes maximum.
- Si un sous-carnet existant correspond déjà à un thème, utilise-le (indique son id).
- Les noms de groupe doivent être courts (2-4 mots), clairs et en français.
- N'inclus PAS toutes les notes si certaines sont trop générales ou ne correspondent à aucun groupe clair — laisse-les de côté.
- Réponds UNIQUEMENT avec du JSON valide, sans markdown, sans explication.
Format de réponse JSON attendu:
{
"groups": [
{
"name": "Nom du sous-carnet",
"existingId": "id-si-sous-carnet-existant-ou-null",
"noteIds": ["id1", "id2", "id3"]
}
]
}`
// 5. Call AI
const config = await getSystemConfig()
const provider = getAIProvider(config)
const rawResponse = await provider.generateText(prompt)
// 6. Parse AI response
let parsed: { groups: Array<{ name: string; existingId?: string | null; noteIds: string[] }> }
try {
// Clean possible markdown code blocks
const cleaned = rawResponse.trim().replace(/^```json\n?/, '').replace(/\n?```$/, '')
parsed = JSON.parse(cleaned)
} catch {
return { success: false, error: 'L\'IA n\'a pas pu générer un plan valide. Réessayez.' }
}
if (!parsed?.groups || !Array.isArray(parsed.groups)) {
return { success: false, error: 'Réponse IA invalide.' }
}
// 7. Build the OrganizationPlan
const noteMap = new Map(notes.map(n => [n.id, n]))
const existingSubMap = new Map(existingSubs.map(s => [s.id, s]))
const groups: OrganizationGroup[] = parsed.groups
.filter(g => g.name && Array.isArray(g.noteIds) && g.noteIds.length > 0)
.map(g => {
const existingId = g.existingId && existingSubMap.has(g.existingId) ? g.existingId : undefined
const groupNotes = g.noteIds
.filter(id => noteMap.has(id))
.map(id => {
const n = noteMap.get(id)!
return { id: n.id, title: n.title || 'Note sans titre' }
})
return {
name: g.name,
isNew: !existingId,
existingId,
notes: groupNotes,
}
})
.filter(g => g.notes.length > 0)
if (groups.length === 0) {
return { success: false, error: 'L\'IA n\'a pas trouvé de groupes thématiques distincts.' }
}
return { success: true, plan: { notebookId, groups } }
} catch (err) {
console.error('[organize-notebook] Analysis error:', err)
return { success: false, error: 'Une erreur s\'est produite lors de l\'analyse.' }
}
}
/**
* Execute an approved organization plan:
* - Creates missing sub-notebooks
* - Moves notes to the correct sub-notebook
*/
export async function executeNotebookOrganization(plan: OrganizationPlan): Promise<ExecuteResult> {
const session = await auth()
if (!session?.user?.id) return { success: false, created: 0, moved: 0, error: 'Non autorisé' }
try {
// Verify notebook ownership
const notebook = await prisma.notebook.findFirst({
where: { id: plan.notebookId, userId: session.user.id, trashedAt: null },
select: { id: true },
})
if (!notebook) return { success: false, created: 0, moved: 0, error: 'Carnet introuvable' }
let created = 0
let moved = 0
for (const group of plan.groups) {
let targetNotebookId: string
if (!group.isNew && group.existingId) {
// Use existing sub-notebook
targetNotebookId = group.existingId
} else {
// Create new sub-notebook
const highestOrder = await prisma.notebook.findFirst({
where: { parentId: plan.notebookId, userId: session.user.id },
orderBy: { order: 'desc' },
select: { order: true },
})
const nextOrder = (highestOrder?.order ?? -1) + 1
const newSub = await prisma.notebook.create({
data: {
name: group.name.trim(),
icon: '📁',
color: '#75B2D6',
order: nextOrder,
parentId: plan.notebookId,
userId: session.user.id,
},
})
targetNotebookId = newSub.id
created++
}
// Move notes
const noteIds = group.notes.map(n => n.id)
if (noteIds.length > 0) {
await prisma.note.updateMany({
where: {
id: { in: noteIds },
userId: session.user.id,
},
data: { notebookId: targetNotebookId },
})
moved += noteIds.length
}
}
revalidatePath('/')
return { success: true, created, moved }
} catch (err) {
console.error('[organize-notebook] Execute error:', err)
return { success: false, created: 0, moved: 0, error: 'Erreur lors de l\'exécution du plan.' }
}
}