feat: page interactive, démos Play/Step et simulateur Carnot

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>
This commit is contained in:
Antigravity
2026-07-24 17:51:43 +00:00
parent f385d43d5d
commit 69c99e4f4f
67 changed files with 12005 additions and 33 deletions

View File

@@ -5,8 +5,13 @@ import { contentModerationService, type ModerationResult } from '@/lib/ai/servic
import { publishEnhanceService } from '@/lib/ai/services/publish-enhance.service'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { isPublishTemplateId } from '@/lib/publish/types'
import { isPublishTemplateId, isInteractivePageTemplate } from '@/lib/publish/types'
import { computePublishedSourceHash, renderPublishedTemplate, renderRewrittenTemplate } from '@/lib/publish/template-render'
import { validateInteractivePage } from '@/lib/interactive-page'
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
import { getSystemConfig } from '@/lib/config'
import { getSlidesProvider } from '@/lib/ai/factory'
import { generateInteractivePageFromContent } from '@/lib/ai/services/interactive-page-generate.service'
const MODERATION_TIMEOUT_MS = 12_000
@@ -93,13 +98,14 @@ export async function POST(request: NextRequest) {
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const body = await request.json()
const { noteId, action, mode, template, language, rewrite } = body as {
const { noteId, action, mode, template, language, rewrite, pageSpec } = body as {
noteId?: string
action?: string
mode?: 'simple' | 'ai'
mode?: 'simple' | 'ai' | 'interactive-page'
template?: string
language?: string
rewrite?: boolean
pageSpec?: unknown
}
if (!noteId) return NextResponse.json({ error: 'noteId required' }, { status: 400 })
@@ -111,13 +117,96 @@ export async function POST(request: NextRequest) {
if (!note) return NextResponse.json({ error: 'Not found' }, { status: 404 })
if (action === 'publish') {
// ── Interactive page (PageSpecV1 JSON snapshot) ─────────────────────
if (mode === 'interactive-page' || isInteractivePageTemplate(template)) {
if (!(await hasUserAiConsent())) {
return aiConsentForbiddenResponse()
}
let validatedPage = pageSpec ? validateInteractivePage(pageSpec) : null
if (!validatedPage?.ok) {
// Deterministic generate (no LLM quota)
const config = await getSystemConfig()
const provider = getSlidesProvider(config)
const generated = await generateInteractivePageFromContent({
content: note.content || '',
lang: language || 'fr',
provider,
})
if (!generated.ok) {
return NextResponse.json(
{
error: generated.error || 'interactive_page_generation_failed',
reason: generated.reason,
issues: generated.issues?.slice(0, 12),
},
{ status: 422 }
)
}
validatedPage = { ok: true, page: generated.page }
}
// Guaranteed valid PageSpec after generate-or-validate above
if (!validatedPage?.ok) {
return NextResponse.json({ error: 'invalid_page_spec' }, { status: 422 })
}
const textForModeration = [
validatedPage.page.hero.title,
validatedPage.page.hero.subtitle,
validatedPage.page.overview?.lead,
...validatedPage.page.sections.map((s) => s.title),
]
.filter(Boolean)
.join('\n')
const moderation = await moderateWithFallback(
note.title || '',
textForModeration
)
if (moderation.verdict === 'blocked') {
return NextResponse.json(
{
error: 'blocked',
reason: moderation.reason,
categories: moderation.categories,
},
{ status: 403 }
)
}
if (moderation.verdict === 'flagged') {
await notifyFlaggedAdmins(note.id, note.title || '', moderation.reason)
}
const slug = await ensureSlug(note.id, note.title || '', note.publicSlug)
const sourceHash = computePublishedSourceHash(note.content || '')
await updateNotePublishState(noteId, {
isPublic: true,
publicSlug: slug,
publishedAt: new Date(),
publishedContent: JSON.stringify(validatedPage.page),
publishedTemplate: 'interactive-page',
publishedSourceHash: sourceHash,
})
return NextResponse.json({
success: true,
slug,
mode: 'interactive-page',
template: 'interactive-page',
moderation: moderation.verdict === 'flagged' ? 'flagged' : undefined,
})
}
const publishMode = mode === 'ai' ? 'ai' : 'simple'
if (publishMode === 'ai') {
if (!(await hasUserAiConsent())) {
return aiConsentForbiddenResponse()
}
if (!template || !isPublishTemplateId(template)) {
if (!template || !isPublishTemplateId(template) || isInteractivePageTemplate(template)) {
return NextResponse.json({ error: 'Invalid template' }, { status: 400 })
}