Ajoute le pipeline PageSpec (validation, rendu, publication /p/{slug}),
les démos TipTap /demo, et le simulateur Carnot (modes frigo/PAC/moteur,
énergie kJ vs puissance W, unités K/°C/°F) avec correctifs d’équations KaTeX.
Co-authored-by: Cursor <cursoragent@cursor.com>
182 lines
5.7 KiB
TypeScript
182 lines
5.7 KiB
TypeScript
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(['svg-scene', 'chart', 'heatmap-matrix', 'simulation', 'none']),
|
|
demoGoal: z.string().optional(),
|
|
})
|
|
|
|
const requestSchema = z.object({
|
|
/** undefined = legacy deterministic full-page (fallback path) */
|
|
action: z.enum(['plan', 'section']).optional(),
|
|
content: z.string().min(40),
|
|
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()
|
|
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: page = 20 crédits, spec §8.5) ──────────────────
|
|
if (parsed.action === 'plan') {
|
|
try {
|
|
await reserveAiUsageOrThrow(session.user.id, 'interactive_page', {
|
|
lane: 'chat',
|
|
})
|
|
} 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 (already billed at plan time) ────────────────
|
|
if (parsed.action === 'section') {
|
|
if (!parsed.section || !parsed.sectionId || !parsed.pageTitle) {
|
|
return NextResponse.json(
|
|
{ error: 'section, sectionId and pageTitle are required' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
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: error.issues }, { 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 })
|
|
}
|
|
}
|