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

@@ -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(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/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.
* 34 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 }
}