feat(credits): solde IA unique (option M), packs Stripe et polish billing
Un pot de crédits global (BASIC 100 / PRO 1 000 / BUSINESS 4 000) remplace les seaux par fonction côté débit. Packs one-shot S/M/L, admin sans seaux legacy, usage multi-features, hints de coût, anti-flash thème et hydratation admin. Inclut aussi le pipeline slides renforcé et la page publique polishée.
This commit is contained in:
@@ -5,100 +5,193 @@ import { z } from 'zod'
|
||||
import { toolRegistry } from './registry'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { buildPresentationHTML } from './slides-html-builder'
|
||||
import { normalizeSlideDeck, SLIDE_HARD_CAP } from '@/lib/ai/services/slide-content-quality'
|
||||
|
||||
const notesField = z.string().optional().describe('Optional speaker notes (1–2 sentences for the presenter, not shown on slide)')
|
||||
|
||||
const slideSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('title'), title: z.string(), subtitle: z.string().optional() }),
|
||||
z.object({ type: z.literal('bullets'), title: z.string(), items: z.array(z.string()) }),
|
||||
z.object({ type: z.literal('chart'), title: z.string(), chartType: z.enum(['bar', 'horizontal-bar', 'line', 'donut', 'radar']), data: z.array(z.object({ label: z.string(), value: z.number() })), subtitle: z.string().optional() }),
|
||||
z.object({ type: z.literal('stats'), title: z.string(), stats: z.array(z.object({ value: z.string(), label: z.string() })) }),
|
||||
z.object({ type: z.literal('table'), title: z.string(), headers: z.array(z.string()), rows: z.array(z.array(z.string())) }),
|
||||
z.object({ type: z.literal('cards'), title: z.string(), cards: z.array(z.object({ title: z.string(), description: z.string() })) }),
|
||||
z.object({ type: z.literal('timeline'), title: z.string(), events: z.array(z.object({ date: z.string(), title: z.string(), description: z.string().optional() })) }),
|
||||
z.object({ type: z.literal('quote'), quote: z.string(), author: z.string().optional(), context: z.string().optional() }),
|
||||
z.object({ type: z.literal('comparison'), title: z.string(), left: z.object({ title: z.string(), points: z.array(z.string()), score: z.string().optional() }), right: z.object({ title: z.string(), points: z.array(z.string()), score: z.string().optional() }) }),
|
||||
z.object({ type: z.literal('equation'), title: z.string(), equations: z.array(z.object({ latex: z.string(), label: z.string().optional() })), explanation: z.string().optional() }),
|
||||
z.object({ type: z.literal('image'), title: z.string(), url: z.string().optional(), caption: z.string().optional() }),
|
||||
z.object({ type: z.literal('summary'), title: z.string(), items: z.array(z.string()) }),
|
||||
z.object({
|
||||
type: z.literal('title'),
|
||||
title: z.string().describe('Action title / deck name'),
|
||||
subtitle: z.string().optional().describe('One-line context or audience promise'),
|
||||
notes: notesField,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('bullets'),
|
||||
title: z.string().describe('Action title = the insight, not a topic label'),
|
||||
items: z.array(z.string()).max(5).describe('3–5 short claims (8–18 words), each distinct, from the note'),
|
||||
notes: notesField,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('chart'),
|
||||
title: z.string().describe('Action title stating what the chart proves'),
|
||||
chartType: z.enum(['bar', 'horizontal-bar', 'line', 'donut', 'radar']),
|
||||
data: z.array(z.object({ label: z.string(), value: z.number() })).min(2).max(8),
|
||||
subtitle: z.string().optional(),
|
||||
notes: notesField,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('stats'),
|
||||
title: z.string(),
|
||||
stats: z.array(z.object({ value: z.string(), label: z.string() })).min(2).max(4),
|
||||
notes: notesField,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('table'),
|
||||
title: z.string(),
|
||||
headers: z.array(z.string()).max(6),
|
||||
rows: z.array(z.array(z.string())).max(8),
|
||||
notes: notesField,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('cards'),
|
||||
title: z.string(),
|
||||
cards: z
|
||||
.array(z.object({ title: z.string(), description: z.string() }))
|
||||
.min(2)
|
||||
.max(6)
|
||||
.describe('2–4 cards preferred; short titles + 1–2 sentence descriptions'),
|
||||
notes: notesField,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('timeline'),
|
||||
title: z.string(),
|
||||
events: z
|
||||
.array(z.object({ date: z.string(), title: z.string(), description: z.string().optional() }))
|
||||
.min(2)
|
||||
.max(6),
|
||||
notes: notesField,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('quote'),
|
||||
quote: z.string().describe('Exact or faithful quote from the note'),
|
||||
author: z.string().optional(),
|
||||
context: z.string().optional().describe('Why this quote matters'),
|
||||
notes: notesField,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('comparison'),
|
||||
title: z.string().describe('Action title of the comparison insight'),
|
||||
left: z.object({
|
||||
title: z.string(),
|
||||
points: z.array(z.string()).max(4),
|
||||
score: z.string().optional(),
|
||||
}),
|
||||
right: z.object({
|
||||
title: z.string(),
|
||||
points: z.array(z.string()).max(4),
|
||||
score: z.string().optional(),
|
||||
}),
|
||||
notes: notesField,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('equation'),
|
||||
title: z.string(),
|
||||
equations: z.array(z.object({ latex: z.string(), label: z.string().optional() })).max(4),
|
||||
explanation: z.string().optional(),
|
||||
notes: notesField,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('image'),
|
||||
title: z.string(),
|
||||
url: z.string().optional().describe('Only if a real image URL exists in the note'),
|
||||
caption: z.string().optional(),
|
||||
notes: notesField,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('summary'),
|
||||
title: z.string().describe('e.g. Next steps / Key takeaways'),
|
||||
items: z.array(z.string()).max(5).describe('3–5 actionable takeaways'),
|
||||
notes: notesField,
|
||||
}),
|
||||
])
|
||||
|
||||
toolRegistry.register({
|
||||
name: 'generate_slides',
|
||||
description: 'Renders a structured presentation from JSON data into a full animated HTML file and saves it.',
|
||||
description: `Create an executive presentation from structured slide data (rendered to HTML by the server).
|
||||
|
||||
CONTENT RULES (quality > quantity):
|
||||
- One idea per slide; titles are ACTION TITLES (insights), not topic labels like "Context" or "Analysis"
|
||||
- Narrative: title → problem/context → insight → evidence → implications → summary
|
||||
- Bullets: 3–5 max, short claims (≈8–18 words), no filler paragraphs
|
||||
- chart/stats ONLY with real numbers from the source note — NEVER invent KPIs or radar scores
|
||||
- First slide type "title", last type "summary"
|
||||
- Hard limit: ${SLIDE_HARD_CAP} slides; fewer for short notes
|
||||
- All facts from the note; optional "notes" field for speaker cues
|
||||
|
||||
Slide types: title | bullets | chart | stats | table | cards | timeline | quote | comparison | equation | image | summary`,
|
||||
isInternal: true,
|
||||
buildTool: (ctx) =>
|
||||
tool({
|
||||
description: `Create a presentation from structured slide data. Each slide has a type and corresponding content.
|
||||
description: `Create a high-quality executive deck. Prefer short action titles and sparse, high-signal content over dense filler.
|
||||
|
||||
Available slide types:
|
||||
- "title": title, subtitle (opening slide with particles)
|
||||
- "bullets": title, items[] (bullet list with 4-6 items of 15+ words each)
|
||||
- "chart": title, chartType (bar|horizontal-bar|line|donut|radar), data[{label,value}], subtitle
|
||||
- "stats": title, stats[{value:"98%", label:"KPI name"}] (3-4 animated KPIs)
|
||||
- "table": title, headers[], rows[][] (data table)
|
||||
- "cards": title, cards[{title, description}] (3-6 info cards)
|
||||
- "timeline": title, events[{date, title, description}] (chronological events)
|
||||
- "quote": quote, author, context (citation with analysis)
|
||||
- "comparison": title, left{title, points[], score}, right{...} (A vs B)
|
||||
- "equation": title, equations[{latex, label}], explanation (math formulas)
|
||||
- "image": title, url, caption (image slide)
|
||||
- "summary": title, items[] (conclusion with checkmarks)
|
||||
Types:
|
||||
- title: title, subtitle?
|
||||
- bullets: title (insight), items[3-5] short claims
|
||||
- chart: ONLY real numbers — chartType bar|horizontal-bar|line|donut|radar, data[{label,value}]
|
||||
- stats: 2–4 real KPIs {value,label}
|
||||
- table / cards / timeline / quote / comparison / equation / image / summary
|
||||
|
||||
RULES:
|
||||
- Count the plain-text words in the note (ignore markdown, URLs, metadata, headers)
|
||||
- <50 words → 2-3 slides MAXIMUM (title + 1 content + summary)
|
||||
- 50-150 words → 3-4 slides max
|
||||
- 150-400 words → 5-6 slides max
|
||||
- >400 words → 7-8 slides max — HARD LIMIT, NEVER exceed 8
|
||||
- First slide MUST be type "title"
|
||||
- Last slide MUST be type "summary"
|
||||
- Include "chart" ONLY if real numeric data exists in the note — otherwise FORBIDDEN
|
||||
- Use VARIED types — never 2 identical types in a row
|
||||
- All text must come from the source note — never invent data`,
|
||||
Rules: first=title, last=summary, max ${SLIDE_HARD_CAP} slides, no invented data, no fake charts.`,
|
||||
inputSchema: z.object({
|
||||
title: z.string().describe('Short presentation title (6 words max)'),
|
||||
theme: z.string().optional().describe('Visual recipe: architectural-saas, midnight-cathedral, aurora-borealis, venture-pitch, clinical-precision, coastal-morning, etc.'),
|
||||
slides: z.array(slideSchema).max(8).describe('Array of slide objects, 3-8 slides MAX'),
|
||||
title: z.string().describe('Short deck title (max ~8 words)'),
|
||||
theme: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Visual recipe: architectural-saas, midnight-cathedral, aurora-borealis, venture-pitch, clinical-precision, coastal-morning, etc.',
|
||||
),
|
||||
slides: z.array(slideSchema).max(SLIDE_HARD_CAP).describe(`Slide array, 2–${SLIDE_HARD_CAP} items`),
|
||||
}),
|
||||
|
||||
execute: async ({ title, theme, slides }) => {
|
||||
try {
|
||||
// Hard cap: never more than 8 slides regardless of what the model outputs
|
||||
const cappedSlides = slides.slice(0, 8)
|
||||
const normalized = normalizeSlideDeck({ title, theme, slides: slides as unknown[] })
|
||||
|
||||
const html = buildPresentationHTML({ title, theme, slides: cappedSlides as any })
|
||||
const html = buildPresentationHTML({
|
||||
title: normalized.title,
|
||||
theme: normalized.theme,
|
||||
slides: normalized.slides as any,
|
||||
})
|
||||
|
||||
const canvas = await prisma.canvas.create({
|
||||
data: {
|
||||
name: title || 'Présentation',
|
||||
name: normalized.title || 'Présentation',
|
||||
data: JSON.stringify({
|
||||
type: 'slides',
|
||||
title: title || 'Présentation',
|
||||
title: normalized.title || 'Présentation',
|
||||
html,
|
||||
slideCount: cappedSlides.length,
|
||||
theme: theme || 'architectural-saas',
|
||||
spec: { title, theme, slides: cappedSlides },
|
||||
slideCount: normalized.slides.length,
|
||||
theme: normalized.theme || 'architectural-saas',
|
||||
spec: {
|
||||
title: normalized.title,
|
||||
theme: normalized.theme,
|
||||
slides: normalized.slides,
|
||||
},
|
||||
}),
|
||||
userId: ctx.userId,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
if (ctx.actionId) {
|
||||
await prisma.agentAction.update({
|
||||
where: { id: ctx.actionId },
|
||||
data: {
|
||||
status: 'success',
|
||||
result: canvas.id,
|
||||
log: `Slides generated: ${cappedSlides.length} slides, ${Math.round(html.length / 1024)}KB`,
|
||||
},
|
||||
}).catch(err => console.error('[Slides Tool] Failed to update action status:', err))
|
||||
await prisma.agentAction
|
||||
.update({
|
||||
where: { id: ctx.actionId },
|
||||
data: {
|
||||
status: 'success',
|
||||
result: canvas.id,
|
||||
log: `Slides generated: ${normalized.slides.length} slides, ${Math.round(html.length / 1024)}KB (quality-normalized)`,
|
||||
},
|
||||
})
|
||||
.catch((err) => console.error('[Slides Tool] Failed to update action status:', err))
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
canvasId: canvas.id,
|
||||
canvasName: canvas.name,
|
||||
slideCount: cappedSlides.length,
|
||||
message: `Presentation "${canvas.name}" created with ${cappedSlides.length} slides.`,
|
||||
slideCount: normalized.slides.length,
|
||||
message: `Presentation "${canvas.name}" created with ${normalized.slides.length} slides.`,
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('[Slides Tool] FATAL:', e)
|
||||
|
||||
Reference in New Issue
Block a user