import { NextRequest, NextResponse } from 'next/server' import { auth } from '@/auth' import { prisma } from '@/lib/prisma' import { QuotaExceededError } from '@/lib/entitlements' import { reserveAiUsageOrThrow } from '@/lib/ai-quota' import { slideGenerateCreditCost, resolveCreditCost } from '@/lib/credits' import type { FeatureName } from '@/lib/quota-utils' import { logAuditEvent, getClientIp } from '@/lib/audit-log' import { encodeSlideIntentDescription, normalizeSlideIntent, type SlideAudience, type SlidePurpose, } from '@/lib/ai/services/slide-intent' type GenerateType = 'slide-generator' | 'excalidraw-generator' const TYPE_DEFAULTS: Record = { 'slide-generator': { // tools unused: slide-generator runs a structured-output pipeline (no function calling) role: 'Génère un deck exécutif de haute qualité (titres d\'action, une idée par slide, pas de filler, graphiques uniquement avec vrais chiffres).', tools: [], maxSteps: 1, }, 'excalidraw-generator': { role: 'Génère un diagramme Excalidraw clair et professionnel à partir du contenu de la note fournie.', tools: ['note_search', 'note_read', 'generate_excalidraw'], maxSteps: 6, }, } // ─── POST : kick off generation (fire-and-forget) ────────────────────────── export async function POST(req: NextRequest) { const session = await auth() if (!session?.user?.id) { return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }) } const userId = session.user.id const body = await req.json() const { noteId, type, theme, style, language, template, purpose, audience, slideCount } = body as { noteId: string type: GenerateType theme?: string style?: string language?: string template?: string purpose?: SlidePurpose audience?: SlideAudience slideCount?: number } if (!noteId || !type || !TYPE_DEFAULTS[type]) { return NextResponse.json({ error: 'Paramètres invalides' }, { status: 400 }) } // Crédits globaux (respecte le BYOK) — slides = 1 + N const featureKey = (type === 'slide-generator' ? 'slide_generate' : 'excalidraw_generate') as FeatureName const creditCost = type === 'slide-generator' ? slideGenerateCreditCost(slideCount) : resolveCreditCost(featureKey) try { await reserveAiUsageOrThrow(userId, featureKey, { amount: creditCost, slideCount: type === 'slide-generator' ? slideCount : undefined, metadata: { noteId, type, slideCount: slideCount ?? null }, lane: 'chat', }) } catch (e) { if (e instanceof QuotaExceededError) { return NextResponse.json( { error: e.message, code: 'QUOTA_EXCEEDED', feature: featureKey, creditCost, }, { status: 402 }, ) } throw e } const note = await prisma.note.findFirst({ where: { id: noteId, userId }, select: { id: true, title: true, notebookId: true }, }) if (!note) { return NextResponse.json({ error: 'Note introuvable' }, { status: 404 }) } const defaults = TYPE_DEFAULTS[type] const isEn = language === 'English' let role = defaults.role if (isEn) { if (type === 'slide-generator') { const recipeHint = (theme && theme !== 'auto') ? ` Use theme:"${theme}".` : '' role = `Generate a high-quality executive deck (action titles, one idea per slide, no filler, charts only with real numbers).${recipeHint}` } 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 un deck exécutif de haute qualité (titres d'action, une idée par slide, pas de filler, graphiques uniquement avec vrais chiffres).${recipeHint}` } const agentName = type === 'slide-generator' ? `${isEn ? 'Slides' : 'Présentation'} — ${(note.title || 'Note').substring(0, 40)}` : `${isEn ? 'Diagram' : 'Diagramme'} — ${(note.title || 'Note').substring(0, 40)}` const slideIntent = type === 'slide-generator' ? normalizeSlideIntent({ purpose, audience, slideCount, template: template || 'auto', }) : null // Map purpose → legacy template when user picks intent without template if (slideIntent && (!slideIntent.template || slideIntent.template === 'auto')) { const purposeToTemplate: Record = { board: 'board-update', project: 'project-status', strategy: 'strategy-review', } if (slideIntent.purpose && purposeToTemplate[slideIntent.purpose]) { slideIntent.template = purposeToTemplate[slideIntent.purpose] } } const agent = await prisma.agent.create({ data: { name: agentName, type, role, description: type === 'slide-generator' && slideIntent ? encodeSlideIntentDescription(slideIntent) : undefined, tools: JSON.stringify(defaults.tools), maxSteps: defaults.maxSteps, frequency: 'one-shot', isEnabled: true, sourceNoteIds: JSON.stringify([noteId]), targetNotebookId: note.notebookId ?? undefined, slideTheme: theme ?? 'keynote', slideStyle: style ?? 'soft', userId, }, }) // ── Fire and forget — do NOT await so the HTTP response returns immediately ── // In Node.js / Docker self-hosted, the process keeps running after response. // skipQuota: units already reserved above (avoids double-charge in executeAgent). import('@/lib/ai/services/agent-executor.service') .then(({ executeAgent }) => executeAgent(agent.id, userId, undefined, { skipQuota: true })) .catch(err => console.error('[run-for-note] Background agent error:', err)) logAuditEvent({ userId, action: 'AI_REQUEST', resource: featureKey, metadata: { agentId: agent.id, noteId, featureKey }, ip: getClientIp(req), }) return NextResponse.json({ success: true, agentId: agent.id, status: 'running' }) } // ─── GET : poll current agent status ────────────────────────────────────── export async function GET(req: NextRequest) { const session = await auth() if (!session?.user?.id) { return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }) } const userId = session.user.id const agentId = req.nextUrl.searchParams.get('agentId') if (!agentId) { return NextResponse.json({ error: 'agentId manquant' }, { status: 400 }) } // Verify ownership const agent = await prisma.agent.findFirst({ where: { id: agentId, userId }, select: { id: true }, }) if (!agent) { return NextResponse.json({ error: 'Agent introuvable' }, { status: 404 }) } // Return latest action for this agent const action = await prisma.agentAction.findFirst({ where: { agentId }, orderBy: { createdAt: 'desc' }, select: { id: true, status: true, result: true, log: true, createdAt: true }, }) if (!action) { // Action not created yet (race condition in first poll) — still running 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 if (action.status === 'success' && action.result) { // result field: the executor stores canvas.id or note.id const canvas = await prisma.canvas.findFirst({ where: { id: action.result }, select: { id: true }, }) if (canvas) { canvasId = canvas.id } else { noteId = action.result } } return NextResponse.json({ status: action.status, // 'running' | 'success' | 'failure' actionId: action.id, canvasId, noteId, error: action.status === 'failure' ? (action.log || 'Erreur inconnue') : undefined, }) }