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

@@ -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 }