All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 5s
- Fix useBrainstormSocket: stable guestId via useRef, remove setState in cleanup - Fix GhostCursor: direct DOM manipulation via refs, no useState re-renders - Fix all SQL embedding queries: add ::vector cast on text columns - Fix embedding truncation to 15000 chars (under 8192 token limit) - Fix NoteEmbedding INSERT: remove non-existent updatedAt column - Fix billing page: show all quota stats in grid instead of single metric - Fix usage meter: accordion expand/collapse, per-feature detail - Fix semantic search: rebuild 103 note embeddings, ::vector cast on vectorSearch - Fix brainstorm expand/manual-idea/create: ::vector cast on embedding SQL
281 lines
9.5 KiB
TypeScript
281 lines
9.5 KiB
TypeScript
'use server'
|
|
|
|
import { auth } from '@/auth'
|
|
import prisma from '@/lib/prisma'
|
|
import { getChatProvider } 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 using the chat provider (not embeddings provider)
|
|
const config = await getSystemConfig()
|
|
let provider
|
|
try {
|
|
provider = getChatProvider(config)
|
|
} catch (providerErr) {
|
|
console.error('[organize-notebook] Failed to get AI provider:', providerErr)
|
|
return { success: false, error: `Fournisseur IA non configuré : ${(providerErr as Error).message}` }
|
|
}
|
|
|
|
let rawResponse: string
|
|
try {
|
|
rawResponse = await provider.generateText(prompt)
|
|
} catch (aiErr) {
|
|
console.error('[organize-notebook] AI generateText failed:', aiErr)
|
|
return { success: false, error: `L'IA n'a pas pu répondre : ${(aiErr as Error).message}` }
|
|
}
|
|
|
|
// 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 (always as children of plan.notebookId)
|
|
* - 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' }
|
|
|
|
console.log('[organize-notebook] Executing plan:', JSON.stringify({
|
|
notebookId: plan.notebookId,
|
|
groups: plan.groups.map(g => ({ name: g.name, isNew: g.isNew, existingId: g.existingId, noteCount: g.notes.length }))
|
|
}))
|
|
|
|
let created = 0
|
|
let moved = 0
|
|
|
|
for (const group of plan.groups) {
|
|
let targetNotebookId: string
|
|
|
|
// Validate existingId: must be a real sub-notebook of plan.notebookId (not the parent itself)
|
|
const validExistingId = group.existingId && group.existingId !== plan.notebookId
|
|
? group.existingId
|
|
: undefined
|
|
|
|
if (!group.isNew && validExistingId) {
|
|
// Verify it's actually a child of plan.notebookId
|
|
const verifiedSub = await prisma.notebook.findFirst({
|
|
where: { id: validExistingId, parentId: plan.notebookId, userId: session.user.id, trashedAt: null },
|
|
select: { id: true },
|
|
})
|
|
if (verifiedSub) {
|
|
targetNotebookId = verifiedSub.id
|
|
} else {
|
|
// Fall through to create a new sub-notebook
|
|
group.isNew = true
|
|
}
|
|
}
|
|
|
|
if (group.isNew || !targetNotebookId!) {
|
|
// Create new sub-notebook — always as a child of plan.notebookId
|
|
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: '#A47148',
|
|
order: nextOrder,
|
|
parentId: plan.notebookId, // always a sub-notebook
|
|
userId: session.user.id,
|
|
},
|
|
})
|
|
console.log(`[organize-notebook] Created sub-notebook "${newSub.name}" (id: ${newSub.id}) under ${plan.notebookId}`)
|
|
targetNotebookId = newSub.id
|
|
created++
|
|
}
|
|
|
|
// Move notes — update notebookId to the target sub-notebook
|
|
const noteIds = group.notes.map(n => n.id)
|
|
if (noteIds.length > 0) {
|
|
const result = await prisma.note.updateMany({
|
|
where: {
|
|
id: { in: noteIds },
|
|
userId: session.user.id,
|
|
},
|
|
data: { notebookId: targetNotebookId },
|
|
})
|
|
console.log(`[organize-notebook] Moved ${result.count} notes to "${group.name}"`)
|
|
moved += result.count
|
|
}
|
|
}
|
|
|
|
revalidatePath('/home')
|
|
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.' }
|
|
}
|
|
}
|