Files
Momento/memento-note/lib/ai/services/interactive-page-client.service.ts
Antigravity 69c99e4f4f 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>
2026-07-24 17:51:43 +00:00

212 lines
5.1 KiB
TypeScript

import type {
PageSection,
PageSpecV1,
PageValidationIssue,
} from '@/lib/interactive-page'
export type PagePlanDemoKind = 'svg-scene' | 'chart' | 'heatmap-matrix' | 'simulation' | 'none'
export type PagePlanSection = {
title: string
goal: string
demoKind: PagePlanDemoKind
demoGoal?: string
}
export type PagePlan = {
heroTitle: string
heroSubtitle?: string
overviewLead: string
overviewCards: {
badge: string
title: string
body: string
intent?: string
}[]
sections: PagePlanSection[]
}
export type GenerateInteractivePageResponse =
| { ok: true; page: PageSpecV1; attempts: number }
| {
ok: false
error: string
reason?: string
issues?: PageValidationIssue[]
quotaExceeded?: boolean
status?: number
}
export type GeneratePlanResponse =
| { ok: true; plan: PagePlan; attempts: number }
| {
ok: false
error: string
reason?: string
quotaExceeded?: boolean
status?: number
}
export type GenerateSectionResponse =
| { ok: true; section: PageSection; attempts: number }
| { ok: false; error: string; status?: number }
async function postJson(
body: Record<string, unknown>,
timeoutMs = 55_000
): Promise<{ res: Response; data: Record<string, never> } | { abortError: true }> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), timeoutMs)
try {
const res = await fetch('/api/ai/interactive-page', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal,
})
const data = await res.json().catch(() => ({}))
return { res, data }
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
return { abortError: true }
}
throw err
} finally {
clearTimeout(timeout)
}
}
/** LLM plan (billed — 20 crédits). */
export async function generateInteractivePagePlan(params: {
content: string
lang?: string
noteId?: string
}): Promise<GeneratePlanResponse> {
let out: Awaited<ReturnType<typeof postJson>>
try {
out = await postJson({ ...params, action: 'plan' })
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : 'Network error',
}
}
if ('abortError' in out) {
return { ok: false, error: 'Génération trop longue — réessaie' }
}
const { res, data } = out as {
res: Response
data: any
}
if (res.status === 402) {
return {
ok: false,
error: data.error || 'Quota exceeded',
quotaExceeded: true,
status: 402,
}
}
if (!res.ok) {
return {
ok: false,
error: data.error || `HTTP ${res.status}`,
reason: data.reason,
status: res.status,
}
}
return { ok: true, plan: data.plan, attempts: data.attempts ?? 1 }
}
/** LLM single section (not billed — page billed at plan time). */
export async function generateInteractivePageSection(params: {
content: string
lang?: string
noteId?: string
pageTitle: string
sectionId: string
section: PagePlanSection
}): Promise<GenerateSectionResponse> {
let out: Awaited<ReturnType<typeof postJson>>
try {
out = await postJson({ ...params, action: 'section' })
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : 'Network error',
}
}
if ('abortError' in out) {
return { ok: false, error: 'timeout' }
}
const { res, data } = out as {
res: Response
data: any
}
if (!res.ok) {
return { ok: false, error: data.error || `HTTP ${res.status}`, status: res.status }
}
return { ok: true, section: data.section, attempts: data.attempts ?? 1 }
}
/** Legacy deterministic full page (fallback, no quota). */
export async function generateInteractivePage(params: {
content: string
lang?: string
noteId?: string
notebookId?: string
}): Promise<GenerateInteractivePageResponse> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 75_000)
let res: Response
try {
res = await fetch('/api/ai/interactive-page', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
signal: controller.signal,
})
} catch (err) {
clearTimeout(timeout)
const aborted = err instanceof Error && err.name === 'AbortError'
return {
ok: false,
error: aborted
? 'Génération trop longue — réessaie'
: err instanceof Error
? err.message
: 'Network error',
}
} finally {
clearTimeout(timeout)
}
const data = await res.json().catch(() => ({}))
if (res.status === 402) {
return {
ok: false,
error: data.error || 'Quota exceeded',
quotaExceeded: true,
status: 402,
}
}
if (!res.ok) {
return {
ok: false,
error: data.error || `HTTP ${res.status}`,
reason: data.reason,
issues: data.issues,
status: res.status,
}
}
return {
ok: true,
page: data.page,
attempts: data.attempts ?? 1,
}
}