import { NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { auth } from '@/auth' import { getSystemConfig } from '@/lib/config' import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent' import { getSlidesProvider } from '@/lib/ai/factory' import { generateInteractivePageFromContent } from '@/lib/ai/services/interactive-page-generate.service' import { generatePagePlan, generatePageSection, } from '@/lib/ai/services/interactive-page-llm.service' import { reserveAiUsageOrThrow } from '@/lib/ai-quota' import { QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements' export const maxDuration = 60 const sectionPlanSchema = z.object({ title: z.string().min(1), goal: z.string().min(1), demoKind: z.enum(['steps', 'svg-scene', 'chart', 'heatmap-matrix', 'simulation', 'none']), demoGoal: z.string().nullish(), }) const requestSchema = z.object({ /** undefined = legacy deterministic full-page (fallback path) */ action: z.enum(['plan', 'section']).optional(), content: z.string().min(40).max(500_000), lang: z.string().optional(), noteId: z.string().optional(), notebookId: z.string().optional(), pageTitle: z.string().optional(), sectionId: z.string().optional(), section: sectionPlanSchema.optional(), }) export async function POST(req: NextRequest) { try { const session = await auth() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } if (!(await hasUserAiConsent())) { return aiConsentForbiddenResponse() } const body = await req.json().catch(() => null) if (!body || typeof body !== 'object') { return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) } const parsed = requestSchema.parse(body) const wordCount = parsed.content .replace(/<[^>]+>/g, ' ') .split(/\s+/) .filter(Boolean).length if (wordCount < 40) { return NextResponse.json( { error: 'Contenu trop court pour une page interactive (~40 mots min.)' }, { status: 400 } ) } const config = await getSystemConfig() const provider = getSlidesProvider(config) const lang = parsed.lang || 'fr' // ── LLM plan (billed 5/20 crédits — le reste est facturé par section) ── if (parsed.action === 'plan') { try { await reserveAiUsageOrThrow(session.user.id, 'interactive_page', { lane: 'chat', amount: 5, }) } catch (err) { if (err instanceof QuotaExceededError) { return NextResponse.json(err.toJSON(), { status: 402 }) } if ( err instanceof QuotaServiceUnavailableError || process.env.NODE_ENV === 'production' ) { return NextResponse.json( { error: 'QUOTA_SERVICE_UNAVAILABLE' }, { status: 503 } ) } console.error('[/api/ai/interactive-page] Quota check error (fail-open):', err) } const result = await generatePagePlan({ content: parsed.content, lang, provider, }) if (!result.ok) { return NextResponse.json( { error: result.error, reason: result.reason, attempts: result.attempts }, { status: result.error === 'unsuitable_content' ? 422 : 502 } ) } return NextResponse.json({ plan: result.plan, attempts: result.attempts }) } // ── LLM single section (billed 3/20 crédits per section — no free LLM) ── if (parsed.action === 'section') { if (!parsed.section || !parsed.sectionId || !parsed.pageTitle) { return NextResponse.json( { error: 'section, sectionId and pageTitle are required' }, { status: 400 } ) } try { await reserveAiUsageOrThrow(session.user.id, 'interactive_page', { lane: 'chat', amount: 3, }) } catch (err) { if (err instanceof QuotaExceededError) { return NextResponse.json(err.toJSON(), { status: 402 }) } if ( err instanceof QuotaServiceUnavailableError || process.env.NODE_ENV === 'production' ) { return NextResponse.json( { error: 'QUOTA_SERVICE_UNAVAILABLE' }, { status: 503 } ) } console.error('[/api/ai/interactive-page] Quota check error (fail-open):', err) } const result = await generatePageSection({ content: parsed.content, lang, provider, pageTitle: parsed.pageTitle, sectionId: parsed.sectionId, section: parsed.section, }) if (!result.ok) { return NextResponse.json( { error: 'section_generation_failed', issues: result.issues?.slice(0, 12), attempts: result.attempts, }, { status: 422 } ) } return NextResponse.json({ section: result.section, attempts: result.attempts }) } // ── Legacy deterministic full page (fallback, no LLM → no quota) ───── const result = await generateInteractivePageFromContent({ content: parsed.content, lang, provider, }) if (!result.ok) { if (result.error === 'unsuitable_content') { return NextResponse.json( { error: 'unsuitable_content', reason: result.reason, attempts: result.attempts, }, { status: 422 } ) } const first = result.issues?.[0] const reason = result.reason || (first ? `${first.path ? first.path + ': ' : ''}${first.message}` : undefined) return NextResponse.json( { error: result.error === 'timeout' ? 'La génération a pris trop de temps — réessayez' : reason || 'La génération a produit une page invalide', reason, issues: result.issues?.slice(0, 12), attempts: result.attempts, }, { status: 422 } ) } return NextResponse.json({ page: result.page, attempts: result.attempts, }) } catch (error: unknown) { if (error instanceof z.ZodError) { return NextResponse.json( { error: 'Requête invalide', issues: error.issues.slice(0, 10).map((i) => ({ path: i.path.join('.'), message: i.message, })), }, { status: 400 } ) } const message = error instanceof Error ? error.message : 'Erreur génération interactive page' console.error('[/api/ai/interactive-page]', error) return NextResponse.json({ error: message }, { status: 500 }) } }