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:
125
memento-note/app/api/ai/interactive-demo/route.ts
Normal file
125
memento-note/app/api/ai/interactive-demo/route.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { auth } from '@/auth'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
|
||||
import { QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
|
||||
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||
import { getSlidesProvider } from '@/lib/ai/factory'
|
||||
import { generateInteractiveDemoFromContent } from '@/lib/ai/services/interactive-demo-generate.service'
|
||||
|
||||
export const maxDuration = 180
|
||||
|
||||
const requestSchema = z.object({
|
||||
content: z.string().min(20),
|
||||
selection: z.string().optional().nullable(),
|
||||
lang: z.string().optional(),
|
||||
noteId: z.string().optional(),
|
||||
})
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
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)
|
||||
// Keep raw HTML/markdown so formula extractors see $...$ / KaTeX like slides
|
||||
const sourceRaw = parsed.selection?.trim() || parsed.content
|
||||
const sourcePlain = stripHtml(sourceRaw)
|
||||
const wordCount = sourcePlain.split(/\s+/).filter(Boolean).length
|
||||
if (wordCount < 20) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Sélectionne au moins ~20 mots de contenu pour générer une démo interactive',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await reserveAiUsageOrThrow(session.user.id, 'interactive_demo', {
|
||||
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-demo] Quota check error (fail-open):', err)
|
||||
}
|
||||
|
||||
const config = await getSystemConfig()
|
||||
const lang = parsed.lang || 'fr'
|
||||
// Same admin model as slide decks (AI_PROVIDER_SLIDES / AI_MODEL_SLIDES → chat fallback)
|
||||
const provider = getSlidesProvider(config)
|
||||
|
||||
const result = await generateInteractiveDemoFromContent({
|
||||
content: sourceRaw,
|
||||
lang,
|
||||
provider,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
const first = result.issues[0]
|
||||
const detail = first
|
||||
? `${first.path ? first.path + ': ' : ''}${first.message}`
|
||||
: ''
|
||||
console.error(
|
||||
'[/api/ai/interactive-demo] validation failed after repairs',
|
||||
result.issues.slice(0, 10)
|
||||
)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: detail
|
||||
? `Démo invalide après correction — ${detail}`
|
||||
: 'La génération a produit un JSON invalide après correction',
|
||||
issues: result.issues.slice(0, 10),
|
||||
attempts: result.attempts,
|
||||
},
|
||||
{ status: 422 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
demo: result.demo,
|
||||
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 demo'
|
||||
console.error('[/api/ai/interactive-demo]', error)
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
181
memento-note/app/api/ai/interactive-page/route.ts
Normal file
181
memento-note/app/api/ai/interactive-page/route.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user