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:
@@ -0,0 +1,398 @@
|
||||
import type { AIProvider } from '@/lib/ai/types'
|
||||
import { extractSourceAssets } from '@/lib/ai/services/slide-source-assets'
|
||||
import {
|
||||
validateInteractiveDemo,
|
||||
type InteractiveDemoV1,
|
||||
} from '@/lib/interactive-demo'
|
||||
import {
|
||||
validateInteractivePage,
|
||||
type PageSpecV1,
|
||||
type PageValidationIssue,
|
||||
} from '@/lib/interactive-page'
|
||||
import { normalizeInteractivePageCandidate } from '@/lib/interactive-page/normalize'
|
||||
|
||||
function stripToPlain(html: string): string {
|
||||
return html
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function slugId(title: string): string {
|
||||
const s = title
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 40)
|
||||
return s ? `page.${s}` : 'page.generated'
|
||||
}
|
||||
|
||||
function firstSentence(text: string, max = 100): string {
|
||||
const s = text.split(/[.!?。]/)[0]?.trim() || text.trim()
|
||||
return s.slice(0, max) || 'Page interactive'
|
||||
}
|
||||
|
||||
function chunkSentences(text: string): string[] {
|
||||
return text
|
||||
.split(/(?<=[.!?。])\s+/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 20)
|
||||
}
|
||||
|
||||
const INTENT_CYCLE = ['compute', 'flow', 'output', 'cache'] as const
|
||||
|
||||
/**
|
||||
* Instant Play/Step demo from note vocabulary — no LLM.
|
||||
* 3–4 nodes + spotlight steps; formulas in speak when available.
|
||||
*/
|
||||
export function buildDeterministicDemo(
|
||||
content: string,
|
||||
lang: string,
|
||||
assets: ReturnType<typeof extractSourceAssets>
|
||||
): InteractiveDemoV1 | null {
|
||||
const fr = lang.startsWith('fr')
|
||||
const plain = stripToPlain(content)
|
||||
const sentences = [
|
||||
...assets.keySentences,
|
||||
...chunkSentences(plain),
|
||||
].filter((s, i, a) => a.indexOf(s) === i)
|
||||
|
||||
// Prefer short phrase labels from key sentences — never raw formula fragments
|
||||
const labels: string[] = []
|
||||
for (const s of sentences.slice(0, 8)) {
|
||||
const words = s
|
||||
.replace(/\$[^$]*\$/g, ' ')
|
||||
.replace(/[\\{}]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 4)
|
||||
.join(' ')
|
||||
.trim()
|
||||
if (words.length >= 6 && words.length <= 40 && !labels.includes(words)) {
|
||||
labels.push(words)
|
||||
}
|
||||
if (labels.length >= 4) break
|
||||
}
|
||||
// Clean formulas usable inside $…$ (KaTeX eats spaces → no prose, bounded)
|
||||
const cleanFormulas = assets.formulas.filter((f) => f.length <= 80)
|
||||
if (labels.length < 3) {
|
||||
const fallbacks = fr
|
||||
? ['Entrée', 'Transformation', 'Résultat', 'Retour']
|
||||
: ['Input', 'Transform', 'Output', 'Loop']
|
||||
for (const fb of fallbacks) {
|
||||
if (labels.length >= 4) break
|
||||
if (!labels.includes(fb)) labels.push(fb)
|
||||
}
|
||||
}
|
||||
while (labels.length < 3) labels.push(`Étape ${labels.length + 1}`)
|
||||
const nodeCount = Math.min(4, Math.max(3, labels.length))
|
||||
|
||||
const nodes = labels.slice(0, nodeCount).map((label, i) => ({
|
||||
id: `n${i + 1}`,
|
||||
label:
|
||||
cleanFormulas[i] && i < 2
|
||||
? `${i + 1} · ${label.split('\n')[0]}\n$${cleanFormulas[i]}$`
|
||||
: `${i + 1} · ${label}`,
|
||||
intent: INTENT_CYCLE[i % INTENT_CYCLE.length],
|
||||
}))
|
||||
|
||||
const edges = nodes.map((n, i) => {
|
||||
const next = nodes[(i + 1) % nodes.length]
|
||||
return {
|
||||
id: `e${i + 1}`,
|
||||
from: n.id,
|
||||
to: next.id,
|
||||
style: 'solid' as const,
|
||||
intent: 'flow' as const,
|
||||
}
|
||||
})
|
||||
|
||||
const steps = nodes.map((n, i) => {
|
||||
const formula = cleanFormulas[i]
|
||||
const speakBase =
|
||||
sentences[i]?.slice(0, 100) ||
|
||||
(fr ? `Étape **${i + 1}** du mécanisme.` : `Step **${i + 1}** of the mechanism.`)
|
||||
const speak = formula
|
||||
? `${speakBase.split('.')[0]}. $${formula}$`
|
||||
: speakBase
|
||||
const revealed = nodes.slice(0, i + 1).map((x) => x.id)
|
||||
if (i > 0) revealed.push(edges[i - 1].id)
|
||||
const isLast = i === nodes.length - 1
|
||||
return {
|
||||
id: `a1.s${i + 1}`,
|
||||
speak: speak.slice(0, 160),
|
||||
pattern: isLast ? ('overview' as const) : ('spotlightTour' as const),
|
||||
pointTo: [n.id],
|
||||
reveal: [{ ids: isLast ? nodes.map((x) => x.id).concat(edges.map((e) => e.id)) : revealed, scope: 'act' as const }],
|
||||
}
|
||||
})
|
||||
|
||||
const raw = {
|
||||
schemaVersion: 1 as const,
|
||||
id: 'demo.page-auto',
|
||||
lang,
|
||||
disclaimer: fr
|
||||
? 'Schéma pédagogique généré depuis la note — valeurs illustratives.'
|
||||
: 'Pedagogical diagram from your note — illustrative values.',
|
||||
scene: {
|
||||
id: 'scene.main',
|
||||
panels: [
|
||||
{
|
||||
id: 'panel.main',
|
||||
type: 'svg-scene' as const,
|
||||
payload: { nodes, edges },
|
||||
},
|
||||
],
|
||||
},
|
||||
acts: [
|
||||
{
|
||||
id: 'a1',
|
||||
title: fr ? 'Parcours' : 'Walkthrough',
|
||||
pattern: 'flowTrace' as const,
|
||||
steps,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const validated = validateInteractiveDemo(raw)
|
||||
if (!validated.ok) {
|
||||
console.warn(
|
||||
'[interactive-page] deterministic demo invalid',
|
||||
validated.issues.slice(0, 5)
|
||||
)
|
||||
return null
|
||||
}
|
||||
return validated.demo
|
||||
}
|
||||
|
||||
export function buildPageFromNote(
|
||||
content: string,
|
||||
lang: string,
|
||||
assets: ReturnType<typeof extractSourceAssets>
|
||||
): Record<string, unknown> {
|
||||
const plain = stripToPlain(content)
|
||||
const sentences = [
|
||||
...assets.keySentences,
|
||||
...chunkSentences(plain),
|
||||
].filter((s, i, arr) => arr.indexOf(s) === i)
|
||||
const title = firstSentence(sentences[0] || plain, 90)
|
||||
const lead = sentences[0]?.slice(0, 320) || plain.slice(0, 320) || title
|
||||
const fr = lang.startsWith('fr')
|
||||
// Displayable formulas only (KaTeX eats spaces — no prose, bounded length)
|
||||
const displayFormulas = assets.formulas.filter((f) => f.length <= 120)
|
||||
const formula = displayFormulas[0]
|
||||
const formula2 = displayFormulas[1]
|
||||
|
||||
const cards = [
|
||||
{
|
||||
badge: 'PROBLEM',
|
||||
title: fr ? 'Contexte' : 'Context',
|
||||
body: (sentences[0] || lead).slice(0, 160),
|
||||
intent: 'warning' as const,
|
||||
},
|
||||
{
|
||||
badge: 'APPROACH',
|
||||
title: fr ? 'Approche' : 'Approach',
|
||||
body: (sentences[1] || sentences[0] || lead).slice(0, 160),
|
||||
intent: 'flow' as const,
|
||||
},
|
||||
{
|
||||
badge: 'RESULT',
|
||||
title: formula ? (fr ? 'Relation' : 'Relation') : fr ? 'Idée clé' : 'Key idea',
|
||||
body: formula ? `$${formula}$` : (sentences[2] || lead).slice(0, 160),
|
||||
intent: 'output' as const,
|
||||
},
|
||||
]
|
||||
|
||||
const sections: Record<string, unknown>[] = [
|
||||
{
|
||||
id: 's1',
|
||||
title: fr ? 'Le problème' : 'The problem',
|
||||
blocks: [
|
||||
{ type: 'prose', md: sentences[0] || lead },
|
||||
{
|
||||
type: 'callout',
|
||||
kind: 'definition',
|
||||
title: fr ? 'En bref' : 'In short',
|
||||
md: (sentences[1] || plain.slice(0, 180) || title).slice(0, 220),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 's2',
|
||||
title: fr ? 'Mécanisme' : 'Mechanism',
|
||||
blocks: [
|
||||
{ type: 'prose', md: sentences[1] || sentences[0] || lead },
|
||||
...(formula ? [{ type: 'formula', tex: formula }] : []),
|
||||
...(formula2
|
||||
? [
|
||||
{
|
||||
type: 'callout',
|
||||
kind: 'tip',
|
||||
title: fr ? 'Aussi' : 'Also',
|
||||
md: `$${formula2}$`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 's3',
|
||||
title: fr ? 'Synthèse' : 'Synthesis',
|
||||
blocks: [
|
||||
{
|
||||
type: 'prose',
|
||||
md:
|
||||
sentences[2] ||
|
||||
(fr
|
||||
? 'Retenez le mécanisme — la démo interactive en retrace le flux.'
|
||||
: 'Keep the mechanism — the interactive demo traces the flow.'),
|
||||
},
|
||||
{
|
||||
type: 'stats',
|
||||
items: [
|
||||
{
|
||||
value: String(Math.max(assets.formulas.length, 1)),
|
||||
label: fr ? 'Formules' : 'Formulas',
|
||||
},
|
||||
{
|
||||
value: String(Math.min(Math.max(sentences.length, 3), 8)),
|
||||
label: fr ? 'Idées' : 'Ideas',
|
||||
},
|
||||
assets.numbers[0]
|
||||
? {
|
||||
value: String(assets.numbers[0].value),
|
||||
label: assets.numbers[0].label || (fr ? 'Donnée' : 'Figure'),
|
||||
}
|
||||
: { value: '→', label: fr ? 'Suite' : 'Next' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
id: slugId(title),
|
||||
lang,
|
||||
hero: {
|
||||
kicker: fr ? 'EXPLAINER INTERACTIF' : 'INTERACTIVE EXPLAINER',
|
||||
title,
|
||||
subtitle: (sentences[1] || plain).slice(0, 140),
|
||||
meta: fr ? 'Généré depuis votre note' : 'Generated from your note',
|
||||
},
|
||||
overview: { lead, cards },
|
||||
sections,
|
||||
}
|
||||
}
|
||||
|
||||
function injectDemo(
|
||||
page: Record<string, unknown>,
|
||||
demo: unknown
|
||||
): Record<string, unknown> {
|
||||
const sections = Array.isArray(page.sections)
|
||||
? ([...page.sections] as Record<string, unknown>[])
|
||||
: []
|
||||
if (!sections.length) return page
|
||||
const targetIdx = Math.min(1, sections.length - 1)
|
||||
const target = { ...sections[targetIdx] }
|
||||
const blocks = Array.isArray(target.blocks)
|
||||
? [...(target.blocks as Record<string, unknown>[])]
|
||||
: []
|
||||
blocks.push({ type: 'demo', demo, caption: 'Démo interactive' })
|
||||
target.blocks = blocks
|
||||
sections[targetIdx] = target
|
||||
return { ...page, sections }
|
||||
}
|
||||
|
||||
export type GenerateInteractivePageInput = {
|
||||
content: string
|
||||
lang?: string
|
||||
/** Kept for API compatibility — page skeleton no longer depends on LLM. */
|
||||
provider: AIProvider
|
||||
}
|
||||
|
||||
export type GenerateInteractivePageResult =
|
||||
| { ok: true; page: PageSpecV1; attempts: number }
|
||||
| {
|
||||
ok: false
|
||||
issues?: PageValidationIssue[]
|
||||
error?: string
|
||||
reason?: string
|
||||
raw?: string
|
||||
attempts: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Instant reliable page: deterministic skeleton + deterministic Play/Step demo.
|
||||
* No LLM round-trip for the page itself (LLM demos were timing out past client abort).
|
||||
*/
|
||||
export async function generateInteractivePageFromContent(
|
||||
input: GenerateInteractivePageInput
|
||||
): Promise<GenerateInteractivePageResult> {
|
||||
const lang = input.lang || 'fr'
|
||||
const assets = extractSourceAssets(input.content)
|
||||
const plain = stripToPlain(input.content)
|
||||
if (plain.split(/\s+/).filter(Boolean).length < 30) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'unsuitable_content',
|
||||
reason: 'Contenu trop court pour une page interactive',
|
||||
attempts: 0,
|
||||
}
|
||||
}
|
||||
|
||||
let pageObj = buildPageFromNote(input.content, lang, assets)
|
||||
const demo = buildDeterministicDemo(input.content, lang, assets)
|
||||
if (demo) {
|
||||
pageObj = injectDemo(pageObj, demo)
|
||||
}
|
||||
|
||||
const normalized = normalizeInteractivePageCandidate(pageObj, lang)
|
||||
if (!normalized) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'normalize_failed',
|
||||
reason: 'Impossible de normaliser la page',
|
||||
attempts: 1,
|
||||
}
|
||||
}
|
||||
|
||||
const result = validateInteractivePage(normalized)
|
||||
if (!result.ok) {
|
||||
// Last resort: page without demo
|
||||
const sections = Array.isArray(normalized.sections)
|
||||
? (normalized.sections as Record<string, unknown>[]).map((sec) => ({
|
||||
...sec,
|
||||
blocks: Array.isArray(sec.blocks)
|
||||
? (sec.blocks as Record<string, unknown>[]).filter(
|
||||
(b) => b.type !== 'demo'
|
||||
)
|
||||
: [],
|
||||
}))
|
||||
: []
|
||||
const stripped = validateInteractivePage({ ...normalized, sections })
|
||||
if (stripped.ok) {
|
||||
return { ok: true, page: stripped.page, attempts: 1 }
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
issues: result.issues,
|
||||
error: 'validation_failed',
|
||||
reason: result.issues[0]
|
||||
? `${result.issues[0].path}: ${result.issues[0].message}`
|
||||
: 'Page invalide',
|
||||
attempts: 1,
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, page: result.page, attempts: 1 }
|
||||
}
|
||||
Reference in New Issue
Block a user