feat: dashboard Second Brain, essai 7 jours et vérification e-mail
Rendre le dashboard actionnable (inbox, peek, carte mentale), aligner la facturation sur l’essai 7 jours, et bloquer le login e-mail tant que l’adresse n’est pas confirmée. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,7 +4,7 @@ import type {
|
||||
PageValidationIssue,
|
||||
} from '@/lib/interactive-page'
|
||||
|
||||
export type PagePlanDemoKind = 'svg-scene' | 'chart' | 'heatmap-matrix' | 'simulation' | 'none'
|
||||
export type PagePlanDemoKind = 'steps' | 'svg-scene' | 'chart' | 'heatmap-matrix' | 'simulation' | 'none'
|
||||
|
||||
export type PagePlanSection = {
|
||||
title: string
|
||||
|
||||
@@ -47,131 +47,7 @@ function chunkSentences(text: string): string[] {
|
||||
.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,
|
||||
@@ -296,7 +172,7 @@ export function buildPageFromNote(
|
||||
|
||||
function injectDemo(
|
||||
page: Record<string, unknown>,
|
||||
demo: unknown
|
||||
block: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const sections = Array.isArray(page.sections)
|
||||
? ([...page.sections] as Record<string, unknown>[])
|
||||
@@ -307,7 +183,7 @@ function injectDemo(
|
||||
const blocks = Array.isArray(target.blocks)
|
||||
? [...(target.blocks as Record<string, unknown>[])]
|
||||
: []
|
||||
blocks.push({ type: 'demo', demo, caption: 'Démo interactive' })
|
||||
blocks.push(block)
|
||||
target.blocks = blocks
|
||||
sections[targetIdx] = target
|
||||
return { ...page, sections }
|
||||
@@ -332,8 +208,43 @@ export type GenerateInteractivePageResult =
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Deterministic step-by-step block from extracted formulas (math notes).
|
||||
* Replaces the old generic box-diagram fallback — boxes are banned.
|
||||
*/
|
||||
function buildDeterministicSteps(
|
||||
content: string,
|
||||
lang: string,
|
||||
assets: ReturnType<typeof extractSourceAssets>
|
||||
): Record<string, unknown> | null {
|
||||
const fr = lang.startsWith('fr')
|
||||
const formulas = assets.formulas.filter((f) => f.length <= 120).slice(0, 6)
|
||||
if (formulas.length < 3) return null
|
||||
const plain = stripToPlain(content)
|
||||
const sentences = [
|
||||
...assets.keySentences,
|
||||
...chunkSentences(plain),
|
||||
].filter((s, i, a) => a.indexOf(s) === i)
|
||||
return {
|
||||
type: 'steps',
|
||||
title: fr ? 'Dérivation pas à pas' : 'Step-by-step derivation',
|
||||
steps: formulas.map((tex, i) => ({
|
||||
tex,
|
||||
...(i === 0
|
||||
? { rule: fr ? 'Point de départ' : 'Starting point' }
|
||||
: {}),
|
||||
speak:
|
||||
sentences[i]?.slice(0, 120) ||
|
||||
(fr ? `Étape **${i + 1}** de la dérivation.` : `Step **${i + 1}** of the derivation.`),
|
||||
})),
|
||||
caption: fr
|
||||
? 'Formules extraites de la note, déroulées pas à pas.'
|
||||
: 'Formulas extracted from the note, walked through step by step.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Instant reliable page: deterministic skeleton + deterministic steps (math)
|
||||
* or Play/Step demo (other content). No LLM round-trip for the page itself.
|
||||
*/
|
||||
export async function generateInteractivePageFromContent(
|
||||
input: GenerateInteractivePageInput
|
||||
@@ -351,9 +262,9 @@ export async function generateInteractivePageFromContent(
|
||||
}
|
||||
|
||||
let pageObj = buildPageFromNote(input.content, lang, assets)
|
||||
const demo = buildDeterministicDemo(input.content, lang, assets)
|
||||
if (demo) {
|
||||
pageObj = injectDemo(pageObj, demo)
|
||||
const stepsBlock = buildDeterministicSteps(input.content, lang, assets)
|
||||
if (stepsBlock) {
|
||||
pageObj = injectDemo(pageObj, stepsBlock)
|
||||
}
|
||||
|
||||
const normalized = normalizeInteractivePageCandidate(pageObj, lang)
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
import { catalogForPrompt } from '@/lib/simulators'
|
||||
import thermoFixture from '@/lib/interactive-page/fixtures/thermo-page.json'
|
||||
|
||||
const MAX_ATTEMPTS = 2
|
||||
const MAX_ATTEMPTS = 3
|
||||
|
||||
// ── Shared helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -124,19 +124,19 @@ function fixtureDemo(panelType: string): string | null {
|
||||
|
||||
// ── 1. Page plan ─────────────────────────────────────────────────────────────
|
||||
|
||||
const DEMO_KINDS = ['svg-scene', 'chart', 'heatmap-matrix', 'simulation', 'none'] as const
|
||||
const DEMO_KINDS = ['steps', 'svg-scene', 'chart', 'heatmap-matrix', 'simulation', 'none'] as const
|
||||
export type PagePlanDemoKind = (typeof DEMO_KINDS)[number]
|
||||
|
||||
const planSectionSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
goal: z.string().min(1),
|
||||
demoKind: z.enum(DEMO_KINDS),
|
||||
demoGoal: z.string().optional(),
|
||||
demoGoal: z.string().nullish(),
|
||||
})
|
||||
|
||||
const pagePlanSchema = z.object({
|
||||
heroTitle: z.string().min(1),
|
||||
heroSubtitle: z.string().optional(),
|
||||
heroSubtitle: z.string().nullish(),
|
||||
overviewLead: z.string().min(1),
|
||||
overviewCards: z
|
||||
.array(
|
||||
@@ -144,7 +144,7 @@ const pagePlanSchema = z.object({
|
||||
badge: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
body: z.string().min(1),
|
||||
intent: z.enum(INTENT_IDS).optional(),
|
||||
intent: z.enum(INTENT_IDS).nullish(),
|
||||
})
|
||||
)
|
||||
.min(INTERACTIVE_PAGE_CAPS.minOverviewCards)
|
||||
@@ -170,7 +170,7 @@ SCHEMA:
|
||||
"heroTitle": string, // the SUBJECT of the note, never a generic title
|
||||
"heroSubtitle": string, // one sentence, autoportant
|
||||
"overviewLead": string, // the central idea in ONE self-contained paragraph (may use $KaTeX$ inline)
|
||||
"overviewCards": [ { "badge": string, "title": string, "body": string, "intent?": ${JSON.stringify(INTENT_IDS)} } ], // 2–4 key concepts, small-caps badges (ex. PROBLEM / APPROACH / RESULT)
|
||||
"overviewCards": [ { "badge": string, "title": string, "body": string, "intent?": ${JSON.stringify(INTENT_IDS)} } ], // 3–4 key concepts, small-caps badges (ex. PROBLEM / APPROACH / RESULT)
|
||||
"sections": [ { "title": string, "goal": string, "demoKind": ${JSON.stringify(DEMO_KINDS)}, "demoGoal?": string } ] // 2–5
|
||||
}
|
||||
|
||||
@@ -181,6 +181,7 @@ COUVERTURE (règle n°1):
|
||||
- If the content does not benefit from an interactive page, REFUSE: return { "error": "unsuitable_content", "reason": "…" } instead.
|
||||
|
||||
MATCHING CONTENU → DÉMO (choose demoKind per section, "none" when no visual helps):
|
||||
- Dérivation, démonstration, résolution d'équation, calcul pas à pas (maths, physique) → "steps" — JAMAIS de svg-scene pour du contenu mathématique
|
||||
- Catégories × propriétés (comparatif, matrice, échanges) → "heatmap-matrix"
|
||||
- Loi / relation / courbe / évolution chiffrée → "chart"
|
||||
- Processus / flux / cycle / architecture → "svg-scene"
|
||||
@@ -298,8 +299,14 @@ Block types (discriminated by "type"):
|
||||
- { "type": "table", "columns": string[], "rows": string[][], "caption?": string } // every row.length === columns.length
|
||||
- { "type": "demo", "demo": InteractiveDemoV1, "caption?": string }
|
||||
- { "type": "sim", "sim": SimRef, "caption?": string } // interactive simulation with sliders
|
||||
- { "type": "steps", "title?": string, "steps": [{ "tex": string, "rule?": string, "speak?": string }] (2–12), "caption?": string } // step-by-step derivation: tex = KaTeX of the equation state, rule = transformation applied (short, e.g. "on sépare les variables")
|
||||
IntentId: ${JSON.stringify(INTENT_IDS)}
|
||||
|
||||
BLOCK "steps" (Symbolab-style derivation) — MANDATORY for math/derivation content:
|
||||
- Each step = the equation state AFTER applying the rule; steps must chain logically (each follows from the previous).
|
||||
- rule = the transformation applied to reach THIS state (short verb phrase). speak = 1 sentence teacher narration.
|
||||
- FORBIDDEN: using "demo" svg-scene (boxes with arrows) for equations, derivations, proofs, or calculus content — always "steps" instead.
|
||||
|
||||
SimRef — TWO forms:
|
||||
(A) CATALOG simulator (PREFERRED when the section matches one): { "simId": "<id from catalog>", "title?": string, "preset?": { "<paramId>": number }, "disclaimer?": string }
|
||||
→ You ONLY pick the simId and preset values (from the note's real numbers within the allowed ranges). The app runs the simulation.
|
||||
@@ -347,13 +354,16 @@ function buildSectionUserPrompt(
|
||||
|
||||
SECTION_GOAL: ${section.goal}
|
||||
${
|
||||
section.demoKind === 'simulation'
|
||||
? `SIMULATION_REQUIRED: include ONE "sim" block. Prefer a catalog simulator if the section matches one (bind the note's real values into "preset"); otherwise "generic-formula" with the section's key relation.
|
||||
section.demoKind === 'steps'
|
||||
? `STEPS_REQUIRED: include ONE "steps" block — the step-by-step derivation of this section's key result. Real equations from the note, logically chained, a short "rule" per step.
|
||||
STEPS_GOAL: ${section.demoGoal || section.goal}`
|
||||
: section.demoKind === 'simulation'
|
||||
? `SIMULATION_REQUIRED: include ONE "sim" block. Prefer a catalog simulator if the section matches one (bind the note's real values into "preset"); otherwise "generic-formula" with the section's key relation.
|
||||
SIM_GOAL: ${section.demoGoal || section.goal}`
|
||||
: section.demoKind !== 'none'
|
||||
? `DEMO_REQUIRED: include ONE "demo" block of kind "${section.demoKind}".
|
||||
: section.demoKind !== 'none'
|
||||
? `DEMO_REQUIRED: include ONE "demo" block of kind "${section.demoKind}".
|
||||
DEMO_GOAL: ${section.demoGoal || section.goal}`
|
||||
: `NO demo/sim block for this section — rich prose/formula/callout/stats/table only.`
|
||||
: `NO demo/sim/steps block for this section — rich prose/formula/callout/stats/table only.`
|
||||
}
|
||||
|
||||
EXCERPT_START
|
||||
@@ -396,8 +406,10 @@ function validateSectionCandidate(
|
||||
hero: { kicker: 'CHECK', title: 'Section check' },
|
||||
sections: [
|
||||
typeof candidate === 'object' && candidate !== null
|
||||
? { id: sectionId, ...(candidate as Record<string, unknown>) }
|
||||
? { ...(candidate as Record<string, unknown>), id: sectionId }
|
||||
: candidate,
|
||||
// schema requires ≥2 sections — inert filler for the wrap check
|
||||
{ id: 's99', title: '—', blocks: [{ type: 'prose', md: '—' }] },
|
||||
],
|
||||
}
|
||||
const normalized = normalizeInteractivePageCandidate(wrapped, lang)
|
||||
|
||||
@@ -214,6 +214,32 @@ export function normalizeSlideDeck(input: {
|
||||
}
|
||||
return { ...s, stats: valid.slice(0, 4) }
|
||||
}
|
||||
// Equation without real formulas → degrade to bullets (avoid shipping f(x)=?)
|
||||
if (s.type === 'equation') {
|
||||
const eqs = Array.isArray(s.equations) ? s.equations : []
|
||||
const real = eqs.filter((eq: any) => String(eq?.latex || '').trim() && !/f\s*\(\s*x\s*\)\s*=\s*\?/i.test(String(eq.latex)))
|
||||
if (real.length === 0) {
|
||||
return {
|
||||
type: 'bullets',
|
||||
title: trimStr(s.title || 'Points clés', 90),
|
||||
items: cleanList(
|
||||
[...(s.explanation ? [String(s.explanation)] : []), ...eqs.map((eq: any) => eq?.label).filter(Boolean)],
|
||||
4,
|
||||
140,
|
||||
),
|
||||
}
|
||||
}
|
||||
return { ...s, equations: real.slice(0, 4) }
|
||||
}
|
||||
// Image without URL → degrade to bullets (avoid placeholder in final deck)
|
||||
if (s.type === 'image' && !String(s.url || '').trim()) {
|
||||
const caption = String(s.caption || '').trim()
|
||||
return {
|
||||
type: 'bullets',
|
||||
title: trimStr(s.title || 'Illustration', 90),
|
||||
items: caption ? [caption] : cleanList(s.items, 3, 140),
|
||||
}
|
||||
}
|
||||
return s
|
||||
})
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ const SlideTypeEnum = z.enum([
|
||||
'chart',
|
||||
'table',
|
||||
'quote',
|
||||
'image',
|
||||
'summary',
|
||||
])
|
||||
|
||||
@@ -61,6 +62,8 @@ const OutlineSlideSchema = z.object({
|
||||
keyPoints: z.array(z.string()).min(1).max(6),
|
||||
/** For equation slides: latex strings to include */
|
||||
formulas: z.array(z.string()).optional(),
|
||||
/** For image slides: 0-based index into assets.images */
|
||||
imageIndex: z.number().optional(),
|
||||
narrativeRole: z.enum(['opening', 'evidence', 'transition', 'conclusion', 'data']).optional(),
|
||||
})
|
||||
|
||||
@@ -96,6 +99,8 @@ const ExpandedSlideSchema = z.object({
|
||||
author: z.string().optional(),
|
||||
context: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
url: z.string().optional(),
|
||||
caption: z.string().optional(),
|
||||
})
|
||||
|
||||
export interface GenerateSlideDeckParams {
|
||||
@@ -138,6 +143,51 @@ const LANG_NAMES: Record<string, string> = {
|
||||
hi: 'Hindi',
|
||||
}
|
||||
|
||||
/** Detect content language from text via Unicode script ranges + Latin word frequency. */
|
||||
function detectContentLanguage(text: string): string {
|
||||
if (!text) return 'English'
|
||||
// Non-Latin scripts (deterministic)
|
||||
if (/[\u0600-\u06FF\uFB50-\uFDFF\uFE70-\uFEFF]/.test(text)) {
|
||||
if (/[\u06CC\u0698\u06AF\u06A9\u067E\u0686]/.test(text)) return 'Persian (Farsi)'
|
||||
return 'Arabic'
|
||||
}
|
||||
if (/[\uAC00-\uD7AF]/.test(text)) return 'Korean'
|
||||
if (/[\u3040-\u309F\u30A0-\u30FF]/.test(text)) return 'Japanese'
|
||||
if (/[\u4E00-\u9FFF]/.test(text)) return 'Chinese'
|
||||
if (/[\u0400-\u04FF]/.test(text)) return 'Russian'
|
||||
if (/[\u0900-\u097F]/.test(text)) return 'Hindi'
|
||||
|
||||
// Latin script — distinguish via accented chars + function word frequency
|
||||
const lower = text.toLowerCase()
|
||||
const wc = (re: RegExp) => (lower.match(re) || []).length
|
||||
|
||||
// French: distinctive accents (é è ê à ô etc) + function words
|
||||
const frAccents = (lower.match(/[éèêëàâäïîôöùûüç]/g) || []).length
|
||||
const frWords = wc(/\b(dans|avec|pour|cette|être|avoir|fait|plus|sans|sous|entre|après|très|bien|aussi|même|encore|toujours|jamais|pendant|depuis|chez|leurs|mes|tes|ses|notre|votre|celui|ceux|celle|aucun|autre|chaque)\b/g)
|
||||
const frScore = frAccents + frWords
|
||||
|
||||
// Spanish
|
||||
const esAccents = (lower.match(/[áíóúñ¿¡]/g) || []).length
|
||||
const esWords = wc(/\b(pero|como|más|para|con|por|una|los|las|del|al|también|puede|antes|después|siempre|nunca|también|aquí|allí|muy|bien|también|nosotros|vosotros|ellos|suyo|nuestro)\b/g)
|
||||
const esScore = esAccents + esWords
|
||||
|
||||
// German
|
||||
const deUmlauts = (lower.match(/[äöüß]/g) || []).length
|
||||
const deWords = wc(/\b(und|ist|ein|eine|von|mit|für|auf|nicht|auch|sich|bei|zum|zur|den|des|dem|wir|sie|hat|war|wird|sind|kann|muss|noch|schon|immer|wieder)\b/g)
|
||||
const deScore = deUmlauts * 2 + deWords
|
||||
|
||||
// Pick highest scorer (must beat English baseline)
|
||||
const scores: [string, number][] = [
|
||||
['French', frScore],
|
||||
['Spanish', esScore],
|
||||
['German', deScore],
|
||||
]
|
||||
scores.sort((a, b) => b[1] - a[1])
|
||||
// Require a minimum signal (accents/words) to avoid false positives on short English text
|
||||
if (scores[0][1] >= 6) return scores[0][0]
|
||||
return 'English'
|
||||
}
|
||||
|
||||
// ── LLM helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function extractJsonPayload(text: string): unknown | null {
|
||||
@@ -223,6 +273,18 @@ function outlineSystem(lang: 'fr' | 'en', maxSlides: number, assets: SourceAsset
|
||||
: `MATH/STEM DOMAIN DETECTED: you MUST include at least ${Math.min(2, Math.max(1, assets.formulas.length))} slides of type "equation" carrying the extracted formulas. Do NOT replace formulas with vague bullets.`
|
||||
: ''
|
||||
|
||||
const imageRule = assets.hasImages
|
||||
? lang === 'fr'
|
||||
? `MATÉRIEL VISUEL: ${assets.images.length} image(s) disponible(s). Inclus 1 à ${Math.min(3, assets.images.length)} slide(s) de type "image" pour illustrer le propos. Pour chaque slide image, donne l'index (imageIndex, 0-based) de l'image à utiliser.`
|
||||
: `VISUAL MATERIAL: ${assets.images.length} image(s) available. Include 1 to ${Math.min(3, assets.images.length)} slide(s) of type "image" to illustrate key points. For each image slide, provide imageIndex (0-based) of the image to use.`
|
||||
: ''
|
||||
|
||||
const chartRule = assets.hasNumbers
|
||||
? lang === 'fr'
|
||||
? `DONNÉES NUMÉRIQUES: ${assets.numbers.length} valeurs détectées. Tu DOIS inclure au moins 1 slide de type "chart". chartType: "bar" (comparer des quantités), "line" (tendance temporelle), "donut" (proportions d'un tout).`
|
||||
: `NUMERIC DATA: ${assets.numbers.length} values detected. You MUST include at least 1 slide of type "chart". chartType: "bar" (compare quantities), "line" (time trend), "donut" (proportions of a whole).`
|
||||
: ''
|
||||
|
||||
if (lang === 'fr') {
|
||||
return `Tu es l'architecte narratif DeckForge/PPTAgent pour Memento.
|
||||
Tu produis UNIQUEMENT un outline JSON (pas le corps final des slides).
|
||||
@@ -233,10 +295,13 @@ Règles (DeckForge + think-cell):
|
||||
- keyPoints: 2–5 FAITS CONCRETS tirés de la note (chiffres, noms, formules) — jamais de filler
|
||||
- Chaque slide = UNE idée actionnable (titre = insight, pas libellé de section)
|
||||
- Arc pédagogique pour cours: title → définitions/équations → propriétés → exemples → summary
|
||||
- Types: title | equation | bullets | cards | comparison | timeline | stats | chart | table | quote | summary
|
||||
- Types: title | equation | bullets | cards | comparison | timeline | stats | chart | table | quote | image | summary
|
||||
- ATTENTION: le type "equation" est RÉSERVÉ aux formules mathématiques réelles (avec LaTeX). N'utilise JAMAIS "equation" pour un concept métaphorique (équilibre, rapport de forces, etc.) → utilise "bullets" ou "comparison" à la place.
|
||||
- Slide 1 = title, dernière = summary
|
||||
- INTERDIT slides vides, listes de 1 mot, ou répéter le titre de la note
|
||||
${mathRule}
|
||||
${imageRule}
|
||||
${chartRule}
|
||||
Réponds en JSON OutlineSchema.`
|
||||
}
|
||||
return `You are DeckForge/PPTAgent narrative architect for Memento.
|
||||
@@ -248,10 +313,13 @@ Rules:
|
||||
- keyPoints: 2–5 CONCRETE facts from the note (numbers, names, formulas) — no filler
|
||||
- One actionable idea per slide (title = insight, not section label)
|
||||
- Pedagogical arc for lessons: title → definitions/equations → properties → examples → summary
|
||||
- Types: title | equation | bullets | cards | comparison | timeline | stats | chart | table | quote | summary
|
||||
- Types: title | equation | bullets | cards | comparison | timeline | stats | chart | table | quote | image | summary
|
||||
- WARNING: type "equation" is RESERVED for real mathematical formulas (with LaTeX). NEVER use "equation" for metaphorical concepts (balance of power, concessions, etc.) → use "bullets" or "comparison" instead.
|
||||
- First=title, last=summary
|
||||
- FORBIDDEN empty slides, one-word lists, or repeating the note title alone
|
||||
${mathRule}
|
||||
${imageRule}
|
||||
${chartRule}
|
||||
JSON OutlineSchema only.`
|
||||
}
|
||||
|
||||
@@ -268,7 +336,10 @@ OBLIGATOIRE selon type:
|
||||
- comparison: title + left/right avec points[2-4] chacun
|
||||
- timeline: title + events[2-5]
|
||||
- stats: title + stats[2-4] SEULEMENT si chiffres réels fournis
|
||||
- chart: title + data[2+] SEULEMENT si chiffres réels
|
||||
- chart: title + chartType + data[{label, value: NUMBER}] (min 2 entrées)
|
||||
chartType: "bar" (comparer quantités) | "line" (tendance temporelle) | "donut" (proportions) | "horizontal-bar" (libellés longs) | "radar" (comparer dimensions)
|
||||
IMPORTANT: value DOIT être un number (pas string). Utilise les chiffres fournis dans le prompt.
|
||||
- image: title + url (fourni dans le prompt) + caption (1 phrase décrivant ce que l'image illustre)
|
||||
- summary: title + items[3-5] actionnables
|
||||
- quote: quote non vide
|
||||
|
||||
@@ -284,7 +355,11 @@ REQUIRED by type:
|
||||
- cards: title + cards[2-4]
|
||||
- comparison: left/right points[2-4] each
|
||||
- timeline: events[2-5]
|
||||
- stats/chart: only with real numbers provided
|
||||
- stats: only with real numbers provided
|
||||
- chart: title + chartType + data[{label, value: NUMBER}] (min 2 entries)
|
||||
chartType: "bar" (compare quantities) | "line" (time trend) | "donut" (proportions) | "horizontal-bar" (long labels) | "radar" (compare dimensions)
|
||||
IMPORTANT: value MUST be a number (not string). Use the numbers provided in the prompt.
|
||||
- image: title + url (provided in prompt) + caption (1 sentence describing what the image illustrates)
|
||||
- summary: items[3-5]
|
||||
- quote: non-empty quote
|
||||
|
||||
@@ -356,6 +431,72 @@ function enforceMathOutline(
|
||||
}
|
||||
}
|
||||
|
||||
/** Force image slides into outline when server extracted images (like enforceMathOutline for formulas). */
|
||||
function enforceImageOutline(
|
||||
outline: z.infer<typeof OutlineSchema>,
|
||||
assets: SourceAssets,
|
||||
maxSlides: number,
|
||||
): z.infer<typeof OutlineSchema> {
|
||||
if (!assets.hasImages || assets.images.length === 0) return outline
|
||||
const hasImg = outline.slides.some((s) => s.type === 'image')
|
||||
if (hasImg) return outline
|
||||
|
||||
// Inject 1 image slide after title (or after first equation slide)
|
||||
const title = outline.slides.find((s) => s.type === 'title') || outline.slides[0]!
|
||||
const insertAfter = outline.slides.findIndex((s) => s.type === 'title')
|
||||
const insertIdx = insertAfter >= 0 ? insertAfter + 1 : 1
|
||||
|
||||
const imgSlide: z.infer<typeof OutlineSlideSchema> = {
|
||||
position: insertIdx + 1,
|
||||
type: 'image',
|
||||
headline: assets.keySentences[0]?.slice(0, 60) || 'Illustration',
|
||||
keyPoints: [assets.images[0]!],
|
||||
imageIndex: 0,
|
||||
narrativeRole: 'evidence',
|
||||
}
|
||||
|
||||
const before = outline.slides.slice(0, insertIdx)
|
||||
const after = outline.slides.slice(insertIdx, maxSlides - 1)
|
||||
return {
|
||||
...outline,
|
||||
slides: [...before, imgSlide, ...after].map((s, i) => ({ ...s, position: i + 1 })),
|
||||
}
|
||||
}
|
||||
|
||||
function pickChartType(count: number): 'bar' | 'horizontal-bar' | 'donut' {
|
||||
if (count <= 4) return 'donut'
|
||||
if (count <= 6) return 'bar'
|
||||
return 'horizontal-bar'
|
||||
}
|
||||
|
||||
function enforceChartOutline(
|
||||
outline: z.infer<typeof OutlineSchema>,
|
||||
assets: SourceAssets,
|
||||
maxSlides: number,
|
||||
): z.infer<typeof OutlineSchema> {
|
||||
if (!assets.hasNumbers || assets.numbers.length < 2) return outline
|
||||
const hasChart = outline.slides.some((s) => s.type === 'chart')
|
||||
if (hasChart) return outline
|
||||
|
||||
const titleIdx = outline.slides.findIndex((s) => s.type === 'title')
|
||||
const insertIdx = titleIdx >= 0 ? titleIdx + 1 : 1
|
||||
|
||||
const chartSlide: z.infer<typeof OutlineSlideSchema> = {
|
||||
position: insertIdx + 1,
|
||||
type: 'chart',
|
||||
headline: assets.keySentences[0]?.slice(0, 60) || 'Données clés',
|
||||
keyPoints: assets.numbers.slice(0, 4).map((n) => `${n.label}: ${n.raw}`),
|
||||
narrativeRole: 'data',
|
||||
}
|
||||
|
||||
const before = outline.slides.slice(0, insertIdx)
|
||||
const after = outline.slides.slice(insertIdx, maxSlides - 1)
|
||||
return {
|
||||
...outline,
|
||||
slides: [...before, chartSlide, ...after].map((s, i) => ({ ...s, position: i + 1 })),
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackExpandFromOutline(
|
||||
o: z.infer<typeof OutlineSlideSchema>,
|
||||
assets: SourceAssets,
|
||||
@@ -371,10 +512,17 @@ function fallbackExpandFromOutline(
|
||||
}
|
||||
if (type === 'equation') {
|
||||
const forms = o.formulas?.length ? o.formulas : assets.formulas.slice(0, 3)
|
||||
if (!forms.length) {
|
||||
return {
|
||||
type: 'bullets',
|
||||
title,
|
||||
items: (o.keyPoints.length >= 2 ? o.keyPoints : assets.keySentences.slice(0, 3)).slice(0, 5),
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: 'equation',
|
||||
title,
|
||||
equations: (forms.length ? forms : ['f(x) = ?']).map((latex, i) => ({
|
||||
equations: forms.map((latex, i) => ({
|
||||
latex,
|
||||
label: o.keyPoints[i] || `Formule ${i + 1}`,
|
||||
})),
|
||||
@@ -420,6 +568,16 @@ function fallbackExpandFromOutline(
|
||||
})),
|
||||
}
|
||||
}
|
||||
if (type === 'image') {
|
||||
const imgIdx = o.imageIndex ?? 0
|
||||
const url = assets.images[imgIdx] || assets.images[0] || ''
|
||||
return {
|
||||
type: 'image',
|
||||
title,
|
||||
url,
|
||||
caption: o.keyPoints[0] || assets.keySentences[0] || '',
|
||||
}
|
||||
}
|
||||
// default bullets — use keyPoints + sentences to fill density
|
||||
const items = [
|
||||
...o.keyPoints,
|
||||
@@ -490,15 +648,15 @@ export async function generateSlideDeck(params: GenerateSlideDeckParams): Promis
|
||||
}
|
||||
}
|
||||
|
||||
const noteLang = notes[0]?.language
|
||||
const contentLang =
|
||||
params.contentLanguage ||
|
||||
(noteLang && LANG_NAMES[noteLang] ? LANG_NAMES[noteLang] : lang === 'fr' ? 'French' : 'English')
|
||||
|
||||
const noteLang = notes.find((n) => n.language)?.language
|
||||
const combined = notes.map((n) => n.content || '').join('\n\n')
|
||||
const assets = extractSourceAssets(combined)
|
||||
const contentLang =
|
||||
params.contentLanguage ||
|
||||
(noteLang && LANG_NAMES[noteLang] ? LANG_NAMES[noteLang] : detectContentLanguage(combined))
|
||||
const perNoteLimit = notes.length > 5 ? 1200 : 8000
|
||||
const notesText = notes
|
||||
.map((n) => `### ${n.title || 'Note'}\n${prepareNoteTextForSlides(n.content || '', 10_000)}`)
|
||||
.map((n) => `### ${n.title || 'Note'}\n${prepareNoteTextForSlides(n.content || '', perNoteLimit)}`)
|
||||
.join('\n\n')
|
||||
const wordCount = notes.reduce((s, n) => s + countNoteWords(n.content || ''), 0)
|
||||
const limit = slideLimitFromWordCount(wordCount)
|
||||
@@ -526,10 +684,7 @@ export async function generateSlideDeck(params: GenerateSlideDeckParams): Promis
|
||||
? `FORMULES EXTRAITES (à placer dans des slides equation):\n${assets.formulas.map((f, i) => `${i + 1}. ${f}`).join('\n')}`
|
||||
: '',
|
||||
assets.numbers.length
|
||||
? `CHIFFRES:\n${assets.numbers
|
||||
.slice(0, 10)
|
||||
.map((n) => `- ${n.label}: ${n.raw}`)
|
||||
.join('\n')}`
|
||||
? `DONNÉES NUMÉRIQUES (JSON pour slides chart/stats — utilise ces valeurs exactes):\n${JSON.stringify(assets.numbers.slice(0, 10).map((n) => ({ label: n.label.slice(0, 24), value: n.value })))}`
|
||||
: '',
|
||||
assets.keySentences.length
|
||||
? `PHRASES CLÉS:\n${assets.keySentences
|
||||
@@ -537,6 +692,9 @@ export async function generateSlideDeck(params: GenerateSlideDeckParams): Promis
|
||||
.map((s) => `- ${s}`)
|
||||
.join('\n')}`
|
||||
: '',
|
||||
assets.images.length
|
||||
? `IMAGES DISPONIBLES (utilise imageIndex 0-based pour les slides image):\n${assets.images.map((url, i) => `${i}. ${url}`).join('\n')}`
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
@@ -556,68 +714,49 @@ export async function generateSlideDeck(params: GenerateSlideDeckParams): Promis
|
||||
outline = enforceMathOutline(outline, assets, targetMax)
|
||||
}
|
||||
|
||||
// ── Stage 2: Expand PER SLIDE (DeckForge SlideWriter loop) ──
|
||||
const expanded: z.infer<typeof ExpandedSlideSchema>[] = []
|
||||
for (const slideOutline of outline.slides) {
|
||||
try {
|
||||
const formulaHint =
|
||||
slideOutline.type === 'equation'
|
||||
? `\nFORMULES OBLIGATOIRES pour cette slide:\n${(slideOutline.formulas || assets.formulas).slice(0, 4).join('\n')}`
|
||||
: assets.formulas.length && slideOutline.type === 'bullets'
|
||||
? `\n(Formules dispo si besoin: ${assets.formulas.slice(0, 2).join(' ; ')})`
|
||||
: ''
|
||||
|
||||
const one = await llmObject({
|
||||
model,
|
||||
schema: ExpandedSlideSchema,
|
||||
system: expandOneSystem(lang),
|
||||
prompt:
|
||||
lang === 'fr'
|
||||
? `Langue: ${contentLang}.
|
||||
${intentHints ? `Intent: ${intentHints}\n` : ''}Slide ${slideOutline.position}/${outline.slides.length}
|
||||
type: ${slideOutline.type}
|
||||
headline: ${slideOutline.headline}
|
||||
keyPoints: ${JSON.stringify(slideOutline.keyPoints)}
|
||||
role: ${slideOutline.narrativeRole || 'evidence'}
|
||||
${formulaHint}
|
||||
|
||||
Contexte note (extrait):\n${notesText.slice(0, 6000)}
|
||||
|
||||
Expand cette slide en JSON ExpandedSlide COMPLET (corps non vide).`
|
||||
: `Language: ${contentLang}.
|
||||
${intentHints ? `Intent: ${intentHints}\n` : ''}Slide ${slideOutline.position}/${outline.slides.length}
|
||||
type: ${slideOutline.type}
|
||||
headline: ${slideOutline.headline}
|
||||
keyPoints: ${JSON.stringify(slideOutline.keyPoints)}
|
||||
${formulaHint}
|
||||
|
||||
Note excerpt:\n${notesText.slice(0, 6000)}
|
||||
|
||||
Expand into full ExpandedSlide JSON (non-empty body).`,
|
||||
})
|
||||
// Force type/title from outline if model drifts
|
||||
one.type = slideOutline.type
|
||||
if (!one.title) one.title = slideOutline.headline
|
||||
// Inject formulas if equation empty
|
||||
if (one.type === 'equation' && (!one.equations || one.equations.length === 0)) {
|
||||
const forms = slideOutline.formulas?.length ? slideOutline.formulas : assets.formulas
|
||||
one.equations = forms.slice(0, 4).map((latex, i) => ({
|
||||
latex,
|
||||
label: slideOutline.keyPoints[i] || `Eq. ${i + 1}`,
|
||||
}))
|
||||
}
|
||||
expanded.push(one)
|
||||
} catch (e) {
|
||||
console.warn('[SlideDeck] expand failed for slide, using deterministic fallback', e)
|
||||
expanded.push(fallbackExpandFromOutline(slideOutline, assets))
|
||||
}
|
||||
// Images → force image slides when images exist but LLM didn't create any
|
||||
if (assets.hasImages) {
|
||||
outline = enforceImageOutline(outline, assets, targetMax)
|
||||
}
|
||||
|
||||
// Charts → force chart slides when numbers exist but LLM didn't create any
|
||||
if (assets.hasNumbers) {
|
||||
outline = enforceChartOutline(outline, assets, targetMax)
|
||||
}
|
||||
|
||||
// ── Stage 2: Expand slides ──
|
||||
// Single-pass: outline LLM call + deterministic fill (1 LLM call total)
|
||||
let mode = 'outline+deterministic-fill'
|
||||
|
||||
const expanded: z.infer<typeof ExpandedSlideSchema>[] = outline.slides.map((slideOutline) => {
|
||||
const one = fallbackExpandFromOutline(slideOutline, assets)
|
||||
one.type = slideOutline.type
|
||||
if (!one.title) one.title = slideOutline.headline
|
||||
return one
|
||||
})
|
||||
|
||||
// ── Stage 3: Normalize + STEM inject + substance gate ──
|
||||
|
||||
// Rescue chart slides with missing/partial data before normalization drops them
|
||||
const rescued = expanded.map((slide) => {
|
||||
if (slide.type !== 'chart') return slide
|
||||
const validData = (slide.data || []).filter(
|
||||
(d: any) => d && typeof d.value === 'number' && Number.isFinite(d.value) && String(d.label || '').trim(),
|
||||
)
|
||||
if (validData.length < 2 && assets.numbers.length >= 2) {
|
||||
return {
|
||||
...slide,
|
||||
data: assets.numbers.slice(0, 6).map((n) => ({ label: n.label.slice(0, 20), value: n.value })),
|
||||
chartType: slide.chartType || pickChartType(assets.numbers.length),
|
||||
}
|
||||
}
|
||||
return slide
|
||||
})
|
||||
|
||||
let normalized = normalizeSlideDeck({
|
||||
title: outline.title,
|
||||
theme,
|
||||
slides: expanded as unknown[],
|
||||
slides: rescued as unknown[],
|
||||
})
|
||||
|
||||
// Always inject formulas if missing (deterministic — never ship STEM without equations)
|
||||
@@ -630,7 +769,6 @@ Expand into full ExpandedSlide JSON (non-empty body).`,
|
||||
}
|
||||
|
||||
let gate = assertDeckHasSubstance(normalized, stemOpts)
|
||||
let mode = 'outline+per-slide-expand'
|
||||
|
||||
if (!gate.ok) {
|
||||
// Deterministic fill for empty body slides using outline + assets
|
||||
@@ -669,6 +807,30 @@ Expand into full ExpandedSlide JSON (non-empty body).`,
|
||||
|
||||
if (!normalized.theme) normalized.theme = theme
|
||||
|
||||
// GUARANTEE: if numbers were extracted but no chart survived the pipeline, force-inject one
|
||||
if (assets.hasNumbers && assets.numbers.length >= 2) {
|
||||
const hasChart = normalized.slides.some((s) => s.type === 'chart')
|
||||
if (!hasChart) {
|
||||
const chartSlide: Record<string, unknown> = {
|
||||
type: 'chart',
|
||||
title: lang === 'fr' ? 'Données clés' : 'Key data',
|
||||
chartType: pickChartType(assets.numbers.length),
|
||||
data: assets.numbers.slice(0, 6).map((n) => ({
|
||||
label: (n.label.slice(0, 20) || n.raw),
|
||||
value: n.value,
|
||||
})),
|
||||
}
|
||||
const summaryIdx = normalized.slides.findIndex((s) => s.type === 'summary')
|
||||
if (summaryIdx >= 0) {
|
||||
normalized.slides.splice(summaryIdx, 0, chartSlide)
|
||||
} else if (normalized.slides.length < 8) {
|
||||
normalized.slides.push(chartSlide)
|
||||
} else {
|
||||
normalized.slides[normalized.slides.length - 2] = chartSlide
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const canvas = await persist(params.userId, normalized, params.actionId, mode)
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -4,24 +4,55 @@
|
||||
* Critical for STEM notes: formulas must be harvested, not hoped for from the model.
|
||||
*/
|
||||
|
||||
import { extractPublishImageUrls } from '@/lib/publish/process-note-html'
|
||||
|
||||
export interface SourceAssets {
|
||||
formulas: string[]
|
||||
numbers: Array<{ label: string; value: number; raw: string }>
|
||||
keySentences: string[]
|
||||
hasMath: boolean
|
||||
hasNumbers: boolean
|
||||
hasImages: boolean
|
||||
wordCount: number
|
||||
images: string[]
|
||||
}
|
||||
|
||||
/** Extract LaTeX / equation-like fragments from note plain text or HTML. */
|
||||
export function extractFormulas(raw: string): string[] {
|
||||
if (!raw) return []
|
||||
const found: string[] = []
|
||||
// 3+ consecutive plain words at brace depth 0 = prose tail captured after a
|
||||
// formula (KaTeX eats spaces in math mode → must never reach $…$).
|
||||
const PROSE_TAIL_RE =
|
||||
/(?:[.!?]?\s+)[A-ZÀ-ÖØ-Þ]?[a-zA-ZÀ-ÿ'’]{2,}\s+[a-zA-ZÀ-ÿ'’]{2,}\s+[a-zA-ZÀ-ÿ'’]{2,}[\s\S]*$/g
|
||||
const braceDepth = (s: string): number => {
|
||||
let d = 0
|
||||
for (const ch of s) {
|
||||
if (ch === '{') d++
|
||||
else if (ch === '}') d = Math.max(0, d - 1)
|
||||
}
|
||||
return d
|
||||
}
|
||||
const push = (s: string) => {
|
||||
const t = s.replace(/\s+/g, ' ').trim()
|
||||
let t = s.replace(/\s+/g, ' ').trim()
|
||||
t = t.replace(/^\$+|\$+$/g, '').trim()
|
||||
PROSE_TAIL_RE.lastIndex = 0
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = PROSE_TAIL_RE.exec(t)) !== null) {
|
||||
if (braceDepth(t.slice(0, m.index)) === 0) {
|
||||
t = t.slice(0, m.index).trim()
|
||||
break
|
||||
}
|
||||
}
|
||||
t = t.replace(/^\$+|\$+$/g, '').trim()
|
||||
if (t.length >= 2 && t.length <= 280 && !found.includes(t)) found.push(t)
|
||||
}
|
||||
|
||||
// TipTap / published HTML: data-latex="..."
|
||||
for (const m of raw.matchAll(/data-latex=["']([^"']+)["']/gi)) {
|
||||
push(decodeHtmlEntities(m[1] || ''))
|
||||
}
|
||||
|
||||
// $$ ... $$
|
||||
for (const m of raw.matchAll(/\$\$([\s\S]+?)\$\$/g)) push(m[1] || '')
|
||||
// \[ ... \]
|
||||
@@ -47,20 +78,34 @@ export function extractFormulas(raw: string): string[] {
|
||||
return found.slice(0, 24)
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(s: string): string {
|
||||
return s
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
}
|
||||
|
||||
export function extractNumbers(raw: string): Array<{ label: string; value: number; raw: string }> {
|
||||
if (!raw) return []
|
||||
// Normalize Persian (۰-۹) and Arabic (٠-٩) numerals to Western (0-9)
|
||||
const text = raw
|
||||
.replace(/[\u06F0-\u06F9]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0x06f0 + 0x30))
|
||||
.replace(/[\u0660-\u0669]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0x0660 + 0x30))
|
||||
|
||||
const out: Array<{ label: string; value: number; raw: string }> = []
|
||||
const re =
|
||||
/(?:^|[^\d])((?:≈|~)?\s*-?\d+(?:[.,]\d+)?)\s*(%|€|\$|k|m|bn|ms|s|°C)?\b/gi
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = re.exec(raw)) !== null && out.length < 20) {
|
||||
while ((m = re.exec(text)) !== null && out.length < 20) {
|
||||
const numStr = (m[1] || '').replace(/[≈~\s]/g, '').replace(',', '.')
|
||||
const value = parseFloat(numStr)
|
||||
if (!Number.isFinite(value)) continue
|
||||
const unit = m[2] || ''
|
||||
// crude label: 40 chars before
|
||||
const start = Math.max(0, m.index - 40)
|
||||
const ctx = raw.slice(start, m.index).replace(/\s+/g, ' ').trim()
|
||||
const ctx = text.slice(start, m.index).replace(/\s+/g, ' ').trim()
|
||||
const label = ctx.split(/[.;:!?\n]/).pop()?.trim().slice(-32) || `n${out.length + 1}`
|
||||
out.push({ label, value: unit === '%' ? value : value, raw: `${numStr}${unit}` })
|
||||
}
|
||||
@@ -88,16 +133,20 @@ export function extractKeySentences(raw: string, max = 16): string[] {
|
||||
}
|
||||
|
||||
export function extractSourceAssets(raw: string): SourceAssets {
|
||||
const formulas = extractFormulas(raw)
|
||||
const numbers = extractNumbers(raw)
|
||||
const plain = raw.replace(/<[^>]+>/g, ' ')
|
||||
const formulas = extractFormulas(plain)
|
||||
const numbers = extractNumbers(plain)
|
||||
const keySentences = extractKeySentences(raw)
|
||||
const wordCount = raw.replace(/<[^>]+>/g, ' ').split(/\s+/).filter(Boolean).length
|
||||
const images = extractPublishImageUrls(raw).slice(0, 8)
|
||||
const wordCount = plain.split(/\s+/).filter(Boolean).length
|
||||
return {
|
||||
formulas,
|
||||
numbers,
|
||||
keySentences,
|
||||
hasMath: formulas.length > 0 || /équat|equat|différen|differen|dériv|deriv|intégr|integr|EDO|ODE|PDE|latex/i.test(raw),
|
||||
hasMath: formulas.length > 0 || /équat|equat|différen|differen|dériv|deriv|intégr|integr|EDO|ODE|PDE|latex/i.test(plain),
|
||||
hasNumbers: numbers.length >= 2,
|
||||
hasImages: images.length > 0,
|
||||
wordCount,
|
||||
images,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,13 +277,13 @@ function renderBarChart(data: { label: string; value: number }[], r: Recipe): st
|
||||
const max = Math.max(...data.map(d => d.value), 1)
|
||||
const bars = data.map(d => {
|
||||
const pct = Math.round((d.value / max) * 100)
|
||||
return `<div style="display:flex;flex-direction:column;align-items:center;gap:6px;flex:1;min-width:0;">
|
||||
return `<div style="display:flex;flex-direction:column;justify-content:flex-end;align-items:center;gap:6px;flex:1;min-width:0;height:100%;">
|
||||
<span style="font-size:0.75rem;font-weight:700;color:${r.textSecondary};">${d.value}</span>
|
||||
<div class="bar" data-height="${pct}" style="background:linear-gradient(to top,${r.accent1},${r.accent2});height:0%;width:100%;border-radius:6px 6px 0 0;"></div>
|
||||
<div class="bar" data-height="${pct}" style="background:linear-gradient(to top,${r.accent1},${r.accent2});height:0%;width:80%;border-radius:6px 6px 0 0;"></div>
|
||||
<span style="font-size:0.7rem;color:${r.textMuted};text-align:center;max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(d.label)}</span>
|
||||
</div>`
|
||||
}).join('')
|
||||
return `<div style="display:flex;align-items:flex-end;gap:12px;height:200px;">${bars}</div>`
|
||||
return `<div style="display:flex;align-items:flex-end;gap:12px;height:220px;">${bars}</div>`
|
||||
}
|
||||
|
||||
function renderHBarChart(data: { label: string; value: number }[], r: Recipe): string {
|
||||
@@ -325,7 +325,7 @@ function renderLineChart(data: { label: string; value: number }[], r: Recipe): s
|
||||
return `<text x="${x}" y="${h - 15}" text-anchor="middle" font-size="10" fill="${r.textMuted}">${esc(d.label)}</text>`
|
||||
}).join('')
|
||||
return `<svg viewBox="0 0 ${w} ${h}" style="width:100%;height:auto;">
|
||||
<defs><linearGradient id="lg-${Math.random().toString(36).slice(2, 6)}" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="${r.accent1}" stop-opacity="0.25"/><stop offset="100%" stop-color="${r.accent1}" stop-opacity="0"/></linearGradient></defs>
|
||||
<defs><linearGradient id="lg-area" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="${r.accent1}" stop-opacity="0.25"/><stop offset="100%" stop-color="${r.accent1}" stop-opacity="0"/></linearGradient></defs>
|
||||
${gridLines}
|
||||
<path fill="url(#lg-area)" d="${areaD}" opacity="0.4"/>
|
||||
<path class="line-path" d="${pathD}" stroke="${r.accent1}" fill="none" stroke-width="2.5" stroke-linecap="round"/>
|
||||
@@ -486,7 +486,9 @@ function updateNav(){document.querySelectorAll('.nav-dot').forEach(function(d,i)
|
||||
document.addEventListener('keydown',function(e){if(e.key==='ArrowRight'||e.key===' ')changeSlide(1);if(e.key==='ArrowLeft')changeSlide(-1);});
|
||||
var tx=0;document.addEventListener('touchstart',function(e){tx=e.touches[0].clientX;},{passive:true});document.addEventListener('touchend',function(e){var dx=tx-e.changedTouches[0].clientX;if(Math.abs(dx)>50)changeSlide(dx>0?1:-1);},{passive:true});
|
||||
function animateSlide(s){s.querySelectorAll('.reveal').forEach(function(el,i){el.style.transition='none';el.style.opacity='0';el.style.transform='translateY(18px)';el.offsetHeight;el.style.transition='opacity 0.35s ease '+(i*0.07)+'s, transform 0.35s ease '+(i*0.07)+'s';el.style.opacity='1';el.style.transform='translateY(0)';});s.querySelectorAll('.bar[data-height]').forEach(function(b){b.style.height='0%';setTimeout(function(){b.style.height=b.dataset.height+'%';},100);});s.querySelectorAll('.bar-fill[data-width]').forEach(function(b){b.style.width='0%';setTimeout(function(){b.style.width=b.dataset.width+'%';},100);});s.querySelectorAll('.line-path').forEach(function(p){var l=p.getTotalLength?p.getTotalLength():2000;p.style.strokeDasharray=l;p.style.strokeDashoffset=l;setTimeout(function(){p.style.strokeDashoffset='0';},100);});s.querySelectorAll('[data-count]').forEach(function(el){var t=parseFloat(el.dataset.count),sf=el.dataset.suffix||'',st=30,inc=t/st,i=0,v=0;var iv=setInterval(function(){v+=inc;i++;el.textContent=(i>=st?t:Math.round(v))+sf;if(i>=st)clearInterval(iv);},30);});}
|
||||
var first=document.querySelector('.slide[data-slide="1"]');if(first){first.classList.add('active');setTimeout(function(){animateSlide(first);renderKatex();},300);}else{setTimeout(renderKatex,400);}
|
||||
var first=document.querySelector('.slide[data-slide="1"]');if(first){first.classList.add('active');setTimeout(function(){animateSlide(first);renderKatex();},300);}
|
||||
var katexRetries=0;function ensureKatex(){if(typeof katex!=='undefined'){renderKatex();}else if(katexRetries<8){katexRetries++;setTimeout(ensureKatex,500);}}
|
||||
setTimeout(ensureKatex,400);
|
||||
// Particles
|
||||
document.querySelectorAll('canvas[id^="particles-"]').forEach(function(c){c.width=window.innerWidth;c.height=window.innerHeight;var ctx=c.getContext('2d'),pts=[];for(var i=0;i<50;i++)pts.push({x:Math.random()*c.width,y:Math.random()*c.height,vx:(Math.random()-0.5)*0.3,vy:(Math.random()-0.5)*0.3,r:Math.random()*2+0.5});function draw(){ctx.clearRect(0,0,c.width,c.height);pts.forEach(function(p){p.x+=p.vx;p.y+=p.vy;if(p.x<0)p.x=c.width;if(p.x>c.width)p.x=0;if(p.y<0)p.y=c.height;if(p.y>c.height)p.y=0;ctx.beginPath();ctx.arc(p.x,p.y,p.r,0,Math.PI*2);ctx.fillStyle='${r.accent1}80';ctx.fill();});requestAnimationFrame(draw);}draw();});
|
||||
</script>
|
||||
|
||||
@@ -55,17 +55,25 @@ export function buildAuthProviders() {
|
||||
|
||||
const passwordsMatch = await bcrypt.compare(password, user.password);
|
||||
|
||||
if (passwordsMatch) {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
};
|
||||
if (!passwordsMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
// Password accounts must confirm email (Google OAuth sets emailVerified).
|
||||
if (!user.emailVerified) {
|
||||
throw new Error('EMAIL_NOT_VERIFIED');
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === 'EMAIL_NOT_VERIFIED') {
|
||||
throw err;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
133
memento-note/lib/auth/email-verification.ts
Normal file
133
memento-note/lib/auth/email-verification.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
import { sendEmail } from '@/lib/mail'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { getEmailTemplate } from '@/lib/email-template'
|
||||
|
||||
const VERIFY_PREFIX = 'email-verify:'
|
||||
const TOKEN_TTL_MS = 24 * 60 * 60 * 1000 // 24h
|
||||
|
||||
export function generateVerificationToken(): string {
|
||||
const array = new Uint8Array(32)
|
||||
globalThis.crypto.getRandomValues(array)
|
||||
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
function identifierForEmail(email: string): string {
|
||||
return `${VERIFY_PREFIX}${email.toLowerCase()}`
|
||||
}
|
||||
|
||||
export async function createEmailVerificationToken(email: string): Promise<string> {
|
||||
const normalized = email.toLowerCase()
|
||||
const identifier = identifierForEmail(normalized)
|
||||
const token = generateVerificationToken()
|
||||
const expires = new Date(Date.now() + TOKEN_TTL_MS)
|
||||
|
||||
// Replace any pending tokens for this email
|
||||
await prisma.verificationToken.deleteMany({ where: { identifier } })
|
||||
await prisma.verificationToken.create({
|
||||
data: { identifier, token, expires },
|
||||
})
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
export async function sendVerificationEmail(opts: {
|
||||
email: string
|
||||
name?: string | null
|
||||
locale?: string
|
||||
}): Promise<{ success: boolean; error?: string }> {
|
||||
const token = await createEmailVerificationToken(opts.email)
|
||||
const baseUrl = (process.env.NEXTAUTH_URL || '').replace(/\/$/, '')
|
||||
const verifyLink = `${baseUrl}/verify-email?token=${token}`
|
||||
|
||||
const isFr = (opts.locale ?? '').toLowerCase().startsWith('fr')
|
||||
const greet = opts.name?.trim()
|
||||
? opts.name.trim()
|
||||
: isFr
|
||||
? 'Bonjour'
|
||||
: 'Hi'
|
||||
|
||||
const title = isFr ? 'Confirmez votre adresse e-mail' : 'Confirm your email address'
|
||||
const body = isFr
|
||||
? `<p>${greet},</p><p>Merci de vous être inscrit sur Memento. Cliquez sur le bouton ci-dessous pour activer votre compte. Ce lien est valable 24 heures.</p>`
|
||||
: `<p>${greet},</p><p>Thanks for signing up for Memento. Click the button below to activate your account. This link is valid for 24 hours.</p>`
|
||||
const cta = isFr ? 'Confirmer mon e-mail' : 'Confirm my email'
|
||||
const subject = isFr
|
||||
? 'Confirmez votre compte Memento'
|
||||
: 'Confirm your Memento account'
|
||||
|
||||
const html = getEmailTemplate(title, body, verifyLink, cta)
|
||||
const sysConfig = await getSystemConfig()
|
||||
const emailProvider = (sysConfig.EMAIL_PROVIDER || 'auto') as 'resend' | 'smtp' | 'auto'
|
||||
|
||||
return sendEmail({ to: opts.email.toLowerCase(), subject, html }, emailProvider)
|
||||
}
|
||||
|
||||
export async function verifyEmailToken(
|
||||
token: string,
|
||||
): Promise<{ success: true } | { success: false; error: 'invalid' | 'expired' }> {
|
||||
if (!token) return { success: false, error: 'invalid' }
|
||||
|
||||
const record = await prisma.verificationToken.findFirst({
|
||||
where: { token },
|
||||
})
|
||||
|
||||
if (!record || !record.identifier.startsWith(VERIFY_PREFIX)) {
|
||||
return { success: false, error: 'invalid' }
|
||||
}
|
||||
|
||||
if (record.expires < new Date()) {
|
||||
await prisma.verificationToken.deleteMany({
|
||||
where: { identifier: record.identifier },
|
||||
})
|
||||
return { success: false, error: 'expired' }
|
||||
}
|
||||
|
||||
const email = record.identifier.slice(VERIFY_PREFIX.length)
|
||||
const user = await prisma.user.findUnique({ where: { email } })
|
||||
if (!user) {
|
||||
return { success: false, error: 'invalid' }
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { emailVerified: new Date() },
|
||||
}),
|
||||
prisma.verificationToken.deleteMany({
|
||||
where: { identifier: record.identifier },
|
||||
}),
|
||||
])
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend verification for an unverified password account.
|
||||
* Always returns success to avoid email enumeration when the address is unknown.
|
||||
*/
|
||||
export async function resendVerificationEmail(
|
||||
email: string,
|
||||
locale?: string,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const normalized = email.toLowerCase().trim()
|
||||
if (!normalized) return { error: 'missing_email', success: false }
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email: normalized } })
|
||||
if (!user || !user.password) {
|
||||
return { success: true }
|
||||
}
|
||||
if (user.emailVerified) {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
const result = await sendVerificationEmail({
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
locale,
|
||||
})
|
||||
if (!result.success) {
|
||||
return { success: false, error: 'send_failed' }
|
||||
}
|
||||
return { success: true }
|
||||
}
|
||||
2
memento-note/lib/billing/trial-constants.ts
Normal file
2
memento-note/lib/billing/trial-constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
/** Free trial length on first Pro / Business checkout. Safe for client imports. */
|
||||
export const SUBSCRIPTION_TRIAL_DAYS = 7
|
||||
55
memento-note/lib/billing/trial-reminder-email.ts
Normal file
55
memento-note/lib/billing/trial-reminder-email.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { sendEmail } from '@/lib/mail'
|
||||
|
||||
function formatTrialEnd(date: Date, locale: string): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}).format(date)
|
||||
} catch {
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort reminder ~3 days before Stripe ends a trial.
|
||||
* Failures are logged by the caller; never throw to the webhook.
|
||||
*/
|
||||
export async function sendTrialEndingReminder(opts: {
|
||||
to: string
|
||||
name?: string | null
|
||||
trialEndsAt: Date
|
||||
billingUrl: string
|
||||
locale?: string
|
||||
}): Promise<{ success: boolean; error?: string }> {
|
||||
const locale = opts.locale?.startsWith('fr') ? 'fr' : 'en'
|
||||
const endLabel = formatTrialEnd(opts.trialEndsAt, locale === 'fr' ? 'fr-FR' : 'en-US')
|
||||
const greet = opts.name?.trim() ? opts.name.trim() : locale === 'fr' ? 'Bonjour' : 'Hi'
|
||||
|
||||
const subject =
|
||||
locale === 'fr'
|
||||
? 'Votre essai Memento se termine bientôt'
|
||||
: 'Your Memento trial is ending soon'
|
||||
|
||||
const html =
|
||||
locale === 'fr'
|
||||
? `
|
||||
<p>${greet},</p>
|
||||
<p>Votre période d'essai Memento se termine le <strong>${endLabel}</strong>.</p>
|
||||
<p>Après cette date, votre abonnement démarrera automatiquement avec le moyen de paiement enregistré.</p>
|
||||
<p>Pour gérer votre abonnement ou votre carte :</p>
|
||||
<p><a href="${opts.billingUrl}">Ouvrir la facturation</a></p>
|
||||
<p>— L'équipe Memento</p>
|
||||
`
|
||||
: `
|
||||
<p>${greet},</p>
|
||||
<p>Your Memento trial ends on <strong>${endLabel}</strong>.</p>
|
||||
<p>After that date, your subscription will start automatically using the payment method on file.</p>
|
||||
<p>To manage your subscription or card:</p>
|
||||
<p><a href="${opts.billingUrl}">Open billing settings</a></p>
|
||||
<p>— The Memento team</p>
|
||||
`
|
||||
|
||||
return sendEmail({ to: opts.to, subject, html })
|
||||
}
|
||||
34
memento-note/lib/billing/trial.ts
Normal file
34
memento-note/lib/billing/trial.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export { SUBSCRIPTION_TRIAL_DAYS } from '@/lib/billing/trial-constants'
|
||||
|
||||
/**
|
||||
* Offer a trial only to users who never held a real Stripe subscription.
|
||||
* Users with cus_mock / price_mock leftovers from local tests are still eligible
|
||||
* if they never had a non-mock stripeSubscriptionId.
|
||||
*/
|
||||
export async function shouldOfferSubscriptionTrial(userId: string): Promise<boolean> {
|
||||
const sub = await prisma.subscription.findUnique({
|
||||
where: { userId },
|
||||
select: {
|
||||
stripeSubscriptionId: true,
|
||||
tier: true,
|
||||
status: true,
|
||||
trialEndsAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!sub) return true
|
||||
|
||||
const stripeSubId = sub.stripeSubscriptionId
|
||||
if (stripeSubId && !stripeSubId.startsWith('sub_mock')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Already on a paid / trial tier locally without a Stripe id (admin override)
|
||||
if (sub.tier !== 'BASIC' && (sub.status === 'ACTIVE' || sub.status === 'TRIALING')) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export const DASHBOARD_LAYOUT_VERSION = 6 as const
|
||||
export const DASHBOARD_LAYOUT_VERSION = 7 as const
|
||||
|
||||
/** Widgets visibles dans la mise en page par défaut (réf. prototype / capture utilisateur). */
|
||||
export const CANONICAL_VISIBLE_WIDGET_IDS: readonly DashboardWidgetId[] = [
|
||||
@@ -10,8 +10,6 @@ export const CANONICAL_VISIBLE_WIDGET_IDS: readonly DashboardWidgetId[] = [
|
||||
'mind-map',
|
||||
'sentiment',
|
||||
'inbox',
|
||||
'revision',
|
||||
'stats',
|
||||
'reminders',
|
||||
'flashcards-progress',
|
||||
'agents',
|
||||
@@ -115,13 +113,13 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = {
|
||||
// Colonne latérale (droite) — cartes compactes + widgets IA
|
||||
{ id: 'sentiment', visible: true, order: 6, zone: 'side' },
|
||||
{ id: 'inbox', visible: true, order: 7, zone: 'side' },
|
||||
{ id: 'revision', visible: true, order: 8, zone: 'side' },
|
||||
{ id: 'stats', visible: true, order: 9, zone: 'side' },
|
||||
{ id: 'reminders', visible: true, order: 10, zone: 'side' },
|
||||
{ id: 'flashcards-progress', visible: true, order: 11, zone: 'side' },
|
||||
{ id: 'agents', visible: true, order: 12, zone: 'side' },
|
||||
{ id: 'pinned', visible: true, order: 13, zone: 'side' },
|
||||
// Catalogue — masqués par défaut, ajoutables via « Personnaliser »
|
||||
{ id: 'reminders', visible: true, order: 8, zone: 'side' },
|
||||
{ id: 'flashcards-progress', visible: true, order: 9, zone: 'side' },
|
||||
{ id: 'agents', visible: true, order: 10, zone: 'side' },
|
||||
{ id: 'pinned', visible: true, order: 11, zone: 'side' },
|
||||
// Catalogue — masqués par défaut (chiffres déjà dans le bandeau du haut)
|
||||
{ id: 'revision', visible: false, order: 12, zone: 'side' },
|
||||
{ id: 'stats', visible: false, order: 13, zone: 'side' },
|
||||
{ id: 'daily-review', visible: false, order: 14, zone: 'side' },
|
||||
{ id: 'agent-activity', visible: false, order: 15, zone: 'side' },
|
||||
{ id: 'gmail', visible: false, order: 16, zone: 'side' },
|
||||
|
||||
@@ -14,9 +14,9 @@ export function getEmailTemplate(title: string, content: string, actionLink?: st
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div className="container">
|
||||
<div className="header">
|
||||
<a href="${process.env.NEXTAUTH_URL}" className="logo">
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<a href="${process.env.NEXTAUTH_URL || 'https://memento-note.com'}" class="logo">
|
||||
📒 Memento
|
||||
</a>
|
||||
</div>
|
||||
@@ -24,8 +24,8 @@ export function getEmailTemplate(title: string, content: string, actionLink?: st
|
||||
<div>
|
||||
${content}
|
||||
</div>
|
||||
${actionLink ? `<div style="text-align: center;"><a href="${actionLink}" className="button">${actionText || 'Click here'}</a></div>` : ''}
|
||||
<div className="footer">
|
||||
${actionLink ? `<div style="text-align: center;"><a href="${actionLink}" class="button">${actionText || 'Click here'}</a></div>` : ''}
|
||||
<div class="footer">
|
||||
<p>This email was sent from your Memento instance.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,11 +50,16 @@ export function LanguageProvider({ children, initialLanguage = 'en', initialTran
|
||||
|
||||
const isFirstRender = useRef(true)
|
||||
|
||||
// Load saved preference from localStorage AFTER hydration
|
||||
// Load saved preference from cookie only (explicit picker). localStorage
|
||||
// without cookie used to override note-based detection with a stale 'en'.
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('user-language') as SupportedLanguage
|
||||
if (saved && SUPPORTED_LANGS.includes(saved) && saved !== initialLanguage) {
|
||||
setLanguageState(saved)
|
||||
const cookie = document.cookie
|
||||
.split(';')
|
||||
.map(s => s.trim())
|
||||
.find(s => s.startsWith('user-language='))
|
||||
?.split('=')[1] as SupportedLanguage | undefined
|
||||
if (cookie && SUPPORTED_LANGS.includes(cookie) && cookie !== initialLanguage) {
|
||||
setLanguageState(cookie)
|
||||
}
|
||||
}, [initialLanguage])
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ const svgEdgeSchema = z.object({
|
||||
from: z.string().min(1),
|
||||
to: z.string().min(1),
|
||||
style: z.enum(['solid', 'dashed']).optional(),
|
||||
weight: z.number().optional(),
|
||||
weight: z.number().min(0).max(5).optional(),
|
||||
intent: intentSchema,
|
||||
})
|
||||
|
||||
@@ -73,8 +73,8 @@ const chartPayloadSchema = z.object({
|
||||
})
|
||||
|
||||
const heatmapPayloadSchema = z.object({
|
||||
rows: z.number().int().positive(),
|
||||
cols: z.number().int().positive(),
|
||||
rows: z.number().int().positive().max(50),
|
||||
cols: z.number().int().positive().max(50),
|
||||
values: z.array(z.array(z.number())),
|
||||
triangular: z.enum(['lower', 'upper', 'none']).optional(),
|
||||
rowLabels: z.array(z.string()).optional(),
|
||||
|
||||
@@ -8,7 +8,7 @@ export const INTERACTIVE_PAGE_CAPS = {
|
||||
maxDemosPerPage: 5,
|
||||
maxSimsPerPage: 3,
|
||||
maxOverviewCards: 4,
|
||||
minOverviewCards: 2,
|
||||
minOverviewCards: 3,
|
||||
maxStatsItems: 5,
|
||||
minStatsItems: 2,
|
||||
maxJsonBytes: 128 * 1024,
|
||||
@@ -30,8 +30,14 @@ export const PAGE_BLOCK_TYPES = [
|
||||
'table',
|
||||
'image',
|
||||
'sim',
|
||||
'steps',
|
||||
] as const
|
||||
|
||||
export const STEPS_CAPS = {
|
||||
minSteps: 2,
|
||||
maxSteps: 12,
|
||||
} as const
|
||||
|
||||
export const CALLOUT_KINDS = [
|
||||
'definition',
|
||||
'warning',
|
||||
@@ -69,6 +75,8 @@ export const PAGE_HUMAN_STRING_KEYS = [
|
||||
'intro',
|
||||
'xLabel',
|
||||
'yLabel',
|
||||
// steps blocks
|
||||
'rule',
|
||||
// inherited from demos (speak etc. scanned via demo validator)
|
||||
'speak',
|
||||
'text',
|
||||
|
||||
@@ -63,6 +63,33 @@
|
||||
"simId": "ts-diagram"
|
||||
},
|
||||
"caption": "Le même cycle sur le diagramme T–s : les aires sont les chaleurs échangées."
|
||||
},
|
||||
{
|
||||
"type": "steps",
|
||||
"title": "Le COP frigorifique, dérivé pas à pas",
|
||||
"steps": [
|
||||
{
|
||||
"tex": "\\eta_{\\text{Carnot}} = 1 - \\frac{T_c}{T_h}",
|
||||
"rule": "Point de départ — rendement de Carnot",
|
||||
"speak": "Le rendement maximal d'un moteur entre $T_h$ et $T_c$ ne dépend que des températures."
|
||||
},
|
||||
{
|
||||
"tex": "\\mathrm{COP}_{\\text{PAC}} = \\frac{1}{\\eta_{\\text{Carnot}}} = \\frac{T_h}{T_h - T_c}",
|
||||
"rule": "Inversion — pompe à chaleur",
|
||||
"speak": "La pompe à chaleur est l'inverse du moteur : son COP est l'inverse du rendement."
|
||||
},
|
||||
{
|
||||
"tex": "\\mathrm{COP}_{\\text{frigo}} = \\mathrm{COP}_{\\text{PAC}} - 1 = \\frac{T_c}{T_h - T_c}",
|
||||
"rule": "Soustraction de 1 — réfrigérateur",
|
||||
"speak": "Le frigo ne compte que la chaleur utile $Q_c$ : on retire 1 au COP de la PAC."
|
||||
},
|
||||
{
|
||||
"tex": "\\mathrm{COP}_{\\text{frigo}} = \\frac{260}{300 - 260} = 6{,}5",
|
||||
"rule": "Application numérique",
|
||||
"speak": "Avec $T_c = 260$ K et $T_h = 300$ K : le COP maximal vaut 6,5."
|
||||
}
|
||||
],
|
||||
"caption": "Chaque ligne découle de la précédente — la règle appliquée est en marge."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ export {
|
||||
PAGE_BLOCK_TYPES,
|
||||
CALLOUT_KINDS,
|
||||
PAGE_HUMAN_STRING_KEYS,
|
||||
STEPS_CAPS,
|
||||
isPageHumanStringKey,
|
||||
} from './constants'
|
||||
export { pageSpecV1Schema, pageBlockSchema } from './schema'
|
||||
@@ -22,4 +23,6 @@ export type {
|
||||
CatalogSimRef,
|
||||
GenericFormulaSim,
|
||||
SimBlock,
|
||||
StepsBlock,
|
||||
DerivationStep,
|
||||
} from './types'
|
||||
|
||||
@@ -189,6 +189,31 @@ function normalizeBlock(
|
||||
return out
|
||||
}
|
||||
|
||||
if (type === 'steps' || type === 'derivation' || type === 'walkthrough' || type === 'solution' || type === 'proof') {
|
||||
const rawSteps = Array.isArray(obj.steps) ? obj.steps : []
|
||||
const steps = rawSteps
|
||||
.map((st) => {
|
||||
const r = asRecord(st)
|
||||
if (!r) return null
|
||||
const tex = asString(r.tex) || asString(r.latex) || asString(r.equation) || asString(r.math)
|
||||
if (!tex) return null
|
||||
const out: Record<string, unknown> = { tex }
|
||||
const rule = asString(r.rule) || asString(r.transform) || asString(r.action) || asString(r.operation)
|
||||
const speak = asString(r.speak) || asString(r.note) || asString(r.comment)
|
||||
if (rule) out.rule = rule
|
||||
if (speak) out.speak = speak
|
||||
return out
|
||||
})
|
||||
.filter(Boolean)
|
||||
if (steps.length < 2) return null
|
||||
const out: Record<string, unknown> = { type: 'steps', steps }
|
||||
const title = asString(obj.title)
|
||||
if (title) out.title = title
|
||||
const caption = asString(obj.caption)
|
||||
if (caption) out.caption = caption
|
||||
return out
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
INTERACTIVE_PAGE_SCHEMA_VERSION,
|
||||
PAGE_BLOCK_TYPES,
|
||||
SIM_CAPS,
|
||||
STEPS_CAPS,
|
||||
} from './constants'
|
||||
import { isValidSimParamId } from './sim-eval'
|
||||
|
||||
const intentSchema = z.enum(INTENT_IDS).optional()
|
||||
|
||||
@@ -90,6 +92,9 @@ const imageBlock = z.object({
|
||||
const simParamIdSchema = z
|
||||
.string()
|
||||
.regex(/^[A-Za-z_][A-Za-z0-9_]*$/, 'Invalid sim identifier')
|
||||
.refine(isValidSimParamId, {
|
||||
message: 'Identifier collides with a reserved constant/function',
|
||||
})
|
||||
|
||||
const genericSimParamSchema = z.object({
|
||||
id: simParamIdSchema,
|
||||
@@ -150,6 +155,22 @@ const simBlock = z.object({
|
||||
caption: z.string().optional(),
|
||||
})
|
||||
|
||||
const stepsBlock = z.object({
|
||||
type: z.literal('steps'),
|
||||
title: z.string().optional(),
|
||||
steps: z
|
||||
.array(
|
||||
z.object({
|
||||
tex: z.string().min(1),
|
||||
rule: z.string().optional(),
|
||||
speak: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.min(STEPS_CAPS.minSteps)
|
||||
.max(STEPS_CAPS.maxSteps),
|
||||
caption: z.string().optional(),
|
||||
})
|
||||
|
||||
export const pageBlockSchema = z.discriminatedUnion('type', [
|
||||
proseBlock,
|
||||
formulaBlock,
|
||||
@@ -160,6 +181,7 @@ export const pageBlockSchema = z.discriminatedUnion('type', [
|
||||
tableBlock,
|
||||
imageBlock,
|
||||
simBlock,
|
||||
stepsBlock,
|
||||
])
|
||||
|
||||
const sectionSchema = z.object({
|
||||
@@ -199,7 +221,7 @@ export const pageSpecV1Schema = z.object({
|
||||
overview: overviewSchema.optional(),
|
||||
sections: z
|
||||
.array(sectionSchema)
|
||||
.min(1)
|
||||
.min(2)
|
||||
.max(INTERACTIVE_PAGE_CAPS.maxSections),
|
||||
footer: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -115,6 +115,27 @@ export type SimBlock = {
|
||||
caption?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Step-by-step derivation (Symbolab/Khan style): equation states revealed
|
||||
* line by line with the transformation rule used at each step. Fully
|
||||
* generic — the LLM writes KaTeX + rules, the app renders; no drawing.
|
||||
*/
|
||||
export type DerivationStep = {
|
||||
/** KaTeX of the equation state at this step. */
|
||||
tex: string
|
||||
/** Transformation rule applied to reach this state (e.g. "on sépare les variables"). */
|
||||
rule?: string
|
||||
/** Narration for the Play/Step player (falls back to rule). */
|
||||
speak?: string
|
||||
}
|
||||
|
||||
export type StepsBlock = {
|
||||
type: 'steps'
|
||||
title?: string
|
||||
steps: DerivationStep[]
|
||||
caption?: string
|
||||
}
|
||||
|
||||
export type PageBlock =
|
||||
| ProseBlock
|
||||
| FormulaBlock
|
||||
@@ -125,6 +146,7 @@ export type PageBlock =
|
||||
| TableBlock
|
||||
| ImageBlock
|
||||
| SimBlock
|
||||
| StepsBlock
|
||||
|
||||
export type PageSection = {
|
||||
id: string
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
isPageHumanStringKey,
|
||||
} from './constants'
|
||||
import { pageSpecV1Schema } from './schema'
|
||||
import { validateSimExprRefs } from './sim-eval'
|
||||
import { validateSimExprRefs, isValidSimParamId } from './sim-eval'
|
||||
import type {
|
||||
PageBlock,
|
||||
PageSpecV1,
|
||||
@@ -79,6 +79,13 @@ function validateSim(
|
||||
if (sim.simId === 'generic-formula') {
|
||||
const generic = sim as Extract<typeof sim, { simId: 'generic-formula' }>
|
||||
const paramIds = new Set(generic.params.map((p) => p.id))
|
||||
if (paramIds.size !== generic.params.length) {
|
||||
out.push(issue('sim_duplicate_id', `${path}.params`, 'Duplicate param id'))
|
||||
}
|
||||
const computedIds = new Set(generic.computed.map((c) => c.id))
|
||||
if (computedIds.size !== generic.computed.length) {
|
||||
out.push(issue('sim_duplicate_id', `${path}.computed`, 'Duplicate computed id'))
|
||||
}
|
||||
for (const p of generic.params) {
|
||||
if (p.min >= p.max) {
|
||||
out.push(issue('sim_param_range', `${path}.params`, `Param "${p.id}": min >= max`))
|
||||
|
||||
Reference in New Issue
Block a user