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

@@ -281,5 +281,40 @@ export function getChatProvider(config?: Record<string, string>): AIProvider {
return getProviderInstance(route.providerType as ProviderType, cfg, route.modelName, route.embeddingModelName, route.ollamaBaseUrl);
}
/**
* Provider for slide deck generation.
* Uses AI_PROVIDER_SLIDES + AI_MODEL_SLIDES when both are set in admin.
* If only AI_MODEL_SLIDES is set, reuses the chat provider with that exact model id.
* Otherwise falls back to the chat route — never rewrites or invents model names.
*/
export function getSlidesProvider(config?: Record<string, string>): AIProvider {
const cfg = config || {};
const slidesModel = (cfg.AI_MODEL_SLIDES || process.env.AI_MODEL_SLIDES || '').trim();
const slidesProvider = (cfg.AI_PROVIDER_SLIDES || process.env.AI_PROVIDER_SLIDES || '').trim();
const route = resolveAiRoute('chat', cfg);
if (slidesModel && slidesProvider) {
return getProviderInstance(
slidesProvider as ProviderType,
cfg,
slidesModel,
route.embeddingModelName,
route.ollamaBaseUrl,
);
}
if (slidesModel) {
return getProviderInstance(
route.providerType as ProviderType,
cfg,
slidesModel,
route.embeddingModelName,
route.ollamaBaseUrl,
);
}
return getChatProvider(cfg);
}
// Export for use by admin settings form and deploy scripts
export { getProviderConfigKeys };

View File

@@ -18,7 +18,26 @@ import { calculateNextRun } from '@/lib/agents/schedule'
import { markdownToHtml } from '@/lib/markdown-to-html'
import { createNotification } from '@/app/actions/notifications'
import { withAiQuota } from '@/lib/ai-quota'
import { releaseCredits, slideGenerateCreditCost } from '@/lib/credits'
import type { FeatureName } from '@/lib/quota-utils'
import {
countNoteWords,
prepareNoteTextForSlides,
slideLimitFromWordCount,
SLIDE_CONTENT_PRINCIPLES_EN,
SLIDE_CONTENT_PRINCIPLES_FR,
normalizeSlideDeck,
} from '@/lib/ai/services/slide-content-quality'
import { generateSlideDeck } from '@/lib/ai/services/slide-deck-generator.service'
import { decodeSlideIntentDescription } from '@/lib/ai/services/slide-intent'
export type ExecuteAgentOptions = {
/**
* When true, caller already reserved quota (e.g. run-for-note multi-unit pre-check).
* Avoids double-charging on fire-and-forget generation.
*/
skipQuota?: boolean
}
// Import tools for side-effect registration
import '../tools'
@@ -915,94 +934,54 @@ This format AUTOMATICALLY creates shapes, text, AND arrows with correct bindings
},
'slide-generator': {
fr: `Tu es un expert en design de présentations exécutives. Tu analyses la note et génères une structure JSON qui sera rendue en HTML automatiquement par le serveur.
fr: `Tu es un consultant senior (niveau McKinsey/BCG) qui transforme une note Memento en deck exécutif de classe mondiale.
APPELLE OBLIGATOIREMENT generate_slides avec le JSON structuré. NE GÉNÈRE JAMAIS de HTML brut.
═══ ÉTAPE 1 — LECTURE (OBLIGATOIRE) ═══
1. Lis la note avec note_read. Extrais TOUTES les données exploitables (chiffres, listes, citations, comparaisons, étapes).
2. Identifie les types de slides les plus adaptés au contenu réel.
${SLIDE_CONTENT_PRINCIPLES_FR}
═══ ÉTAPE 2 — APPEL generate_slides ═══
Appelle generate_slides avec un objet JSON structuré :
═══ MÉTHODE ═══
1. Lis la note (contenu fourni + note_read si besoin). Extrais : claims, chiffres, citations, étapes, oppositions, décisions.
2. Construis un storyboard mental (max N slides selon le message user) avant d'appeler l'outil.
3. Appelle generate_slides :
{
"title": "Titre court (6 mots max)",
"theme": "architectural-saas", // ou midnight-cathedral, venture-pitch, clinical-precision, etc.
"slides": [ ... tableau de slides ... ]
"title": "Titre du deck (≤8 mots)",
"theme": "architectural-saas",
"slides": [ ... ]
}
═══ TYPES DE SLIDES DISPONIBLES ═══
1. "title" → { type:"title", title:"...", subtitle:"..." }
2. "bullets" → { type:"bullets", title:"...", items:["phrase 1 (15+ mots)", "phrase 2", ...] }
3. "chart" → { type:"chart", title:"...", chartType:"bar|horizontal-bar|line|donut|radar", data:[{label:"Q1",value:65}, ...], subtitle:"..." }
4. "stats" → { type:"stats", title:"...", stats:[{value:"98%", label:"Satisfaction"}, ...] }
5. "table" → { type:"table", title:"...", headers:["Col A","Col B"], rows:[["val1","val2"], ...] }
6. "cards" → { type:"cards", title:"...", cards:[{title:"Titre", description:"Description détaillée..."}, ...] }
7. "timeline" → { type:"timeline", title:"...", events:[{date:"Jan 2024", title:"Lancement", description:"..."}, ...] }
8. "quote" → { type:"quote", quote:"Citation exacte", author:"Auteur", context:"Analyse..." }
9. "comparison" → { type:"comparison", title:"...", left:{title:"A", points:["..."], score:"8/10"}, right:{title:"B", points:["..."], score:"6/10"} }
10. "equation" → { type:"equation", title:"...", equations:[{latex:"E=mc^2", label:"Énergie"}], explanation:"..." }
11. "image" → { type:"image", title:"...", url:"https://...", caption:"..." }
12. "summary" → { type:"summary", title:"...", items:["Point clé 1", "Point clé 2", ...] }
═══ TYPES (choisir selon le contenu, pas pour « remplir ») ═══
title | bullets | chart* | stats* | table | cards | timeline | quote | comparison | equation | image | summary
* chart/stats seulement avec chiffres réels dans la note.
═══ RÈGLES ═══
- Nombre de slides : adapté au contenu réel de la note (la règle ⚠️ dans le message utilisateur est ABSOLUE)
- Slide 1 OBLIGATOIREMENT type "title"
- Dernière slide OBLIGATOIREMENT type "summary"
- Au moins 1 slide "chart" ou "stats" si des chiffres existent dans la note
- VARIER les types — jamais 2 types identiques consécutifs
- Chaque texte (bullet, description) = 15+ mots, phrase complète
- TOUTES les données viennent de la note (JAMAIS inventer de chiffres)
- Les données chart doivent refléter les vrais chiffres de la note
═══ THÈMES DISPONIBLES ═══
═══ THÈMES ═══
Sombres : midnight-cathedral, aurora-borealis, tokyo-neon, venture-pitch, forest-floor, steel-glass, cyberpunk-terminal
Clairs : sunlit-gallery, clinical-precision, editorial-ink, coastal-morning, paper-studio, architectural-saas
Choisir selon le sujet : business/board → architectural-saas ou midnight-cathedral, tech → cyberpunk-terminal ou clinical-precision, créatif → aurora-borealis ou tokyo-neon`,
en: `You are an executive presentation design expert. You analyze the note and generate a structured JSON spec that will be rendered to HTML automatically by the server.
Business → architectural-saas / midnight-cathedral ; tech → clinical-precision ; créatif → aurora-borealis`,
en: `You are a senior consultant (McKinsey/BCG level) turning a Memento note into a world-class executive deck.
You MUST call generate_slides with structured JSON. NEVER output raw HTML.
═══ STEP 1 — READ (MANDATORY) ═══
1. Read the note with note_read. Extract ALL usable data (numbers, lists, quotes, comparisons, steps).
2. Identify the best slide types matching the actual content.
${SLIDE_CONTENT_PRINCIPLES_EN}
═══ STEP 2 — CALL generate_slides ═══
Call generate_slides with a structured JSON object:
═══ METHOD ═══
1. Read the note (provided content + note_read if needed). Extract: claims, numbers, quotes, steps, trade-offs, decisions.
2. Storyboard mentally (respect max N slides from the user message) before calling the tool.
3. Call generate_slides:
{
"title": "Short title (6 words max)",
"title": "Deck title (≤8 words)",
"theme": "architectural-saas",
"slides": [ ... array of slides ... ]
"slides": [ ... ]
}
═══ AVAILABLE SLIDE TYPES ═══
1. "title" → { type:"title", title:"...", subtitle:"..." }
2. "bullets" → { type:"bullets", title:"...", items:["sentence 1 (15+ words)", "sentence 2", ...] }
3. "chart" → { type:"chart", title:"...", chartType:"bar|horizontal-bar|line|donut|radar", data:[{label:"Q1",value:65}, ...], subtitle:"..." }
4. "stats" → { type:"stats", title:"...", stats:[{value:"98%", label:"Satisfaction"}, ...] }
5. "table" → { type:"table", title:"...", headers:["Col A","Col B"], rows:[["val1","val2"], ...] }
6. "cards" → { type:"cards", title:"...", cards:[{title:"Title", description:"Detailed description..."}, ...] }
7. "timeline" → { type:"timeline", title:"...", events:[{date:"Jan 2024", title:"Launch", description:"..."}, ...] }
8. "quote" → { type:"quote", quote:"Exact quote", author:"Author", context:"Analysis..." }
9. "comparison" → { type:"comparison", title:"...", left:{title:"A", points:["..."], score:"8/10"}, right:{title:"B", points:["..."], score:"6/10"} }
10. "equation" → { type:"equation", title:"...", equations:[{latex:"E=mc^2", label:"Energy"}], explanation:"..." }
11. "image" → { type:"image", title:"...", url:"https://...", caption:"..." }
12. "summary" → { type:"summary", title:"...", items:["Key point 1", "Key point 2", ...] }
═══ TYPES (match content — do not pad) ═══
title | bullets | chart* | stats* | table | cards | timeline | quote | comparison | equation | image | summary
* chart/stats only with real numbers from the note.
═══ RULES ═══
- Slide count: adapt to the real content (the ⚠️ rule in the user message is ABSOLUTE)
- Slide 1 MUST be type "title"
- Last slide MUST be type "summary"
- At least 1 "chart" or "stats" slide if numbers exist in the note
- VARY types — never 2 identical types in a row
- Each text (bullet, description) = 15+ words, complete sentence
- ALL data comes from the note (NEVER invent numbers)
- Chart data must reflect actual numbers from the note
═══ AVAILABLE THEMES ═══
═══ THEMES ═══
Dark: midnight-cathedral, aurora-borealis, tokyo-neon, venture-pitch, forest-floor, steel-glass, cyberpunk-terminal
Light: sunlit-gallery, clinical-precision, editorial-ink, coastal-morning, paper-studio, architectural-saas
Choose based on topic: business/board → architectural-saas or midnight-cathedral, tech → cyberpunk-terminal or clinical-precision, creative → aurora-borealis or tokyo-neon`,
Business → architectural-saas / midnight-cathedral; tech → clinical-precision; creative → aurora-borealis`,
},
'task-extractor': {
fr: `Tu es un expert en gestion de tâches et extraction d'action items. Tu analyses des notes et documents pour identifier toutes les tâches, TODOs, et actions à accomplir.
@@ -1028,49 +1007,6 @@ You MUST use the task_extract tool. Do NOT respond with text, call the tool dire
},
}
/**
* Strip markdown syntax, URLs, metadata blocks, and frontmatter from note content.
* Returns only the semantic plain text, used to count real words before generating slides.
*/
function stripNoteMarkdown(raw: string): string {
return raw
// Remove frontmatter / YAML blocks
.replace(/^---[\s\S]*?---/m, '')
// Remove markdown headers
.replace(/^#{1,6}\s+/gm, '')
// Remove bold/italic
.replace(/[*_]{1,3}([^*_]+)[*_]{1,3}/g, '$1')
// Remove inline links — keep label only
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
// Remove bare URLs
.replace(/https?:\/\/\S+/g, '')
// Remove metadata lines (Connection to seed, Novelty score, Source brainstorm, Origin, Derived from, etc.)
.replace(/^\*{0,2}(Connection to seed|Novelty score|Source brainstorm|Source note|Origin|Derived from|## Origin)[^:\n]*:?.*$/gim, '')
// Remove horizontal rules
.replace(/^---+$/gm, '')
// Remove blockquote markers
.replace(/^>\s*/gm, '')
// Remove code blocks
.replace(/```[\s\S]*?```/g, '')
.replace(/`[^`]+`/g, '')
// Collapse whitespace
.replace(/\s+/g, ' ')
.trim()
}
/** Count real semantic words in a note */
function countNoteWords(raw: string): number {
return stripNoteMarkdown(raw).split(/\s+/).filter(Boolean).length
}
/** Return max slides based on real word count */
function slideLimit(wordCount: number): { max: number; label: string } {
if (wordCount < 50) return { max: 3, label: '3 slides MAX (note très courte)' }
if (wordCount < 150) return { max: 4, label: '4 slides MAX (note courte)' }
if (wordCount < 350) return { max: 6, label: '6 slides MAX (note moyenne)' }
return { max: 8, label: '8 slides MAX (note longue)' }
}
function extractJsonFromText(text: string): any {
if (!text) return null
@@ -1323,52 +1259,54 @@ async function executeToolUseAgent(
? LANG_NAMES[noteLanguage]
: (lang === 'fr' ? 'French' : 'English')
prompt = `Create a professional presentation using the content from the notes below. Call generate_slides with a structured JSON object (title, theme, slides[]).`
prompt += `\n\n⚠ LANGUAGE RULE: ALL slide text (titles, bullets, labels, subtitles) MUST be written in ${contentLang}. Do NOT translate to another language.`
prompt = lang === 'fr'
? `Construis un deck exécutif de haute qualité à partir des notes ci-dessous. Appelle generate_slides avec {title, theme, slides[]}.`
: `Build a high-quality executive deck from the notes below. Call generate_slides with {title, theme, slides[]}.`
prompt += `\n\n⚠ LANGUAGE: ALL on-slide text (titles, bullets, labels, subtitles, summary) MUST be in ${contentLang}.`
if (notes.length > 0) {
const notesContext = notes.map(n =>
`### ${n.title || untitled} (${n.createdAt.toLocaleDateString(dateLocale)})\n${n.content.substring(0, 2000)}`
`### ${n.title || untitled} (${n.createdAt.toLocaleDateString(dateLocale)})\n${prepareNoteTextForSlides(n.content || '', 12_000)}`
).join('\n\n')
// Compute real word count (stripped of markdown/metadata) to determine slide limit
const totalWords = notes.reduce((sum, n) => sum + countNoteWords(n.content || ''), 0)
const { max: maxSlides, label: slideRule } = slideLimit(totalWords)
const limit = slideLimitFromWordCount(totalWords)
const slideRule = lang === 'fr' ? limit.labelFr : limit.labelEn
prompt += `\n\n${lang === 'fr' ? 'Notes source' : 'Source notes'}:\n\n${notesContext}`
prompt += `\n\n${lang === 'fr' ? '## Notes source (texte intégral utile)' : '## Source notes (usable full text)'}:\n\n${notesContext}`
prompt += `\n\n${lang === 'fr'
? `⚠️ RÈGLE ABSOLUE : Cette note fait ${totalWords} mots réels (hors markdown/URLs/métadonnées). Tu DOIS générer EXACTEMENT ${slideRule}. Respecter cette limite est PLUS IMPORTANT que la diversité ou la densité.`
: `⚠️ ABSOLUTE RULE: This note has ${totalWords} real words (excluding markdown/URLs/metadata). You MUST generate EXACTLY ${slideRule}. This limit is MORE IMPORTANT than diversity or density.`
? `⚠️ LIMITE DECK : ${totalWords} mots source → ${slideRule}. Mieux vaut ${limit.min} slides excellentes que ${limit.max} slides diluées. Ne dépasse JAMAIS ${limit.max}.`
: `⚠️ DECK LIMIT: ${totalWords} source words ${slideRule}. Prefer ${limit.min} excellent slides over ${limit.max} diluted ones. NEVER exceed ${limit.max}.`
}`
}
// ── Executive Template Structure (HTML-compatible) ──
// ── Executive templates (max 8 slides — matches hard cap) ──
if (slideTemplate && slideTemplate !== 'auto') {
const templates: Record<string, { fr: string; en: string }> = {
'board-update': {
fr: `Structure imposée "Board Update" (10 slides) :
1. TITRE avec date | 2. KPIs majeurs (3-4 gros chiffres) | 3. Bar chart progression objectifs | 4. Grille de métriques (6 cards) | 5. Timeline jalons | 6. Cards réalisations | 7. Line chart tendance | 8. Deux colonnes risques/mitigations | 9. Bullets prochaines étapes | 10. Conclusion`,
en: `Mandatory "Board Update" structure (10 slides):
1. TITLE with date | 2. Key KPIs (3-4 big numbers) | 3. Bar chart goal progress | 4. Metrics grid (6 cards) | 5. Timeline milestones | 6. Achievement cards | 7. Line chart trend | 8. Two-column risks/mitigations | 9. Next steps bullets | 10. Conclusion`
fr: `Structure "Board Update" (≤8 slides, titres d'action) :
1. title | 2. stats KPIs réels seulement | 3. insight + bullets sur performance | 4. chart progression SI chiffres | 5. cards réalisations | 6. comparison risques/mitigations | 7. bullets prochaines décisions | 8. summary`,
en: `"Board Update" structure (≤8 slides, action titles):
1. title | 2. stats real KPIs only | 3. insight + performance bullets | 4. chart ONLY if real numbers | 5. achievement cards | 6. risks/mitigations comparison | 7. next decisions bullets | 8. summary`,
},
'project-status': {
fr: `Structure imposée "Project Status" (10 slides) :
1. TITRE + statut 🟢🟡🔴 | 2. KPIs (% avancement, jours, budget) | 3. Bar chart par workstream | 4. Cards livrables | 5. Timeline roadmap | 6. Line chart burn-down | 7. Deux colonnes blockers/actions | 8. Tableau de risques | 9. Bullets décisions requises | 10. Synthèse`,
en: `Mandatory "Project Status" structure (10 slides):
1. TITLE + status 🟢🟡🔴 | 2. KPIs (% complete, days, budget) | 3. Bar chart by workstream | 4. Deliverable cards | 5. Timeline roadmap | 6. Line chart burn-down | 7. Two-column blockers/actions | 8. Risk table | 9. Required decisions bullets | 10. Summary`
fr: `Structure "Project Status" (≤8) :
1. title + statut | 2. stats avancement (réels) | 3. cards livrables | 4. timeline jalons | 5. comparison blockers/actions | 6. table risques SI présent | 7. bullets décisions | 8. summary`,
en: `"Project Status" structure (≤8):
1. title + status | 2. real progress stats | 3. deliverable cards | 4. milestone timeline | 5. blockers/actions comparison | 6. risk table IF present | 7. decisions bullets | 8. summary`,
},
'strategy-review': {
fr: `Structure imposée "Strategy Review" (10 slides) :
1. TITRE stratégique | 2. Contexte marché (5+ bullets) | 3. Radar/pie positionnement | 4. SWOT deux colonnes | 5. Area chart tendances | 6. Cards axes stratégiques | 7. Timeline roadmap 12 mois | 8. Bar chart projections | 9. KPIs objectifs cibles | 10. Call to action`,
en: `Mandatory "Strategy Review" structure (10 slides):
1. Strategic TITLE | 2. Market context (5+ bullets) | 3. Radar/pie positioning | 4. SWOT two-column | 5. Area chart trends | 6. Strategic axes cards | 7. 12-month roadmap timeline | 8. Bar chart projections | 9. Target KPIs | 10. Call to action`
fr: `Structure "Strategy Review" (≤8) :
1. title stratégique | 2. bullets contexte (claims) | 3. comparison options ou SWOT | 4. cards axes stratégiques | 5. timeline roadmap | 6. stats/chart UNIQUEMENT si données | 7. implications | 8. summary CTA`,
en: `"Strategy Review" structure (≤8):
1. strategic title | 2. context claim bullets | 3. options/SWOT comparison | 4. strategic pillars cards | 5. roadmap timeline | 6. stats/chart ONLY if data | 7. implications | 8. summary CTA`,
},
'quarterly-results': {
fr: `Structure imposée "Quarterly Results" (10 slides) :
1. TITRE "Résultats Q? Année" | 2. 4 KPIs (Revenue, Croissance, Marge, NPS) | 3. Bar chart revenue/mois | 4. Line chart croissance YoY | 5. Cards faits marquants | 6. Grille métriques opérationnelles | 7. Donut répartition clients | 8. Deux colonnes succès/challenges | 9. Area chart forecast | 10. Conclusion outlook`,
en: `Mandatory "Quarterly Results" structure (10 slides):
1. TITLE "Q? Year Results" | 2. 4 KPIs (Revenue, Growth, Margin, NPS) | 3. Bar chart revenue/month | 4. Line chart YoY growth | 5. Highlights cards | 6. Operational metrics grid | 7. Donut client breakdown | 8. Two-column successes/challenges | 9. Area chart forecast | 10. Conclusion outlook`
}
fr: `Structure "Quarterly Results" (≤8) :
1. title trimestre | 2. stats 34 KPIs réels | 3. chart SI séries chiffrées | 4. cards faits marquants | 5. comparison succès/défis | 6. bullets outlook | 7. summary`,
en: `"Quarterly Results" structure (≤8):
1. quarter title | 2. 34 real KPI stats | 3. chart IF numeric series | 4. highlight cards | 5. wins/challenges comparison | 6. outlook bullets | 7. summary`,
},
}
const tmpl = templates[slideTemplate]
if (tmpl) {
@@ -1376,19 +1314,24 @@ async function executeToolUseAgent(
}
}
// ── Density rules ──
prompt += lang === 'fr'
? `\n\nDENSITÉ OBLIGATOIRE : Chaque slide doit avoir un CONTENU RICHE. Minimum 5 bullets par slide "bullets" (≥15 mots chaque). Minimum 4 cards quand tu fais "cards". Minimum 4 data points quand tu fais "chart". Inclus OBLIGATOIREMENT au moins 1 slide "chart" avec données RÉELLES extraites des notes. Si pas de chiffres dans la note, fais un radar de maturité ou une comparaison qualitative notée sur 5.`
: `\n\nMANDATORY DENSITY: Every slide must have RICH CONTENT. Minimum 5 items per "bullets" slide (≥15 words each). Minimum 4 cards when using "cards". Minimum 4 data points for "chart". You MUST include at least 1 "chart" slide with REAL data from the notes. If no numbers in notes, create a maturity radar or qualitative comparison scored out of 5.`
prompt += `\n\n${lang === 'fr'
? 'IMPORTANT : Appelle OBLIGATOIREMENT generate_slides avec le JSON structuré {title, theme, slides:[...]}. Ne réponds JAMAIS avec du texte brut. Respecte ABSOLUMENT la limite de slides indiquée.'
: 'IMPORTANT: You MUST call generate_slides with structured JSON {title, theme, slides:[...]}. NEVER respond with plain text. STRICTLY respect the slide count limit indicated.'}`
? `\n\nQUALITÉ > QUANTITÉ :
- Titres d'action (insight), une idée par slide
- 35 bullets courts max ; pas de filler ni de paragraphes
- Graphiques UNIQUEMENT avec chiffres de la note — JAMAIS inventer de KPI/radar
- Arc : problème → insight → preuve → suite
- Appelle generate_slides maintenant (JSON structuré uniquement).`
: `\n\nQUALITY > QUANTITY:
- Action titles (insights), one idea per slide
- 35 short bullets max; no filler or paragraphs
- Charts ONLY with numbers from the note — NEVER invent KPI/radar scores
- Arc: problem → insight → evidence → next steps
- Call generate_slides now (structured JSON only).`
if (agent.slideTheme && agent.slideTheme !== 'auto') {
prompt += `\n${lang === 'fr'
? `Thème visuel imposé : utilise "theme":"${agent.slideTheme}" dans l'appel generate_slides.`
: `Visual theme required: use "theme":"${agent.slideTheme}" in the generate_slides call.`}`
? `Thème visuel imposé : "theme":"${agent.slideTheme}" dans generate_slides.`
: `Required visual theme: "theme":"${agent.slideTheme}" in the generate_slides call.`}`
}
break
}
@@ -1510,7 +1453,12 @@ async function executeToolUseAgent(
const registered = toolRegistry.get('generate_slides')
if (registered) {
const slideTool = registered.buildTool(ctx)
const executionResult = await slideTool.execute({ title, theme, slides })
const normalized = normalizeSlideDeck({ title, theme, slides })
const executionResult = await slideTool.execute({
title: normalized.title,
theme: normalized.theme,
slides: normalized.slides as any,
})
if (executionResult && executionResult.success && executionResult.canvasId) {
canvasId = executionResult.canvasId
specificToolCalled = true
@@ -1747,9 +1695,124 @@ async function sendAgentEmail(agentId: string, userId: string, agentName: string
}
}
/**
* Dedicated slide path: structured JSON → normalize → HTML canvas.
* Never depends on the model calling generate_slides.
*/
async function executeSlideGeneratorAgent(
agent: {
id: string
name: string
description?: string | null
userId: string
sourceNoteIds?: string | null
sourceNotebookId?: string | null
slideTheme?: string | null
},
actionId: string,
lang: Lang,
): Promise<AgentExecutionResult> {
const startTime = Date.now()
let noteIds: string[] = []
try {
noteIds = agent.sourceNoteIds ? JSON.parse(agent.sourceNoteIds) : []
} catch {
noteIds = []
}
const intent = decodeSlideIntentDescription(agent.description)
const template = intent.template || 'auto'
await prisma.agentAction.update({
where: { id: actionId },
data: {
input: JSON.stringify({
pipeline: 'structured-slide-deck',
noteIds,
sourceNotebookId: agent.sourceNotebookId,
theme: agent.slideTheme,
template,
intent,
lang,
}),
},
})
const result = await generateSlideDeck({
userId: agent.userId,
noteIds: noteIds.length > 0 ? noteIds : undefined,
sourceNotebookId: agent.sourceNotebookId,
theme: agent.slideTheme,
template,
intent,
lang,
actionId,
})
const duration = Date.now() - startTime
if (!result.success || !result.canvasId) {
await prisma.agentAction.update({
where: { id: actionId },
data: {
status: 'failure',
log:
(result.error ||
(lang === 'fr'
? 'Échec de la génération de présentation (pipeline structuré).'
: 'Slide generation failed (structured pipeline).')) +
` (${Math.round(duration / 1000)}s)`,
},
})
return {
success: false,
actionId,
error: result.error || 'Slide generation failed',
}
}
await prisma.agentAction.update({
where: { id: actionId },
data: {
status: 'success',
result: result.canvasId,
log: `Slides OK (${result.mode || 'structured'}): ${result.slideCount} slides — ${result.canvasName} (${Math.round(duration / 1000)}s)`,
toolLog: JSON.stringify([
{
step: 1,
text: 'structured-slide-deck pipeline (no tool-calling)',
toolCalls: [],
toolResults: [
{
toolName: 'generate_slides_structured',
preview: JSON.stringify({
canvasId: result.canvasId,
slideCount: result.slideCount,
mode: result.mode,
}),
},
],
},
]),
},
})
return {
success: true,
actionId,
canvasId: result.canvasId,
}
}
// --- Main Executor ---
export async function executeAgent(agentId: string, userId: string, promptOverride?: string): Promise<AgentExecutionResult> {
export async function executeAgent(
agentId: string,
userId: string,
promptOverride?: string,
options?: ExecuteAgentOptions,
): Promise<AgentExecutionResult> {
const agent = await prisma.agent.findUnique({
where: { id: agentId }
})
@@ -1768,44 +1831,53 @@ export async function executeAgent(agentId: string, userId: string, promptOverri
// Detect user language
const lang = await getUserLanguage(userId)
const quotaFeature = quotaFeatureForAgentType(agent.type || 'scraper')
const agentType = agent.type || 'scraper'
try {
const result = await withAiQuota(
userId,
quotaFeature,
async () => {
let inner: AgentExecutionResult
// Multi-step slide pipeline: 1 + N crédits (sauf si déjà réservé par run-for-note)
let quotaAmount = 1
let slideCountForCost: number | null = null
if (agentType === 'slide-generator') {
const intent = decodeSlideIntentDescription(agent.description)
slideCountForCost = intent.slideCount ?? null
quotaAmount = slideGenerateCreditCost(intent.slideCount)
}
const runAgent = async (): Promise<AgentExecutionResult> => {
// Slide generation uses a dedicated structured-output pipeline (no tool-calling).
// Lite models often fail at function calling; generateObject/JSON is reliable.
if (agentType === 'slide-generator') {
return executeSlideGeneratorAgent(agent, action.id, lang)
}
const hasTools = agent.tools && agent.tools !== '[]' && agent.tools !== 'null'
if (hasTools) {
inner = await executeToolUseAgent(agent, action.id, lang, promptOverride)
} else {
switch ((agent.type || 'scraper') as AgentType) {
case 'scraper':
inner = await executeScraperAgent(agent, action.id, lang)
break
case 'researcher':
inner = await executeResearcherAgent(agent, action.id, lang)
break
case 'monitor':
inner = await executeMonitorAgent(agent, action.id, lang)
break
case 'custom':
inner = await executeCustomAgent(agent, action.id, lang)
break
case 'slide-generator':
case 'excalidraw-generator':
inner = await executeToolUseAgent(agent, action.id, lang, promptOverride)
break
default:
inner = await executeScraperAgent(agent, action.id, lang)
}
return executeToolUseAgent(agent, action.id, lang, promptOverride)
}
return inner
},
{ lane: 'chat' },
)
switch (agentType as AgentType) {
case 'scraper':
return executeScraperAgent(agent, action.id, lang)
case 'researcher':
return executeResearcherAgent(agent, action.id, lang)
case 'monitor':
return executeMonitorAgent(agent, action.id, lang)
case 'custom':
return executeCustomAgent(agent, action.id, lang)
case 'excalidraw-generator':
return executeToolUseAgent(agent, action.id, lang, promptOverride)
default:
return executeScraperAgent(agent, action.id, lang)
}
}
try {
const result = options?.skipQuota
? await runAgent()
: await withAiQuota(userId, quotaFeature, runAgent, {
lane: 'chat',
amount: quotaAmount,
slideCount: slideCountForCost,
})
const nextRunUpdate: Record<string, Date | null> = {}
if (agent.frequency !== 'manual') {
@@ -1861,6 +1933,11 @@ export async function executeAgent(agentId: string, userId: string, promptOverri
const message = error instanceof Error ? error.message : 'Unknown error'
console.error(`[AgentExecutor] Agent ${agentId} failed:`, message)
// Pré-réservé par run-for-note : rembourser les crédits
if (options?.skipQuota && quotaAmount > 0) {
await releaseCredits(userId, quotaAmount, { feature: quotaFeature }).catch(() => {})
}
await prisma.agentAction.update({
where: { id: action.id },
data: { status: 'failure', log: message }

View File

@@ -0,0 +1,643 @@
/**
* Slide deck content quality: limits, normalization, narrative guards.
* Principles (consulting / PMC / think-cell):
* - One idea per slide
* - Action titles (insight, not topic labels)
* - Short claims, not walls of text
* - Charts/stats only with real numbers
* - Narrative arc, not density for density's sake
*/
import { stripHtmlToPlainText } from '@/lib/text/plain-text'
export const SLIDE_HARD_CAP = 8
export type SlideType =
| 'title'
| 'bullets'
| 'chart'
| 'stats'
| 'table'
| 'cards'
| 'timeline'
| 'quote'
| 'comparison'
| 'equation'
| 'image'
| 'summary'
export interface SlideLimit {
max: number
min: number
labelFr: string
labelEn: string
}
/** Max deck size from real semantic word count of source notes. */
export function slideLimitFromWordCount(wordCount: number): SlideLimit {
if (wordCount < 50) {
return {
max: 3,
min: 2,
labelFr: '23 slides MAX (note très courte)',
labelEn: '23 slides MAX (very short note)',
}
}
if (wordCount < 150) {
return {
max: 4,
min: 3,
labelFr: '34 slides MAX (note courte)',
labelEn: '34 slides MAX (short note)',
}
}
if (wordCount < 400) {
return {
max: 6,
min: 4,
labelFr: '46 slides MAX (note moyenne)',
labelEn: '46 slides MAX (medium note)',
}
}
return {
max: SLIDE_HARD_CAP,
min: 5,
labelFr: '58 slides MAX (note longue) — ne jamais dépasser 8',
labelEn: '58 slides MAX (long note) — never exceed 8',
}
}
export function stripNoteMarkdown(raw: string): string {
return raw
.replace(/^---[\s\S]*?---/m, '')
.replace(/^#{1,6}\s+/gm, '')
.replace(/[*_]{1,3}([^*_]+)[*_]{1,3}/g, '$1')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/https?:\/\/\S+/g, '')
.replace(
/^\*{0,2}(Connection to seed|Novelty score|Source brainstorm|Source note|Origin|Derived from|## Origin)[^:\n]*:?.*$/gim,
'',
)
.replace(/^---+$/gm, '')
.replace(/^>\s*/gm, '')
.replace(/```[\s\S]*?```/g, '')
.replace(/`[^`]+`/g, '')
.replace(/\s+/g, ' ')
.trim()
}
export function countNoteWords(raw: string): number {
const plain = stripHtmlToPlainText(raw || '')
const cleaned = stripNoteMarkdown(plain || raw || '')
return cleaned.split(/\s+/).filter(Boolean).length
}
/** Plain text for the LLM: full note when possible, smart head+tail if huge. */
export function prepareNoteTextForSlides(raw: string, maxChars = 12_000): string {
const plain = stripHtmlToPlainText(raw || '')
const cleaned = stripNoteMarkdown(plain || raw || '').trim()
if (cleaned.length <= maxChars) return cleaned
const head = Math.floor(maxChars * 0.7)
const tail = maxChars - head - 40
return `${cleaned.slice(0, head)}\n\n[…]\n\n${cleaned.slice(-tail)}`
}
function trimStr(s: unknown, max = 220): string {
if (typeof s !== 'string') return ''
const t = s.replace(/\s+/g, ' ').trim()
if (t.length <= max) return t
return `${t.slice(0, max - 1).trim()}`
}
function cleanList(items: unknown, maxItems: number, maxLen: number): string[] {
if (!Array.isArray(items)) return []
const out: string[] = []
for (const it of items) {
const s = trimStr(it, maxLen)
if (!s) continue
// Keep short tokens (math symbols, single-letter variables) — only drop empty
out.push(s)
if (out.length >= maxItems) break
}
return out
}
function isNumericLike(value: string): boolean {
return /[\d]/.test(value) || /%|€|\$|k\b|m\b|bn\b/i.test(value)
}
/**
* Normalize model output into a coherent deck.
* Does not invent content — only trims, caps, fixes structure.
*/
export function normalizeSlideDeck(input: {
title?: string
theme?: string
slides?: unknown[]
}): { title: string; theme: string; slides: Record<string, unknown>[] } {
const title = trimStr(input.title || 'Présentation', 80) || 'Présentation'
const theme = (typeof input.theme === 'string' && input.theme.trim()) || 'architectural-saas'
const raw = Array.isArray(input.slides) ? input.slides : []
let slides = raw
.filter((s): s is Record<string, unknown> => Boolean(s) && typeof s === 'object')
.map((s) => normalizeOneSlide(s))
.filter(Boolean) as Record<string, unknown>[]
slides = slides.slice(0, SLIDE_HARD_CAP)
// Ensure title first
if (slides.length === 0) {
slides = [
{ type: 'title', title, subtitle: '' },
{ type: 'summary', title: 'Points clés', items: ['Contenu source insuffisant pour un deck détaillé.'] },
]
} else if (slides[0]?.type !== 'title') {
slides.unshift({
type: 'title',
title,
subtitle: typeof slides[0]?.title === 'string' ? String(slides[0].title) : '',
})
slides = slides.slice(0, SLIDE_HARD_CAP)
}
// Ensure summary last (if ≥2 slides)
if (slides.length >= 2 && slides[slides.length - 1]?.type !== 'summary') {
if (slides.length >= SLIDE_HARD_CAP) {
slides[slides.length - 1] = {
type: 'summary',
title: 'À retenir',
items: extractKeyClaims(slides).slice(0, 4),
}
} else {
slides.push({
type: 'summary',
title: 'À retenir',
items: extractKeyClaims(slides).slice(0, 4),
})
}
}
// Drop empty / invalid charts (no inventing)
slides = slides.map((s) => {
if (s.type === 'chart') {
const data = Array.isArray(s.data) ? s.data : []
const valid = data.filter(
(d: any) => d && typeof d.label === 'string' && typeof d.value === 'number' && Number.isFinite(d.value),
)
if (valid.length < 2) {
return {
type: 'bullets',
title: trimStr(s.title || 'Données', 90),
items: extractKeyClaims([s]).slice(0, 4).length
? extractKeyClaims([s]).slice(0, 4)
: ['Pas de données numériques exploitables dans la source pour un graphique.'],
}
}
return { ...s, data: valid.slice(0, 8) }
}
if (s.type === 'stats') {
const stats = Array.isArray(s.stats) ? s.stats : []
const valid = stats.filter(
(st: any) => st && typeof st.value === 'string' && typeof st.label === 'string' && isNumericLike(String(st.value)),
)
if (valid.length === 0) {
return {
type: 'bullets',
title: trimStr(s.title || 'Indicateurs', 90),
items: cleanList(
stats.map((st: any) => (st?.label ? `${st.label}: ${st.value || ''}` : st?.value)).filter(Boolean),
4,
120,
),
}
}
return { ...s, stats: valid.slice(0, 4) }
}
return s
})
// Avoid two identical types in a row when possible (keep content, only if we can skip empty)
// No reordering that would break narrative — just leave as is after structural fixes.
return { title, theme, slides }
}
function normalizeOneSlide(s: Record<string, unknown>): Record<string, unknown> | null {
const type = String(s.type || 'bullets') as SlideType
switch (type) {
case 'title':
return {
type: 'title',
title: trimStr(s.title || 'Présentation', 80),
subtitle: trimStr(s.subtitle, 140) || undefined,
notes: trimStr(s.notes, 400) || undefined,
}
case 'bullets':
return {
type: 'bullets',
title: trimStr(s.title || 'Points clés', 90),
items: cleanList(s.items, 5, 140),
notes: trimStr(s.notes, 400) || undefined,
}
case 'summary':
return {
type: 'summary',
title: trimStr(s.title || 'À retenir', 90),
items: cleanList(s.items, 5, 140),
notes: trimStr(s.notes, 400) || undefined,
}
case 'cards': {
const cards = Array.isArray(s.cards) ? s.cards : []
const cleaned = cards
.slice(0, 6)
.map((c: any) => ({
title: trimStr(c?.title, 60),
description: trimStr(c?.description, 160),
}))
.filter((c) => c.title || c.description)
return {
type: 'cards',
title: trimStr(s.title || 'Piliers', 90),
cards: cleaned,
notes: trimStr(s.notes, 400) || undefined,
}
}
case 'chart':
return {
type: 'chart',
title: trimStr(s.title || 'Données', 90),
chartType: ['bar', 'horizontal-bar', 'line', 'donut', 'radar'].includes(String(s.chartType))
? s.chartType
: 'bar',
data: Array.isArray(s.data) ? s.data : [],
subtitle: trimStr(s.subtitle, 120) || undefined,
notes: trimStr(s.notes, 400) || undefined,
}
case 'stats':
return {
type: 'stats',
title: trimStr(s.title || 'Chiffres clés', 90),
stats: Array.isArray(s.stats)
? s.stats.slice(0, 4).map((st: any) => ({
value: trimStr(st?.value, 24),
label: trimStr(st?.label, 48),
}))
: [],
notes: trimStr(s.notes, 400) || undefined,
}
case 'table':
return {
type: 'table',
title: trimStr(s.title || 'Tableau', 90),
headers: cleanList(s.headers, 6, 40),
rows: Array.isArray(s.rows)
? s.rows.slice(0, 8).map((r: unknown) => cleanList(r, 6, 60))
: [],
notes: trimStr(s.notes, 400) || undefined,
}
case 'timeline': {
const events = Array.isArray(s.events) ? s.events : []
return {
type: 'timeline',
title: trimStr(s.title || 'Chronologie', 90),
events: events.slice(0, 6).map((e: any) => ({
date: trimStr(e?.date, 40),
title: trimStr(e?.title, 80),
description: trimStr(e?.description, 140) || undefined,
})),
notes: trimStr(s.notes, 400) || undefined,
}
}
case 'quote':
return {
type: 'quote',
quote: trimStr(s.quote, 280),
author: trimStr(s.author, 60) || undefined,
context: trimStr(s.context, 160) || undefined,
notes: trimStr(s.notes, 400) || undefined,
}
case 'comparison': {
const left = (s.left && typeof s.left === 'object' ? s.left : {}) as any
const right = (s.right && typeof s.right === 'object' ? s.right : {}) as any
return {
type: 'comparison',
title: trimStr(s.title || 'Comparaison', 90),
left: {
title: trimStr(left.title || 'A', 40),
points: cleanList(left.points, 4, 100),
score: trimStr(left.score, 16) || undefined,
},
right: {
title: trimStr(right.title || 'B', 40),
points: cleanList(right.points, 4, 100),
score: trimStr(right.score, 16) || undefined,
},
notes: trimStr(s.notes, 400) || undefined,
}
}
case 'equation':
return {
type: 'equation',
title: trimStr(s.title || 'Formules', 90),
equations: Array.isArray(s.equations)
? s.equations.slice(0, 4).map((eq: any) => ({
latex: trimStr(eq?.latex, 120),
label: trimStr(eq?.label, 60) || undefined,
}))
: [],
explanation: trimStr(s.explanation, 200) || undefined,
notes: trimStr(s.notes, 400) || undefined,
}
case 'image':
return {
type: 'image',
title: trimStr(s.title || 'Illustration', 90),
url: typeof s.url === 'string' ? s.url.trim() : undefined,
caption: trimStr(s.caption, 140) || undefined,
notes: trimStr(s.notes, 400) || undefined,
}
default:
return {
type: 'bullets',
title: trimStr(s.title || 'Points', 90),
items: cleanList(s.items || s.content, 5, 140),
}
}
}
/**
* A body slide is empty if it has a title but no real payload
* (the bug that shipped title-only decks).
*/
export function isSlideBodyEmpty(slide: Record<string, unknown>): boolean {
const type = String(slide.type || '')
if (type === 'title') {
// Title slides need a non-empty title (and ideally a subtitle, not required)
return !String(slide.title || '').trim()
}
switch (type) {
case 'bullets':
case 'summary':
return !Array.isArray(slide.items) || slide.items.filter((x) => String(x || '').trim()).length < 2
case 'cards':
return (
!Array.isArray(slide.cards) ||
slide.cards.filter((c: any) => String(c?.title || c?.description || '').trim()).length < 2
)
case 'stats':
return (
!Array.isArray(slide.stats) ||
slide.stats.filter((s: any) => String(s?.value || '').trim() && String(s?.label || '').trim()).length < 2
)
case 'chart':
return (
!Array.isArray(slide.data) ||
slide.data.filter((d: any) => d && typeof d.value === 'number' && String(d.label || '').trim()).length < 2
)
case 'table':
return !Array.isArray(slide.rows) || slide.rows.length < 1
case 'timeline':
return (
!Array.isArray(slide.events) ||
slide.events.filter((e: any) => String(e?.title || '').trim()).length < 2
)
case 'quote':
return !String(slide.quote || '').trim()
case 'comparison': {
const left = (slide.left || {}) as any
const right = (slide.right || {}) as any
const lp = Array.isArray(left.points) ? left.points.filter(Boolean).length : 0
const rp = Array.isArray(right.points) ? right.points.filter(Boolean).length : 0
return lp < 1 || rp < 1
}
case 'equation':
return !Array.isArray(slide.equations) || slide.equations.length < 1
case 'image':
// image without URL is weak but caption-only is still something; require title at least
return !String(slide.title || '').trim()
default:
return true
}
}
export function listEmptyBodySlides(slides: Record<string, unknown>[]): number[] {
const empty: number[] = []
slides.forEach((s, i) => {
if (s.type === 'title') return
if (isSlideBodyEmpty(s)) empty.push(i)
})
return empty
}
/**
* Hard gate: refuse to ship a deck that is mostly titles.
* Content slides (non-title) must have real bodies.
* STEM: if formulas were extracted from the note, deck MUST include equation slides with latex.
*/
export function assertDeckHasSubstance(
deck: {
title?: string
slides: Record<string, unknown>[]
},
opts?: { requireFormulas?: string[] },
): { ok: true } | { ok: false; reason: string; emptyIndexes: number[] } {
const slides = deck.slides || []
if (slides.length < 2) {
return { ok: false, reason: 'Deck has fewer than 2 slides', emptyIndexes: [] }
}
const bodySlides = slides.filter((s) => s.type !== 'title')
if (bodySlides.length === 0) {
return { ok: false, reason: 'Deck has no body slides', emptyIndexes: [] }
}
const emptyIndexes = listEmptyBodySlides(slides)
if (emptyIndexes.length > 0) {
return {
ok: false,
reason: `${emptyIndexes.length} body slide(s) have titles only (no bullets/cards/stats/…)`,
emptyIndexes,
}
}
// At least 2 content claims across the deck
const claims = extractKeyClaims(slides)
if (claims.length < 2) {
return { ok: false, reason: 'Deck has insufficient claims/body text', emptyIndexes: [] }
}
// STEM gate: note had formulas → at least one equation slide with non-empty latex
const required = opts?.requireFormulas?.filter((f) => f && f.trim()) || []
if (required.length > 0) {
const eqSlides = slides.filter((s) => s.type === 'equation')
const latexCount = eqSlides.reduce((n, s) => {
const eqs = Array.isArray(s.equations) ? s.equations : []
return (
n +
eqs.filter((e: any) => typeof e?.latex === 'string' && e.latex.trim().length >= 2).length
)
}, 0)
if (latexCount < 1) {
return {
ok: false,
reason: `STEM gate: note has ${required.length} formula(s) but deck has no equation slides with LaTeX`,
emptyIndexes: [],
}
}
}
return { ok: true }
}
/** Inject equation slides from extracted formulas (deterministic STEM fix). */
export function injectEquationSlidesFromFormulas(
deck: { title: string; theme: string; slides: Record<string, unknown>[] },
formulas: string[],
opts?: { lang?: 'fr' | 'en' },
): { title: string; theme: string; slides: Record<string, unknown>[] } {
const forms = formulas.map((f) => f.trim()).filter((f) => f.length >= 2).slice(0, 8)
if (forms.length === 0) return deck
const hasEq = deck.slides.some((s) => {
if (s.type !== 'equation') return false
const eqs = Array.isArray(s.equations) ? s.equations : []
return eqs.some((e: any) => String(e?.latex || '').trim().length >= 2)
})
if (hasEq) return deck
const lang = opts?.lang || 'fr'
const titleSlide = deck.slides.find((s) => s.type === 'title')
const summarySlide = deck.slides.find((s) => s.type === 'summary')
const middle = deck.slides.filter((s) => s.type !== 'title' && s.type !== 'summary')
const groups: string[][] = []
for (let i = 0; i < forms.length; i += 2) {
groups.push(forms.slice(i, i + 2))
}
const eqSlides = groups.slice(0, 2).map((group, gi) => ({
type: 'equation',
title:
gi === 0
? lang === 'fr'
? 'Équations fondamentales'
: 'Core equations'
: lang === 'fr'
? 'Formules complémentaires'
: 'Additional formulas',
equations: group.map((latex, i) => ({
latex,
label: lang === 'fr' ? `Formule ${gi * 2 + i + 1}` : `Formula ${gi * 2 + i + 1}`,
})),
explanation:
lang === 'fr'
? 'Formules extraites de la note source'
: 'Formulas extracted from the source note',
}))
const slides = [
titleSlide || { type: 'title', title: deck.title },
...eqSlides,
...middle,
summarySlide || {
type: 'summary',
title: lang === 'fr' ? 'À retenir' : 'Key takeaways',
items:
lang === 'fr'
? ['Revoir les formules', 'Appliquer sur un exemple', 'Vérifier les conditions']
: ['Review formulas', 'Apply on an example', 'Check conditions'],
},
].slice(0, SLIDE_HARD_CAP)
return normalizeSlideDeck({ title: deck.title, theme: deck.theme, slides })
}
function extractKeyClaims(slides: Record<string, unknown>[]): string[] {
const claims: string[] = []
for (const s of slides) {
if (s.type === 'title' || s.type === 'summary') continue
if (Array.isArray(s.items)) {
for (const it of s.items) {
if (typeof it === 'string' && it.trim()) claims.push(trimStr(it, 120))
}
}
if (Array.isArray(s.cards)) {
for (const c of s.cards as any[]) {
const line = [c?.title, c?.description].filter(Boolean).join(' — ')
if (line) claims.push(trimStr(line, 120))
}
}
if (typeof s.title === 'string' && s.type !== 'bullets') {
claims.push(trimStr(s.title, 100))
}
}
return [...new Set(claims)].filter(Boolean)
}
/** System-prompt quality rules (shared FR/EN cores built in agent-executor). */
export const SLIDE_CONTENT_PRINCIPLES_FR = `
═══ QUALITÉ DU CONTENU (OBLIGATOIRE) ═══
Tu es un consultant McKinsey/BCG qui structure un deck exécutif. Le public lit les titres d'abord.
1. UNE IDÉE PAR SLIDE
- Le "title" de chaque slide est un TITRE D'ACTION : une phrase d'insight, pas un libellé de sujet.
- Mauvais : "Contexte" / "Analyse" / "Résultats"
- Bon : "Le délai de livraison a doublé en 6 mois" / "Trois leviers expliquent 80 % du retard"
2. ARC NARRATIF (dans l'ordre)
title → contexte/problème → insight principal → preuves (chiffres ou exemples) → implications/options → summary (next steps)
Ne pas coller une liste de sujets sans fil conducteur.
3. TEXTE COURT ET UTILE
- Bullets : 3 à 5 MAX, 1 claim distinct par ligne (818 mots idéalement).
- INTERDIT : paragraphes, répétition du titre, filler ("Il est important de noter…", "En conclusion on peut dire…").
- Chaque bullet doit apporter une information nouvelle tirée de la note.
4. DONNÉES HONNÊTES
- "chart" / "stats" UNIQUEMENT si des chiffres REELS figurent dans la note.
- INTERDIT d'inventer des KPI, scores radar, pourcentages ou tendances.
- Sans chiffres : cards, comparison, timeline, quote, bullets — jamais de faux graphiques.
5. VARIÉTÉ UTILE
- Varier les types selon le contenu (pas 4× bullets d'affilée si d'autres formats collent mieux).
- Premier slide = "title", dernier = "summary" avec 35 takeaways actionnables.
6. FIDÉLITÉ À LA SOURCE
- Reformuler pour la clarté, ne pas inventer de faits, noms, dates ou conclusions absents de la note.
- Si la note est courte : peu de slides, contenu dense et fidèle — pas de padding.
7. NOTES ORATEUR (optionnel)
- Champ "notes" : 12 phrases pour le présentateur (non affiché sur la slide).
`
export const SLIDE_CONTENT_PRINCIPLES_EN = `
═══ CONTENT QUALITY (MANDATORY) ═══
You are a McKinsey/BCG consultant building an executive deck. Audiences read titles first.
1. ONE IDEA PER SLIDE
- Each slide "title" is an ACTION TITLE: an insight sentence, not a topic label.
- Bad: "Context" / "Analysis" / "Results"
- Good: "Delivery time doubled in 6 months" / "Three levers explain 80% of the delay"
2. NARRATIVE ARC (in order)
title → context/problem → key insight → evidence (numbers or examples) → implications/options → summary (next steps)
Do not dump a topic list without a storyline.
3. SHORT, USEFUL TEXT
- Bullets: 35 MAX, one distinct claim per line (ideally 818 words).
- FORBIDDEN: paragraphs, restating the title, filler ("It is important to note…").
- Each bullet must add new information from the note.
4. HONEST DATA
- "chart" / "stats" ONLY if REAL numbers appear in the note.
- NEVER invent KPIs, radar scores, percentages, or trends.
- No numbers: use cards, comparison, timeline, quote, bullets — never fake charts.
5. USEFUL VARIETY
- Vary types based on content (not 4× bullets if another type fits better).
- First slide = "title", last = "summary" with 35 actionable takeaways.
6. FIDELITY TO SOURCE
- Rephrase for clarity; do not invent facts, names, dates, or conclusions absent from the note.
- Short notes → few slides, faithful content — no padding.
7. SPEAKER NOTES (optional)
- "notes" field: 12 sentences for the presenter (not shown on the slide).
`

View File

@@ -0,0 +1,692 @@
/**
* Slide deck generation — DeckForge / PPTAgent / Hermes-inspired pipeline.
*
* Sources of truth (research):
* - DeckForge: intent → outline → expand PER SLIDE → refine → validate IR
* https://github.com/Whatsonyourmind/deckforge
* - PPTAgent: outline with retrieved document content, then iterative fill
* https://github.com/icip-cas/PPTAgent arXiv:2501.03936
* - Hermes PowerPoint skill: domain palettes, no empty slides, QA before ship
* https://hermes-agent.nousresearch.com/docs/user-guide/skills/bundled/productivity/productivity-powerpoint
* - OpenClaw ppt-html-pipeline: per-page blueprint, no large blank regions
* https://github.com/Yuancircle/openclaw-ppt-html-pipeline
*
* NEVER persists a deck that fails substance validation.
*/
import { generateObject, generateText } from 'ai'
import { z } from 'zod'
import { prisma } from '@/lib/prisma'
import { buildPresentationHTML } from '@/lib/ai/tools/slides-html-builder'
import {
assertDeckHasSubstance,
countNoteWords,
injectEquationSlidesFromFormulas,
normalizeSlideDeck,
prepareNoteTextForSlides,
slideLimitFromWordCount,
SLIDE_HARD_CAP,
} from '@/lib/ai/services/slide-content-quality'
import { extractSourceAssets, type SourceAssets } from '@/lib/ai/services/slide-source-assets'
import {
intentPromptHints,
normalizeSlideIntent,
type SlideIntent,
} from '@/lib/ai/services/slide-intent'
import { getSystemConfig } from '@/lib/config'
import { getSlidesProvider } from '@/lib/ai/factory'
// ── Types catalog (includes equation — critical for STEM notes) ──────────────
const SlideTypeEnum = z.enum([
'title',
'bullets',
'equation',
'cards',
'comparison',
'timeline',
'stats',
'chart',
'table',
'quote',
'summary',
])
const OutlineSlideSchema = z.object({
position: z.number(),
type: SlideTypeEnum,
/** Assertive headline, max ~10 words (DeckForge: max 8) */
headline: z.string(),
/** 25 key points that MUST appear on the slide body */
keyPoints: z.array(z.string()).min(1).max(6),
/** For equation slides: latex strings to include */
formulas: z.array(z.string()).optional(),
narrativeRole: z.enum(['opening', 'evidence', 'transition', 'conclusion', 'data']).optional(),
})
const OutlineSchema = z.object({
title: z.string(),
narrativeArc: z.enum(['pyramid', 'scr', 'mece', 'chronological', 'pedagogical']).optional(),
slides: z.array(OutlineSlideSchema).min(2).max(SLIDE_HARD_CAP),
})
const ExpandedSlideSchema = z.object({
type: SlideTypeEnum,
title: z.string(),
subtitle: z.string().optional(),
items: z.array(z.string()).optional(),
equations: z.array(z.object({ latex: z.string(), label: z.string().optional() })).optional(),
explanation: z.string().optional(),
cards: z.array(z.object({ title: z.string(), description: z.string() })).optional(),
left: z
.object({ title: z.string(), points: z.array(z.string()), score: z.string().optional() })
.optional(),
right: z
.object({ title: z.string(), points: z.array(z.string()), score: z.string().optional() })
.optional(),
events: z
.array(z.object({ date: z.string(), title: z.string(), description: z.string().optional() }))
.optional(),
stats: z.array(z.object({ value: z.string(), label: z.string() })).optional(),
chartType: z.enum(['bar', 'horizontal-bar', 'line', 'donut', 'radar']).optional(),
data: z.array(z.object({ label: z.string(), value: z.number() })).optional(),
headers: z.array(z.string()).optional(),
rows: z.array(z.array(z.string())).optional(),
quote: z.string().optional(),
author: z.string().optional(),
context: z.string().optional(),
notes: z.string().optional(),
})
export interface GenerateSlideDeckParams {
userId: string
noteIds?: string[]
sourceNotebookId?: string | null
theme?: string | null
template?: string | null
lang?: 'fr' | 'en'
contentLanguage?: string | null
actionId?: string | null
/** Product intent: purpose, audience, slide count */
intent?: SlideIntent | null
}
export interface GenerateSlideDeckResult {
success: boolean
canvasId?: string
canvasName?: string
slideCount?: number
error?: string
mode?: string
}
const LANG_NAMES: Record<string, string> = {
fr: 'French',
en: 'English',
es: 'Spanish',
de: 'German',
it: 'Italian',
pt: 'Portuguese',
nl: 'Dutch',
pl: 'Polish',
ru: 'Russian',
zh: 'Chinese',
ja: 'Japanese',
ko: 'Korean',
ar: 'Arabic',
fa: 'Persian (Farsi)',
hi: 'Hindi',
}
// ── LLM helpers ──────────────────────────────────────────────────────────────
export function extractJsonPayload(text: string): unknown | null {
if (!text) return null
const raw = text.trim()
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i)
const candidate = fenced?.[1]?.trim() || raw
const tryParse = (s: string) => {
try {
return JSON.parse(s)
} catch {
return null
}
}
let parsed = tryParse(candidate)
if (parsed) return parsed
const brace = candidate.match(/\{[\s\S]*\}/)
if (brace) parsed = tryParse(brace[0])
return parsed
}
async function llmObject<T>(opts: {
model: any
schema: z.ZodType<T>
system: string
prompt: string
}): Promise<T> {
const { model, schema, system, prompt } = opts
try {
const { object } = await generateObject({ model, schema, system, prompt })
return object as T
} catch (err) {
console.warn('[SlideDeck] generateObject failed → text JSON:', (err as Error)?.message)
const { text } = await generateText({
model,
system,
prompt: `${prompt}\n\nOutput ONE JSON object only.`,
})
const raw = extractJsonPayload(text)
if (!raw) throw new Error(`Non-JSON model output: ${String(text).slice(0, 180)}`)
const parsed = schema.safeParse(raw)
if (parsed.success) return parsed.data
return raw as T
}
}
// ── Notes load ───────────────────────────────────────────────────────────────
async function loadNotes(params: GenerateSlideDeckParams) {
if (params.noteIds?.length) {
return prisma.note.findMany({
where: {
id: { in: params.noteIds },
userId: params.userId,
isArchived: false,
trashedAt: null,
},
select: { id: true, title: true, content: true, createdAt: true, language: true },
})
}
if (params.sourceNotebookId) {
return prisma.note.findMany({
where: {
notebookId: params.sourceNotebookId,
userId: params.userId,
isArchived: false,
trashedAt: null,
},
orderBy: { createdAt: 'desc' },
take: 15,
select: { id: true, title: true, content: true, createdAt: true, language: true },
})
}
return []
}
// ── Stage prompts (DeckForge-style) ──────────────────────────────────────────
function outlineSystem(lang: 'fr' | 'en', maxSlides: number, assets: SourceAssets): string {
const mathRule = assets.hasMath
? lang === 'fr'
? `DOMAINE MATH/STEM DÉTECTÉ: tu DOIS inclure au moins ${Math.min(2, Math.max(1, assets.formulas.length))} slides de type "equation" qui portent les formules extraites. Ne remplace PAS les formules par des bullets vagues.`
: `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.`
: ''
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).
Règles (DeckForge + think-cell):
- Max ${maxSlides} slides
- Headline ASSERTIVE, max 10 mots (pas "Contexte", "Introduction")
- keyPoints: 25 faits de la note par slide
- 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
- Slide 1 = title, dernière = summary
${mathRule}
Réponds en JSON OutlineSchema.`
}
return `You are DeckForge/PPTAgent narrative architect for Memento.
Output ONLY outline JSON (not full slide bodies).
Rules:
- Max ${maxSlides} slides
- ASSERTIVE headlines, max 10 words
- keyPoints: 25 facts from the note per slide
- Pedagogical arc for lessons: title → definitions/equations → properties → examples → summary
- Types: title | equation | bullets | cards | comparison | timeline | stats | chart | table | quote | summary
- First=title, last=summary
${mathRule}
JSON OutlineSchema only.`
}
function expandOneSystem(lang: 'fr' | 'en'): string {
if (lang === 'fr') {
return `Tu es le rédacteur de slides DeckForge. Tu EXPANDES UNE slide à la fois.
INTERDIT: slide avec seulement un titre.
OBLIGATOIRE selon type:
- title: title + subtitle (1 phrase)
- equation: title + equations[{latex,label?}] (min 1, latex fidèle à la note) + explanation courte
- bullets: title + items[3-5] claims ≤18 mots
- cards: title + cards[2-4] {title, description}
- 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
- summary: title + items[3-5] actionnables
- quote: quote non vide
JSON ExpandedSlide uniquement.`
}
return `You are DeckForge slide writer. Expand ONE slide only.
FORBIDDEN: title-only slides.
REQUIRED by type:
- title: title + subtitle
- equation: title + equations[{latex,label?}] min 1 + short explanation
- bullets: title + items[3-5] ≤18 words each
- cards: title + cards[2-4]
- comparison: left/right points[2-4] each
- timeline: events[2-5]
- stats/chart: only with real numbers provided
- summary: items[3-5]
- quote: non-empty quote
ExpandedSlide JSON only.`
}
/** Force math slides into outline when server extracted formulas (PPTAgent retrieval). */
function enforceMathOutline(
outline: z.infer<typeof OutlineSchema>,
assets: SourceAssets,
maxSlides: number,
): z.infer<typeof OutlineSchema> {
if (!assets.hasMath || assets.formulas.length === 0) return outline
const hasEq = outline.slides.some((s) => s.type === 'equation')
if (hasEq) {
// Ensure equation slides list formulas
return {
...outline,
slides: outline.slides.map((s, i) => {
if (s.type !== 'equation') return s
const chunk = assets.formulas.slice(i % assets.formulas.length, (i % assets.formulas.length) + 2)
return {
...s,
formulas: s.formulas?.length ? s.formulas : chunk.length ? chunk : assets.formulas.slice(0, 2),
}
}),
}
}
// Inject equation slides after title
const title = outline.slides.find((s) => s.type === 'title') || {
position: 1,
type: 'title' as const,
headline: outline.title,
keyPoints: assets.keySentences.slice(0, 2),
narrativeRole: 'opening' as const,
}
const rest = outline.slides.filter((s) => s.type !== 'title' && s.type !== 'summary')
const summary = outline.slides.find((s) => s.type === 'summary') || {
position: 99,
type: 'summary' as const,
headline: 'Points clés',
keyPoints: assets.keySentences.slice(0, 3),
narrativeRole: 'conclusion' as const,
}
const eqSlides: z.infer<typeof OutlineSlideSchema>[] = []
const groups = Math.min(2, Math.ceil(assets.formulas.length / 2))
for (let g = 0; g < groups; g++) {
const formulas = assets.formulas.slice(g * 2, g * 2 + 2)
eqSlides.push({
position: g + 2,
type: 'equation',
headline: g === 0 ? 'Équations fondamentales' : 'Formules complémentaires',
keyPoints: formulas.map((f) => f.slice(0, 80)),
formulas,
narrativeRole: 'evidence',
})
}
let slides = [title, ...eqSlides, ...rest, summary].slice(0, maxSlides)
// Ensure last is summary
if (slides[slides.length - 1]?.type !== 'summary') {
slides = [...slides.slice(0, maxSlides - 1), summary]
}
return {
...outline,
slides: slides.map((s, i) => ({ ...s, position: i + 1 })),
}
}
function fallbackExpandFromOutline(
o: z.infer<typeof OutlineSlideSchema>,
assets: SourceAssets,
): z.infer<typeof ExpandedSlideSchema> {
const type = o.type
const title = o.headline
if (type === 'title') {
return {
type: 'title',
title,
subtitle: o.keyPoints[0] || assets.keySentences[0] || '',
}
}
if (type === 'equation') {
const forms = o.formulas?.length ? o.formulas : assets.formulas.slice(0, 3)
return {
type: 'equation',
title,
equations: (forms.length ? forms : ['f(x) = ?']).map((latex, i) => ({
latex,
label: o.keyPoints[i] || `Formule ${i + 1}`,
})),
explanation: o.keyPoints.join(' · ').slice(0, 200),
}
}
if (type === 'cards') {
const pts = o.keyPoints.length >= 2 ? o.keyPoints : assets.keySentences.slice(0, 3)
return {
type: 'cards',
title,
cards: pts.slice(0, 4).map((p, i) => ({
title: `Point ${i + 1}`,
description: p,
})),
}
}
if (type === 'summary') {
return {
type: 'summary',
title,
items: (o.keyPoints.length ? o.keyPoints : assets.keySentences).slice(0, 5),
}
}
if (type === 'stats' && assets.numbers.length >= 2) {
return {
type: 'stats',
title,
stats: assets.numbers.slice(0, 4).map((n) => ({
value: n.raw,
label: n.label || title,
})),
}
}
if (type === 'chart' && assets.numbers.length >= 2) {
return {
type: 'chart',
title,
chartType: 'bar',
data: assets.numbers.slice(0, 6).map((n) => ({
label: n.label.slice(0, 20) || n.raw,
value: n.value,
})),
}
}
// default bullets — use keyPoints + sentences to fill density
const items = [
...o.keyPoints,
...assets.keySentences.filter((s) => !o.keyPoints.some((k) => s.includes(k.slice(0, 20)))),
].slice(0, 5)
while (items.length < 3 && assets.keySentences[items.length]) {
items.push(assets.keySentences[items.length]!)
}
return {
type: type === 'quote' ? 'bullets' : type === 'comparison' ? 'bullets' : type === 'timeline' ? 'bullets' : 'bullets',
title,
items: items.length >= 2 ? items : [title, assets.keySentences[0] || 'Voir la note source'].filter(Boolean),
}
}
// ── Persist ──────────────────────────────────────────────────────────────────
async function persist(
userId: string,
deck: ReturnType<typeof normalizeSlideDeck>,
actionId: string | null | undefined,
mode: string,
) {
const html = buildPresentationHTML({
title: deck.title,
theme: deck.theme,
slides: deck.slides as any,
})
const canvas = await prisma.canvas.create({
data: {
name: deck.title || 'Présentation',
data: JSON.stringify({
type: 'slides',
title: deck.title,
html,
slideCount: deck.slides.length,
theme: deck.theme,
mode,
spec: { title: deck.title, theme: deck.theme, slides: deck.slides },
}),
userId,
},
})
if (actionId) {
await prisma.agentAction
.update({
where: { id: actionId },
data: {
status: 'success',
result: canvas.id,
log: `Slides OK (${mode}): ${deck.slides.length} slides, ${Math.round(html.length / 1024)}KB, substance OK`,
},
})
.catch(() => {})
}
return canvas
}
// ── Main ─────────────────────────────────────────────────────────────────────
export async function generateSlideDeck(params: GenerateSlideDeckParams): Promise<GenerateSlideDeckResult> {
const lang = params.lang === 'en' ? 'en' : 'fr'
const notes = await loadNotes(params)
if (!notes.length) {
return {
success: false,
error: lang === 'fr' ? 'Aucune note source.' : 'No source notes.',
}
}
const noteLang = notes[0]?.language
const contentLang =
params.contentLanguage ||
(noteLang && LANG_NAMES[noteLang] ? LANG_NAMES[noteLang] : lang === 'fr' ? 'French' : 'English')
const combined = notes.map((n) => n.content || '').join('\n\n')
const assets = extractSourceAssets(combined)
const notesText = notes
.map((n) => `### ${n.title || 'Note'}\n${prepareNoteTextForSlides(n.content || '', 10_000)}`)
.join('\n\n')
const wordCount = notes.reduce((s, n) => s + countNoteWords(n.content || ''), 0)
const limit = slideLimitFromWordCount(wordCount)
const intent = normalizeSlideIntent(params.intent || { template: params.template || 'auto' })
// User-chosen count overrides word-count heuristic
const targetMax = intent.slideCount || limit.max
const targetMin = intent.slideCount || limit.min
const theme =
params.theme && params.theme !== 'auto'
? params.theme
: assets.hasMath || intent.purpose === 'course'
? 'clinical-precision'
: 'architectural-saas'
// Admin-configured slides model (or chat if slides not set). Never rename model IDs.
const sysConfig = await getSystemConfig()
const model = getSlidesProvider(sysConfig).getModel()
const intentHints = intentPromptHints(intent, lang)
try {
// ── Stage 1: Outline (DeckForge outliner) ──
const assetsBlock = [
assets.formulas.length
? `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')}`
: '',
assets.keySentences.length
? `PHRASES CLÉS:\n${assets.keySentences
.slice(0, 8)
.map((s) => `- ${s}`)
.join('\n')}`
: '',
]
.filter(Boolean)
.join('\n\n')
let outline = await llmObject({
model,
schema: OutlineSchema,
system: outlineSystem(lang, targetMax, assets),
prompt:
lang === 'fr'
? `Langue du deck: ${contentLang}.\nNombre de slides cible: ${targetMax} (min ${targetMin}).\nThème: ${theme}.\n${intentHints ? `\n## Intent produit\n${intentHints}\n` : ''}\n## Assets serveur\n${assetsBlock || '(aucun)'}\n\n## Note\n${notesText}\n\nProduis l'outline JSON.`
: `Deck language: ${contentLang}.\nTarget slides: ${targetMax} (min ${targetMin}).\nTheme: ${theme}.\n${intentHints ? `\n## Product intent\n${intentHints}\n` : ''}\n## Server assets\n${assetsBlock || '(none)'}\n\n## Note\n${notesText}\n\nProduce outline JSON.`,
})
// STEM or course purpose → force equation slides when formulas exist
if (assets.hasMath || intent.purpose === 'course') {
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))
}
}
// ── Stage 3: Normalize + STEM inject + substance gate ──
let normalized = normalizeSlideDeck({
title: outline.title,
theme,
slides: expanded as unknown[],
})
// Always inject formulas if missing (deterministic — never ship STEM without equations)
if (assets.formulas.length > 0) {
normalized = injectEquationSlidesFromFormulas(normalized, assets.formulas, { lang })
}
const stemOpts = {
requireFormulas: assets.formulas.length > 0 ? assets.formulas : undefined,
}
let gate = assertDeckHasSubstance(normalized, stemOpts)
let mode = 'outline+per-slide-expand'
if (!gate.ok) {
// Deterministic fill for empty body slides using outline + assets
const slides = normalized.slides.map((s, i) => {
if (s.type === 'title') return s
if (!gate.ok && gate.emptyIndexes.includes(i)) {
const o = outline.slides[Math.min(i, outline.slides.length - 1)]!
return fallbackExpandFromOutline(o, assets) as unknown as Record<string, unknown>
}
return s
})
normalized = normalizeSlideDeck({
title: outline.title,
theme,
slides,
})
if (assets.formulas.length > 0) {
normalized = injectEquationSlidesFromFormulas(normalized, assets.formulas, { lang })
}
gate = assertDeckHasSubstance(normalized, stemOpts)
mode = 'outline+expand+deterministic-fill'
}
if (!gate.ok) {
const msg =
lang === 'fr'
? `Refus de livrer un deck incomplet: ${gate.reason}`
: `Refusing incomplete deck: ${gate.reason}`
if (params.actionId) {
await prisma.agentAction
.update({ where: { id: params.actionId }, data: { status: 'failure', log: msg } })
.catch(() => {})
}
return { success: false, error: msg, mode }
}
if (!normalized.theme) normalized.theme = theme
const canvas = await persist(params.userId, normalized, params.actionId, mode)
return {
success: true,
canvasId: canvas.id,
canvasName: canvas.name,
slideCount: normalized.slides.length,
mode,
}
} catch (e: any) {
console.error('[SlideDeck] FATAL', e)
const message = e?.message || String(e)
if (params.actionId) {
await prisma.agentAction
.update({
where: { id: params.actionId },
data: {
status: 'failure',
log: lang === 'fr' ? `Échec slides: ${message}` : `Slide fail: ${message}`,
},
})
.catch(() => {})
}
return { success: false, error: message }
}
}

View File

@@ -0,0 +1,151 @@
/**
* Slide generation intent (Sprint A product layer).
* Passed from UI → API → agent.description → generateSlideDeck.
*/
export type SlidePurpose =
| 'auto'
| 'course'
| 'board'
| 'project'
| 'strategy'
| 'pitch'
| 'summary'
export type SlideAudience = 'auto' | 'student' | 'board' | 'team' | 'client' | 'general'
export interface SlideIntent {
purpose?: SlidePurpose
audience?: SlideAudience
/** Target body slides including title+summary, clamped 38 */
slideCount?: number
/** Existing executive template id or auto */
template?: string
}
const PURPOSE_SET = new Set<string>([
'auto',
'course',
'board',
'project',
'strategy',
'pitch',
'summary',
])
const AUDIENCE_SET = new Set<string>([
'auto',
'student',
'board',
'team',
'client',
'general',
])
export function clampSlideCount(n: unknown): number | undefined {
const v = typeof n === 'number' ? n : typeof n === 'string' ? parseInt(n, 10) : NaN
if (!Number.isFinite(v)) return undefined
return Math.min(8, Math.max(3, Math.round(v)))
}
export function normalizeSlideIntent(raw: Partial<SlideIntent> | null | undefined): SlideIntent {
if (!raw || typeof raw !== 'object') return {}
const purpose = raw.purpose && PURPOSE_SET.has(raw.purpose) ? raw.purpose : 'auto'
const audience = raw.audience && AUDIENCE_SET.has(raw.audience) ? raw.audience : 'auto'
const slideCount = clampSlideCount(raw.slideCount)
const template =
typeof raw.template === 'string' && raw.template.trim() ? raw.template.trim() : 'auto'
return { purpose, audience, slideCount, template }
}
/** Encode intent into agent.description (backward compatible with template:xxx). */
export function encodeSlideIntentDescription(intent: SlideIntent): string {
const n = normalizeSlideIntent(intent)
// Prefer structured JSON for the structured pipeline
return `slide-intent:${JSON.stringify(n)}`
}
/** Decode agent.description → intent */
export function decodeSlideIntentDescription(description?: string | null): SlideIntent {
if (!description) return {}
if (description.startsWith('slide-intent:')) {
try {
return normalizeSlideIntent(JSON.parse(description.slice('slide-intent:'.length)))
} catch {
return {}
}
}
if (description.startsWith('template:')) {
const template = description.replace('template:', '').trim() || 'auto'
// Map legacy templates to purpose
const purposeMap: Record<string, SlidePurpose> = {
'board-update': 'board',
'project-status': 'project',
'strategy-review': 'strategy',
'quarterly-results': 'board',
}
return normalizeSlideIntent({
template,
purpose: purposeMap[template] || 'auto',
})
}
return {}
}
/** Human guidance injected into outline/expand prompts */
export function intentPromptHints(intent: SlideIntent, lang: 'fr' | 'en'): string {
const n = normalizeSlideIntent(intent)
const lines: string[] = []
if (n.purpose && n.purpose !== 'auto') {
const mapFr: Record<SlidePurpose, string> = {
auto: '',
course: 'Type: COURS / leçon — arc pédagogique (définitions → formules → exemples → résumé). Priorité aux slides equation si maths.',
board: 'Type: COMITÉ / board — KPIs, décisions, risques, next steps.',
project: 'Type: SUIVI PROJET — avancement, blockers, livrables, roadmap.',
strategy: 'Type: STRATÉGIE — options, trade-offs, recommandations.',
pitch: 'Type: PITCH — problème, solution, preuve, call to action.',
summary: 'Type: SYNTHÈSE — peu de slides, dense, takeaways actionnables.',
}
const mapEn: Record<SlidePurpose, string> = {
auto: '',
course: 'Type: LESSON — pedagogical arc (defs → formulas → examples → summary). Prefer equation slides for math.',
board: 'Type: BOARD — KPIs, decisions, risks, next steps.',
project: 'Type: PROJECT STATUS — progress, blockers, deliverables, roadmap.',
strategy: 'Type: STRATEGY — options, trade-offs, recommendations.',
pitch: 'Type: PITCH — problem, solution, proof, CTA.',
summary: 'Type: SUMMARY — few dense slides, actionable takeaways.',
}
lines.push(lang === 'fr' ? mapFr[n.purpose] : mapEn[n.purpose])
}
if (n.audience && n.audience !== 'auto') {
const mapFr: Record<SlideAudience, string> = {
auto: '',
student: 'Audience: étudiants — clarté, définitions, exemples, formules explicites.',
board: 'Audience: comité de direction — concis, chiffres, décisions.',
team: 'Audience: équipe — opérationnel, actions, responsabilités.',
client: 'Audience: client — bénéfices, preuves, sans jargon interne.',
general: 'Audience: grand public — langage simple.',
}
const mapEn: Record<SlideAudience, string> = {
auto: '',
student: 'Audience: students — clarity, definitions, examples, explicit formulas.',
board: 'Audience: board — concise, numbers, decisions.',
team: 'Audience: team — operational, actions, ownership.',
client: 'Audience: client — benefits, proof, no internal jargon.',
general: 'Audience: general — plain language.',
}
lines.push(lang === 'fr' ? mapFr[n.audience] : mapEn[n.audience])
}
if (n.slideCount) {
lines.push(
lang === 'fr'
? `Nombre de slides imposé: EXACTEMENT ${n.slideCount} (title + corps + summary inclus).`
: `Slide count required: EXACTLY ${n.slideCount} (including title + body + summary).`,
)
}
return lines.filter(Boolean).join('\n')
}

View File

@@ -0,0 +1,103 @@
/**
* Server-side extraction of usable assets from a note BEFORE any slide LLM call.
* Inspired by PPTAgent (retrieve relevant content per slide) and DeckForge (data_needs).
* Critical for STEM notes: formulas must be harvested, not hoped for from the model.
*/
export interface SourceAssets {
formulas: string[]
numbers: Array<{ label: string; value: number; raw: string }>
keySentences: string[]
hasMath: boolean
hasNumbers: boolean
wordCount: number
}
/** Extract LaTeX / equation-like fragments from note plain text or HTML. */
export function extractFormulas(raw: string): string[] {
if (!raw) return []
const found: string[] = []
const push = (s: string) => {
const t = s.replace(/\s+/g, ' ').trim()
if (t.length >= 2 && t.length <= 280 && !found.includes(t)) found.push(t)
}
// $$ ... $$
for (const m of raw.matchAll(/\$\$([\s\S]+?)\$\$/g)) push(m[1] || '')
// \[ ... \]
for (const m of raw.matchAll(/\\\[([\s\S]+?)\\\]/g)) push(m[1] || '')
// \( ... \)
for (const m of raw.matchAll(/\\\(([\s\S]+?)\\\)/g)) push(m[1] || '')
// $ ... $ (single line)
for (const m of raw.matchAll(/(?<![\\$])\$([^$\n]{2,120})\$(?!\$)/g)) push(m[1] || '')
// Common latex commands in free text
for (const m of raw.matchAll(
/(?:^|[\s(])((?:\\frac\{[^}]+\}\{[^}]+\}|\\partial|\\nabla|\\sum|\\int|\\frac|\\cdot|\\times|\\left|\\right|y'|y''|d[xy]\/d[xy]|∂)[^\n]{0,100})/g,
)) {
push(m[1] || '')
}
// Equation-like: f(x) = ..., dy/dx = ...
for (const m of raw.matchAll(
/(?:^|\n)\s*([A-Za-zΑ-ω∂∇][A-Za-z0-9_'"]*\s*(?:\([^)]*\))?\s*[=≈≡]\s*[^\n]{3,100})/g,
)) {
const line = (m[1] || '').trim()
if (/[=≈]/.test(line) && /[a-zA-Z0-9]/.test(line)) push(line)
}
return found.slice(0, 24)
}
export function extractNumbers(raw: string): Array<{ label: string; value: number; raw: string }> {
if (!raw) return []
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) {
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 label = ctx.split(/[.;:!?\n]/).pop()?.trim().slice(-32) || `n${out.length + 1}`
out.push({ label, value: unit === '%' ? value : value, raw: `${numStr}${unit}` })
}
return out
}
export function extractKeySentences(raw: string, max = 16): string[] {
if (!raw) return []
const plain = raw
.replace(/<[^>]+>/g, ' ')
.replace(/\$\$[\s\S]+?\$\$/g, ' ')
.replace(/\\\[[\s\S]+?\\\]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
const parts = plain
.split(/(?<=[.!?])\s+/)
.map((s) => s.trim())
.filter((s) => s.length >= 40 && s.length <= 220)
const out: string[] = []
for (const p of parts) {
if (out.length >= max) break
if (!out.some((x) => x.slice(0, 40) === p.slice(0, 40))) out.push(p)
}
return out
}
export function extractSourceAssets(raw: string): SourceAssets {
const formulas = extractFormulas(raw)
const numbers = extractNumbers(raw)
const keySentences = extractKeySentences(raw)
const wordCount = raw.replace(/<[^>]+>/g, ' ').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),
hasNumbers: numbers.length >= 2,
wordCount,
}
}

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)

View File

@@ -17,6 +17,12 @@ export interface ToolUseOptions {
systemPrompt?: string
messages?: any[]
prompt?: string
/**
* Force tool usage when models (e.g. lite Gemini) prefer free text.
* - 'auto' | 'required' | 'none'
* - { type: 'tool', toolName: 'generate_slides' }
*/
toolChoice?: any
}
export interface ToolCallResult {