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:
Antigravity
2026-07-16 20:43:18 +00:00
parent 704fed1191
commit 556a0b2f3f
77 changed files with 35745 additions and 10430 deletions

View File

@@ -72,17 +72,19 @@ function renderTitle(slide: SlideTitle, r: Recipe, idx: number): string {
}
function renderBullets(slide: SlideBullets, r: Recipe, idx: number): string {
const items = slide.items.map((item, i) => `
<div class="reveal" style="display:flex;align-items:flex-start;gap:16px;padding:8px 0;">
<span style="width:8px;height:8px;border-radius:50%;background:${r.accent1};margin-top:10px;flex-shrink:0;"></span>
<span style="font-size:1.1rem;line-height:1.65;color:${r.text};">${esc(item)}</span>
const list = (slide.items || []).filter(Boolean)
// Dense layout: 2-col grid when 4+ bullets (Hermes/OpenClaw: avoid sparse empty slides)
const twoCol = list.length >= 4
const items = list.map((item) => `
<div class="reveal" style="display:flex;align-items:flex-start;gap:14px;padding:12px 14px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:12px;">
<span style="width:8px;height:8px;border-radius:50%;background:${r.accent1};margin-top:8px;flex-shrink:0;"></span>
<span style="font-size:clamp(0.95rem,1.6vw,1.15rem);line-height:1.55;color:${r.text};">${esc(item)}</span>
</div>`).join('')
return `<div class="slide" data-slide="${idx}">
${mesh(r)}
<div class="content">
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.6rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:32px;"></div>
<div style="display:flex;flex-direction:column;gap:6px;">${items}</div>
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.6rem,3.2vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 20px;">${esc(slide.title)}</h2>
<div style="display:grid;grid-template-columns:${twoCol ? '1fr 1fr' : '1fr'};gap:12px;">${items}</div>
</div>
</div>`
}
@@ -109,8 +111,11 @@ function renderChart(slide: SlideChart, r: Recipe, idx: number): string {
}
function renderStats(slide: SlideStats, r: Recipe, idx: number): string {
const cols = Math.min(slide.stats.length, 4)
const items = slide.stats.map(s => `
const stats = (slide.stats || []).filter((s) => s && (s.value || s.label))
// Never force empty columns: 1 stat = full width, 2 = half, 34 = equal
const n = Math.max(1, stats.length)
const cols = n === 1 ? 1 : n === 2 ? 2 : n === 3 ? 3 : 4
const items = stats.map(s => `
<div class="reveal" style="text-align:center;padding:28px 16px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:16px;">
<div style="font-size:clamp(2.2rem,4vw,3.5rem);font-weight:900;line-height:1;background:linear-gradient(135deg,${r.accent1},${r.accent2});-webkit-background-clip:text;-webkit-text-fill-color:transparent;" data-count="${extractNum(s.value)}" data-suffix="${extractSuffix(s.value)}">${esc(s.value)}</div>
<div style="font-size:0.8rem;color:${r.textMuted};margin-top:10px;letter-spacing:0.12em;text-transform:uppercase;font-weight:600;">${esc(s.label)}</div>
@@ -118,9 +123,8 @@ function renderStats(slide: SlideStats, r: Recipe, idx: number): string {
return `<div class="slide" data-slide="${idx}">
${mesh(r)}
<div class="content">
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.6rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:32px;"></div>
<div style="display:grid;grid-template-columns:repeat(${cols},1fr);gap:20px;">${items}</div>
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.6rem,3.2vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 24px;">${esc(slide.title)}</h2>
<div style="display:grid;grid-template-columns:repeat(${cols},minmax(0,1fr));gap:16px;">${items}</div>
</div>
</div>`
}
@@ -144,20 +148,22 @@ function renderTable(slide: SlideTable, r: Recipe, idx: number): string {
}
function renderCards(slide: SlideCards, r: Recipe, idx: number): string {
const cols = slide.cards.length <= 2 ? 2 : slide.cards.length === 4 ? 2 : 3
const items = slide.cards.map((c, i) => `
<div class="reveal" style="padding:24px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:14px;position:relative;overflow:hidden;">
const cards = (slide.cards || []).filter((c) => c && (c.title || c.description))
// 1 card → full width (no empty second column). 2 → 2 cols. 3 → 3. 4+ → 2x2
const n = Math.max(1, cards.length)
const cols = n === 1 ? 1 : n === 2 ? 2 : n === 3 ? 3 : 2
const items = cards.map((c, i) => `
<div class="reveal" style="padding:24px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:14px;position:relative;overflow:hidden;min-height:120px;">
<span style="position:absolute;top:10px;right:14px;font-size:1.8rem;font-weight:900;color:${r.accent1};opacity:0.12;">${String(i + 1).padStart(2, '0')}</span>
<div style="width:24px;height:3px;background:${r.accent1};border-radius:2px;margin-bottom:12px;"></div>
<div style="font-size:1rem;font-weight:700;color:${r.text};margin-bottom:8px;">${esc(c.title)}</div>
<div style="font-size:0.88rem;color:${r.textSecondary};line-height:1.6;">${esc(c.description)}</div>
<div style="font-size:1.05rem;font-weight:700;color:${r.text};margin-bottom:8px;">${esc(c.title)}</div>
<div style="font-size:0.9rem;color:${r.textSecondary};line-height:1.6;">${esc(c.description)}</div>
</div>`).join('')
return `<div class="slide" data-slide="${idx}">
${mesh(r)}
<div class="content">
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.6rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:32px;"></div>
<div style="display:grid;grid-template-columns:repeat(${cols},1fr);gap:16px;">${items}</div>
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.6rem,3.2vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 24px;">${esc(slide.title)}</h2>
<div style="display:grid;grid-template-columns:repeat(${cols},minmax(0,1fr));gap:16px;">${items}</div>
</div>
</div>`
}
@@ -214,18 +220,22 @@ function renderComparison(slide: SlideComparison, r: Recipe, idx: number): strin
}
function renderEquation(slide: SlideEquation, r: Recipe, idx: number): string {
const eqs = slide.equations.map(eq => `
<div class="reveal" style="text-align:center;padding:24px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:12px;margin-bottom:12px;">
<div style="font-size:clamp(1.4rem,3vw,2.2rem);font-family:'KaTeX_Main',serif;color:${r.text};letter-spacing:0.02em;">${esc(eq.latex)}</div>
${eq.label ? `<div style="font-size:0.8rem;color:${r.textMuted};margin-top:8px;">${esc(eq.label)}</div>` : ''}
</div>`).join('')
const eqs = (slide.equations || []).map((eq) => {
// data-latex for KaTeX client render; also show fallback monospaced text
const latex = eq.latex || ''
return `
<div class="reveal" style="text-align:center;padding:22px 24px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:14px;margin-bottom:14px;">
<div class="eq-latex" data-latex="${esc(latex)}" style="font-size:clamp(1.25rem,2.6vw,1.85rem);color:${r.text};overflow-x:auto;padding:8px 0;"></div>
<noscript><code style="font-size:1.1rem;">${esc(latex)}</code></noscript>
${eq.label ? `<div style="font-size:0.85rem;color:${r.textMuted};margin-top:10px;font-weight:600;">${esc(eq.label)}</div>` : ''}
</div>`
}).join('')
return `<div class="slide" data-slide="${idx}">
${mesh(r)}
<div class="content">
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:28px;"></div>
${eqs}
${slide.explanation ? `<p class="reveal" style="font-size:0.95rem;color:${r.textSecondary};line-height:1.7;margin-top:16px;text-align:center;max-width:700px;margin-inline:auto;">${esc(slide.explanation)}</p>` : ''}
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.6rem,3.2vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 20px;">${esc(slide.title)}</h2>
${eqs || `<div class="reveal" style="color:${r.textMuted};">Aucune équation</div>`}
${slide.explanation ? `<p class="reveal" style="font-size:1rem;color:${r.textSecondary};line-height:1.7;margin-top:18px;max-width:780px;">${esc(slide.explanation)}</p>` : ''}
</div>
</div>`
}
@@ -429,14 +439,19 @@ export function buildPresentationHTML(input: PresentationInput): string {
<title>${esc(input.title)}</title>
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="${r.fontUrl}" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<style>
:root{--color-bg:${r.bg};--color-text:${r.text};--color-text-secondary:${r.textSecondary};--color-text-muted:${r.textMuted};--color-accent-1:${r.accent1};--color-accent-2:${r.accent2};--color-glass-bg:${r.glassBg};--color-glass-border:${r.glassBorder};--svg-grid-line:${r.svgGrid};--font-display:${r.fontDisplay},serif;--font-body:${r.fontBody},system-ui,sans-serif;}
*,*::before,*::after{box-sizing:border-box;}
html,body{margin:0;padding:0;overflow:hidden;background:var(--color-bg);color:var(--color-text);font-family:var(--font-body);-webkit-font-smoothing:antialiased;}
.deck{width:100vw;height:100vh;position:relative;}
.slide{position:absolute;top:0;left:0;width:100%;height:100%;background:var(--color-bg);opacity:0;transform:scale(0.96);transition:opacity 0.6s ease,transform 0.6s ease;pointer-events:none;overflow:hidden;display:flex;align-items:center;justify-content:center;}
html,body{margin:0;padding:0;width:100%;height:100%;overflow:hidden;background:var(--color-bg);color:var(--color-text);font-family:var(--font-body);-webkit-font-smoothing:antialiased;}
/* Fill iframe/container — use % not 100vw (vw breaks lab preview letterboxing) */
.deck{width:100%;height:100%;position:relative;overflow:hidden;}
.slide{position:absolute;top:0;left:0;width:100%;height:100%;background:var(--color-bg);opacity:0;transform:scale(0.98);transition:opacity 0.45s ease,transform 0.45s ease;pointer-events:none;overflow:hidden;display:flex;align-items:center;justify-content:center;}
.slide.active{opacity:1;transform:scale(1);pointer-events:all;}
.slide>.content{position:relative;z-index:2;width:100%;max-width:1100px;padding:clamp(1.5rem,4vw,4rem);}
.slide>.content{position:relative;z-index:2;width:100%;max-width:min(1100px,94%);height:100%;max-height:100%;padding:clamp(1.25rem,3.5vmin,3.5rem);display:flex;flex-direction:column;justify-content:center;box-sizing:border-box;}
.eq-latex{max-width:100%;}
.eq-latex .katex-display{margin:0.4em 0;overflow-x:auto;overflow-y:hidden;}
.gradient-mesh{position:absolute;inset:0;overflow:hidden;pointer-events:none;z-index:0;}
.blob{position:absolute;border-radius:50%;filter:blur(80px);animation:float-slow var(--dur,18s) ease-in-out infinite;}
@keyframes float-slow{0%{transform:translate(0,0) scale(1);}50%{transform:translate(-40px,40px) scale(0.92);}100%{transform:translate(0,0) scale(1);}}
@@ -464,13 +479,14 @@ ${slidesHtml}
<script>
var current=1,slides=document.querySelectorAll('.slide'),total=slides.length;
(function(){var d=document.getElementById('nav-dots');for(var i=1;i<=total;i++){var b=document.createElement('button');b.className='nav-dot'+(i===1?' active':'');(function(n){b.addEventListener('click',function(){goToSlide(n)});})(i);d.appendChild(b);}updateNav();})();
function goToSlide(n){if(n<1||n>total||n===current)return;var p=document.querySelector('.slide.active'),nx=document.querySelector('.slide[data-slide="'+n+'"]');if(!nx)return;if(p)p.classList.remove('active');nx.classList.add('active');current=n;updateNav();animateSlide(nx);}
function renderKatex(){if(typeof katex==='undefined')return;document.querySelectorAll('.eq-latex[data-latex]').forEach(function(el){var tex=el.getAttribute('data-latex')||'';try{katex.render(tex,el,{throwOnError:false,displayMode:true});}catch(e){el.textContent=tex;}});}
function goToSlide(n){if(n<1||n>total||n===current)return;var p=document.querySelector('.slide.active'),nx=document.querySelector('.slide[data-slide="'+n+'"]');if(!nx)return;if(p)p.classList.remove('active');nx.classList.add('active');current=n;updateNav();animateSlide(nx);setTimeout(renderKatex,40);}
function changeSlide(dir){var n=current+dir;if(n<1)n=total;if(n>total)n=1;goToSlide(n);}
function updateNav(){document.querySelectorAll('.nav-dot').forEach(function(d,i){d.classList.toggle('active',(i+1)===current);});var c=document.getElementById('slide-counter');if(c)c.textContent=current+' / '+total;try{parent.postMessage({type:'slideChange',current:current,total:total},'*');}catch(e){}}
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);},300);}
var first=document.querySelector('.slide[data-slide="1"]');if(first){first.classList.add('active');setTimeout(function(){animateSlide(first);renderKatex();},300);}else{setTimeout(renderKatex,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>

View File

@@ -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 (12 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('35 short claims (818 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('24 cards preferred; short titles + 12 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('35 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: 35 max, short claims (≈818 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: 24 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)