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

@@ -2,12 +2,15 @@ import { willUseByokForLane } from '@/lib/ai/provider-for-user'
import type { AiFeatureLane } from '@/lib/ai/router'
import { getSystemConfig } from '@/lib/config'
import {
reserveUsageOrThrow,
releaseUsage,
checkSessionEntitlementOrThrow,
QuotaExceededError,
QuotaServiceUnavailableError,
} from '@/lib/entitlements'
import {
releaseCredits,
reserveCreditsOrThrow,
resolveCreditCost,
} from '@/lib/credits'
import type { FeatureName } from '@/lib/quota-utils'
import { emitAiUsageChanged } from '@/lib/ai-usage-sync'
@@ -15,22 +18,34 @@ export type ReserveAiUsageOptions = {
lane?: AiFeatureLane
/** Defaults to userId — host-pays brainstorm bills this account. */
billingUserId?: string
/** Units / crédits (default 1). Multi-step features (slides) pass amount > 1. */
amount?: number
/** Pour coût slides précis (1+N). */
slideCount?: number | null
metadata?: Record<string, unknown>
}
/** Reserve one AI unit; skips when BYOK will be used for the lane. */
/** Reserve crédits IA ; saute si BYOK pour le lane. */
export async function reserveAiUsageOrThrow(
userId: string,
feature: FeatureName,
options?: ReserveAiUsageOptions,
): Promise<{ usedByok: boolean; billedUserId: string }> {
): Promise<{ usedByok: boolean; billedUserId: string; amount: number }> {
const billedUserId = options?.billingUserId ?? userId
const lane = options?.lane ?? 'chat'
const cost = resolveCreditCost(feature, {
amount: options?.amount,
slideCount: options?.slideCount,
})
const config = await getSystemConfig()
const { usedByok } = await willUseByokForLane(lane, config, billedUserId)
if (!usedByok) {
await reserveUsageOrThrow(billedUserId, feature)
await reserveCreditsOrThrow(billedUserId, cost, {
feature,
metadata: options?.metadata,
})
}
return { usedByok, billedUserId }
return { usedByok, billedUserId, amount: cost }
}
/** Host-pays: reserve on session owner with guest metadata on 402. */
@@ -39,30 +54,49 @@ export async function reserveSessionAiUsageOrThrow(
triggeredByUserId: string,
isGuestActor: boolean,
feature: FeatureName,
options?: { lane?: AiFeatureLane },
): Promise<{ usedByok: boolean }> {
options?: { lane?: AiFeatureLane; amount?: number; slideCount?: number | null },
): Promise<{ usedByok: boolean; amount: number }> {
const lane = options?.lane ?? 'chat'
const cost = resolveCreditCost(feature, {
amount: options?.amount,
slideCount: options?.slideCount,
})
const config = await getSystemConfig()
const { usedByok } = await willUseByokForLane(lane, config, billingOwnerId)
if (!usedByok) {
await checkSessionEntitlementOrThrow(
billingOwnerId,
triggeredByUserId,
isGuestActor,
feature,
)
try {
await reserveCreditsOrThrow(billingOwnerId, cost, { feature })
} catch (err) {
if (err instanceof QuotaExceededError) {
err.billingOwnerId = billingOwnerId
err.triggeredByUserId = triggeredByUserId
err.isGuestActor = isGuestActor
// Also keep session path for legacy callers
try {
await checkSessionEntitlementOrThrow(
billingOwnerId,
triggeredByUserId,
isGuestActor,
feature,
)
} catch {
/* already QuotaExceeded from credits */
}
}
throw err
}
}
return { usedByok }
return { usedByok, amount: cost }
}
/** Run fn under quota; releases the unit if fn throws (unless BYOK). */
/** Run fn under crédits ; libère si fn throw (sauf BYOK). */
export async function withAiQuota<T>(
userId: string,
feature: FeatureName,
fn: () => Promise<T>,
options?: ReserveAiUsageOptions,
): Promise<T> {
const { usedByok, billedUserId } = await reserveAiUsageOrThrow(userId, feature, options)
const { usedByok, billedUserId, amount } = await reserveAiUsageOrThrow(userId, feature, options)
try {
const result = await fn()
if (!usedByok) {
@@ -70,8 +104,8 @@ export async function withAiQuota<T>(
}
return result
} catch (err) {
if (!usedByok) {
await releaseUsage(billedUserId, feature)
if (!usedByok && amount > 0) {
await releaseCredits(billedUserId, amount, { feature })
}
throw err
}
@@ -84,9 +118,9 @@ export async function withSessionAiQuota<T>(
isGuestActor: boolean,
feature: FeatureName,
fn: () => Promise<T>,
options?: { lane?: AiFeatureLane },
options?: { lane?: AiFeatureLane; amount?: number; slideCount?: number | null },
): Promise<T> {
const { usedByok } = await reserveSessionAiUsageOrThrow(
const { usedByok, amount } = await reserveSessionAiUsageOrThrow(
billingOwnerId,
triggeredByUserId,
isGuestActor,
@@ -100,8 +134,8 @@ export async function withSessionAiQuota<T>(
}
return result
} catch (err) {
if (!usedByok) {
await releaseUsage(billingOwnerId, feature)
if (!usedByok && amount > 0) {
await releaseCredits(billingOwnerId, amount, { feature })
}
throw err
}

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 {

View File

@@ -43,41 +43,62 @@ export function normalizeThemeId(raw: string | null | undefined): ThemeId {
return 'light'
}
/** Cookie lu par le layout serveur pour éviter le flash clair au changement de route. */
export const THEME_COOKIE_NAME = 'memento-theme'
/**
* Applique le thème sur `<html>` : `class="dark"` et/ou `data-theme`.
* - `light` → papier par défaut (`:root`), pas de `data-theme`
* - `dark` → sombre global (`.dark`)
* - `auto` → `.dark` si prefers-color-scheme: dark
* - palettes nommées → `data-theme="<id>"` ; `midnight` force aussi `.dark` (variante sombre du thème)
*
* Important : on nenlève JAMAIS `dark` avant de le remettre (sinon flash clair
* dune frame à chaque navigation / re-render de ThemeInitializer).
*/
export function applyDocumentTheme(theme: string): void {
if (typeof document === 'undefined') return
const t = normalizeThemeId(theme)
const root = document.documentElement
root.classList.remove('dark')
root.removeAttribute('data-theme')
if (t === 'auto') {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
root.classList.add('dark')
}
return
}
const wantsDark =
t === 'dark' ||
t === 'midnight' ||
(t === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
if (t === 'dark') {
// Classe dark : bascule atomique (pas de remove puis add)
if (wantsDark) {
root.classList.add('dark')
return
} else {
root.classList.remove('dark')
}
if (t === 'light') {
return
}
if ((NAMED_PALETTE_IDS as readonly string[]).includes(t)) {
// data-theme pour les palettes nommées uniquement
if (t === 'auto' || t === 'dark' || t === 'light') {
root.removeAttribute('data-theme')
} else if ((NAMED_PALETTE_IDS as readonly string[]).includes(t)) {
root.setAttribute('data-theme', t)
if (t === 'midnight') {
root.classList.add('dark')
}
} else {
root.removeAttribute('data-theme')
}
root.dataset.appliedTheme = t
}
/** Persiste le thème (localStorage + cookie) pour que le serveur connaisse le dark. */
export function persistThemePreference(theme: string): void {
const t = normalizeThemeId(theme)
try {
localStorage.setItem('theme-preference', t)
} catch {
/* ignore */
}
try {
// 1 an — lu par le layout racine via cookies()
document.cookie = `${THEME_COOKIE_NAME}=${encodeURIComponent(t)}; path=/; max-age=31536000; SameSite=Lax`
} catch {
/* ignore */
}
applyDocumentTheme(t)
}

View File

@@ -13,6 +13,7 @@ export type AuditAction =
| 'SUBSCRIPTION_OVERRIDE'
| 'BILLING_CONFIG_UPDATED'
| 'PLAN_ENTITLEMENT_UPDATED'
| 'QUOTA_RESET'
export interface AuditLogParams {
userId?: string | null

View File

@@ -0,0 +1,158 @@
/**
* Packs de crédits IA (achat one-shot Stripe, mode payment).
* Les crédits achetés sajoutent à `purchasedBalance` (reportables entre mois).
*/
import { getConfigValue } from '@/lib/config'
import { stripe } from '@/lib/stripe'
export type CreditPackId = 'S' | 'M' | 'L'
export type CreditPackDef = {
id: CreditPackId
credits: number
/** Prix daffichage par défaut (si Stripe non configuré) */
defaultDisplay: string
defaultAmount: number
currency: string
priceConfigKey: string
}
/** Catalogue fixe — crédits par pack (option M). */
export const CREDIT_PACKS: Record<CreditPackId, CreditPackDef> = {
S: {
id: 'S',
credits: 100,
defaultDisplay: '4,90 €',
defaultAmount: 4.9,
currency: 'EUR',
priceConfigKey: 'STRIPE_PRICE_CREDITS_S',
},
M: {
id: 'M',
credits: 500,
defaultDisplay: '19,90 €',
defaultAmount: 19.9,
currency: 'EUR',
priceConfigKey: 'STRIPE_PRICE_CREDITS_M',
},
L: {
id: 'L',
credits: 2000,
defaultDisplay: '49,90 €',
defaultAmount: 49.9,
currency: 'EUR',
priceConfigKey: 'STRIPE_PRICE_CREDITS_L',
},
}
export const CREDIT_PACK_IDS = Object.keys(CREDIT_PACKS) as CreditPackId[]
export function isCreditPackId(value: string): value is CreditPackId {
return value === 'S' || value === 'M' || value === 'L'
}
export function getCreditPack(id: CreditPackId): CreditPackDef {
return CREDIT_PACKS[id]
}
export async function resolvePackPriceId(packId: CreditPackId): Promise<string> {
const pack = CREDIT_PACKS[packId]
const fromDb = await getConfigValue(pack.priceConfigKey, '')
const priceId = fromDb || process.env[pack.priceConfigKey] || ''
if (priceId) return priceId
const isMock =
!process.env.STRIPE_SECRET_KEY || process.env.STRIPE_SECRET_KEY === 'sk_test_placeholder'
if (isMock && process.env.NODE_ENV !== 'test') {
return `price_mock_credits_${packId.toLowerCase()}`
}
throw new Error(`No Stripe price ID configured for credit pack ${packId}`)
}
export type PackPublicPrice = {
id: CreditPackId
credits: number
display: string
amount: number
currency: string
configured: boolean
}
function formatMoney(amount: number, currency: string): string {
const cur = currency.toUpperCase()
if (cur === 'EUR') {
return `${amount.toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 2 })} €`
}
if (cur === 'USD') {
return `$${amount.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`
}
if (cur === 'GBP') {
return `£${amount.toLocaleString('en-GB', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`
}
return `${amount} ${cur}`
}
export async function getPackPublicPrices(): Promise<PackPublicPrice[]> {
const isMock =
!process.env.STRIPE_SECRET_KEY || process.env.STRIPE_SECRET_KEY === 'sk_test_placeholder'
return Promise.all(
CREDIT_PACK_IDS.map(async (id) => {
const pack = CREDIT_PACKS[id]
let display = pack.defaultDisplay
let amount = pack.defaultAmount
let currency = pack.currency
let configured = false
try {
const priceId = await resolvePackPriceId(id)
if (priceId && !priceId.startsWith('price_mock_')) {
configured = true
if (!isMock) {
const price = await stripe.prices.retrieve(priceId)
if (price.unit_amount != null) {
amount = price.unit_amount / 100
currency = price.currency.toUpperCase()
display = formatMoney(amount, currency)
}
}
}
} catch {
/* pack non configuré — on garde le prix catalogue */
}
return {
id,
credits: pack.credits,
display,
amount,
currency,
configured: configured || isMock,
}
}),
)
}
/** Retrouve packId + crédits depuis un price ID Stripe (webhook). */
export async function resolvePackFromPriceId(
priceId: string,
): Promise<{ packId: CreditPackId; credits: number } | null> {
if (!priceId) return null
if (priceId.startsWith('price_mock_credits_')) {
const suffix = priceId.replace('price_mock_credits_', '').toUpperCase()
if (isCreditPackId(suffix)) {
return { packId: suffix, credits: CREDIT_PACKS[suffix].credits }
}
}
for (const id of CREDIT_PACK_IDS) {
const pack = CREDIT_PACKS[id]
const fromDb = await getConfigValue(pack.priceConfigKey, '')
const configured = fromDb || process.env[pack.priceConfigKey] || ''
if (configured && configured === priceId) {
return { packId: id, credits: pack.credits }
}
}
return null
}

View File

@@ -11,6 +11,9 @@ const ENV_FALLBACKS: Record<string, string> = {
AI_MODEL_CHAT: process.env.AI_MODEL_CHAT || '',
AI_PROVIDER_CHAT_FALLBACK: process.env.AI_PROVIDER_CHAT_FALLBACK || '',
AI_MODEL_CHAT_FALLBACK: process.env.AI_MODEL_CHAT_FALLBACK || '',
// Slides generation — optional dedicated model (never auto-renamed)
AI_PROVIDER_SLIDES: process.env.AI_PROVIDER_SLIDES || '',
AI_MODEL_SLIDES: process.env.AI_MODEL_SLIDES || '',
AI_PROVIDER_TAGS_FALLBACK: process.env.AI_PROVIDER_TAGS_FALLBACK || '',
AI_MODEL_TAGS_FALLBACK: process.env.AI_MODEL_TAGS_FALLBACK || '',
AI_PROVIDER_EMBEDDING_FALLBACK: process.env.AI_PROVIDER_EMBEDDING_FALLBACK || '',

753
memento-note/lib/credits.ts Normal file
View File

@@ -0,0 +1,753 @@
/**
* Crédits IA globaux (modèle A).
* Un seul solde ; chaque action a un coût ; le tableau d'usage agrège le ledger par feature.
*/
import { prisma } from '@/lib/prisma'
import { redis } from '@/lib/redis'
import { getCurrentPeriodKey, VALID_FEATURES, type FeatureName } from '@/lib/quota-utils'
import type { SubscriptionTier } from '@/lib/plan-entitlements'
// Imports dynamiques vers entitlements pour éviter les cycles
// (entitlements délègue aussi vers credits pour reserveUsageOrThrow).
async function getEffectiveTier(userId: string): Promise<SubscriptionTier> {
const { getEffectiveTier: fn } = await import('@/lib/entitlements')
return fn(userId)
}
async function makeQuotaExceeded(
feature: string,
limit: number,
used: number,
userId: string,
) {
const { QuotaExceededError } = await import('@/lib/entitlements')
const tier = await getEffectiveTier(userId)
return new QuotaExceededError('PRO', feature, limit, used, false, { currentTier: tier })
}
/**
* Allocations mensuelles — option M (alignée marché IA 20252026).
* Sources marché : enveloppes type 100 / ~1 000 / ~4 000 (agrégateurs multi-modèles,
* ordres de grandeur Copilot ~1 000 crédits Pro, grilles Starter/Pro/Ultra).
* BASIC 100 · PRO 1 000 · BUSINESS 4 000 · ENTERPRISE illimité.
*/
export const CREDIT_ALLOCATIONS: Record<SubscriptionTier, number | 'unlimited'> = {
BASIC: 100,
PRO: 1000,
BUSINESS: 4000,
ENTERPRISE: 'unlimited',
}
/** Coût unitaire fixe par feature (slides : voir slideGenerateCreditCost). */
export const CREDIT_COSTS: Record<string, number> = {
semantic_search: 1,
auto_tag: 1,
auto_title: 1,
reformulate: 1,
chat: 1,
brainstorm_create: 5,
brainstorm_expand: 1,
brainstorm_enrich: 1,
suggest_charts: 1,
publish_enhance: 3,
ai_flashcard: 3,
voice_transcribe: 1,
excalidraw_generate: 4,
slide_generate: 7, // défaut si pas de slideCount (1+6)
}
export function slideGenerateCreditCost(slideCount?: number | null): number {
const n =
typeof slideCount === 'number' && Number.isFinite(slideCount)
? Math.min(8, Math.max(3, Math.round(slideCount)))
: 6
return 1 + n
}
export function notebookSlideCreditCost(opts: {
slideCount?: number | null
noteCount?: number | null
}): number {
const n =
typeof opts.slideCount === 'number' && Number.isFinite(opts.slideCount)
? Math.min(12, Math.max(5, Math.round(opts.slideCount)))
: 8
const notes =
typeof opts.noteCount === 'number' && Number.isFinite(opts.noteCount)
? Math.max(0, Math.round(opts.noteCount))
: 0
return 2 + n + Math.floor(notes / 5)
}
export function resolveCreditCost(
feature: string,
options?: { amount?: number; slideCount?: number | null },
): number {
if (feature === 'slide_generate') {
if (options?.slideCount != null) return slideGenerateCreditCost(options.slideCount)
if (options?.amount != null && options.amount > 1) return Math.max(1, Math.floor(options.amount))
return slideGenerateCreditCost(null)
}
if (options?.amount != null && options.amount > 1) {
return Math.max(1, Math.floor(options.amount))
}
return CREDIT_COSTS[feature] ?? 1
}
// ── Types ───────────────────────────────────────────────────────────────────
export type CreditBalance = {
period: string
unlimited: boolean
totalRemaining: number
subscriptionRemaining: number
purchasedRemaining: number
subscriptionGranted: number
subscriptionSpent: number
spentThisPeriod: number
}
export type CreditBreakdownRow = {
feature: string
creditsUsed: number
actionsCount: number
percentOfSpent: number
}
export type CreditUsageSummary = {
tier: SubscriptionTier
period: string
balance: CreditBalance
breakdown: CreditBreakdownRow[]
}
const TTL_SECONDS = 90 * 24 * 60 * 60
function redisKeys(userId: string, period: string) {
return {
subSpent: `credits:${userId}:${period}:sub_spent`,
purchased: `credits:${userId}:${period}:purchased`,
granted: `credits:${userId}:${period}:granted`,
}
}
// ── Ensure account / monthly grant ──────────────────────────────────────────
export async function ensureCreditAccount(userId: string): Promise<{
periodKey: string
subscriptionGranted: number
subscriptionSpent: number
purchasedBalance: number
unlimited: boolean
}> {
const periodKey = getCurrentPeriodKey()
const tier = await getEffectiveTier(userId)
const allocation = CREDIT_ALLOCATIONS[tier]
const unlimited = allocation === 'unlimited'
const grant = unlimited ? 0 : (allocation as number)
let account = await prisma.aiCreditAccount.findUnique({ where: { userId } })
if (!account) {
account = await prisma.aiCreditAccount.create({
data: {
userId,
periodKey,
subscriptionGranted: grant,
subscriptionSpent: 0,
purchasedBalance: 0,
},
})
if (!unlimited && grant > 0) {
await appendLedger(userId, {
kind: 'GRANT_SUBSCRIPTION',
amount: grant,
feature: null,
balanceAfter: grant,
metadata: { periodKey, tier },
})
}
await syncRedisFromAccount(userId, account)
return {
periodKey,
subscriptionGranted: account.subscriptionGranted,
subscriptionSpent: account.subscriptionSpent,
purchasedBalance: account.purchasedBalance,
unlimited,
}
}
// Nouvelle période : expire reliquat abonnement, conserve packs, re-grant
if (account.periodKey !== periodKey) {
const purchased = account.purchasedBalance
account = await prisma.aiCreditAccount.update({
where: { userId },
data: {
periodKey,
subscriptionGranted: grant,
subscriptionSpent: 0,
purchasedBalance: purchased,
},
})
if (!unlimited && grant > 0) {
await appendLedger(userId, {
kind: 'GRANT_SUBSCRIPTION',
amount: grant,
feature: null,
balanceAfter: grant + purchased,
metadata: { periodKey, tier, rolledPurchased: purchased },
})
}
await syncRedisFromAccount(userId, account)
} else if (!unlimited && account.subscriptionGranted !== grant) {
// Palier changé en cours de mois : met à jour l'allocation (ne retire pas le déjà consommé)
account = await prisma.aiCreditAccount.update({
where: { userId },
data: { subscriptionGranted: grant },
})
await syncRedisFromAccount(userId, account)
}
return {
periodKey: account.periodKey,
subscriptionGranted: account.subscriptionGranted,
subscriptionSpent: account.subscriptionSpent,
purchasedBalance: account.purchasedBalance,
unlimited,
}
}
async function syncRedisFromAccount(
userId: string,
account: {
periodKey: string
subscriptionGranted: number
subscriptionSpent: number
purchasedBalance: number
},
) {
const k = redisKeys(userId, account.periodKey)
try {
await redis
.multi()
.set(k.granted, String(account.subscriptionGranted), 'EX', TTL_SECONDS)
.set(k.subSpent, String(account.subscriptionSpent), 'EX', TTL_SECONDS)
.set(k.purchased, String(account.purchasedBalance), 'EX', TTL_SECONDS)
.exec()
} catch (err) {
console.error('[credits] Redis sync failed:', err)
}
}
async function appendLedger(
userId: string,
entry: {
kind: string
amount: number
feature: string | null
balanceAfter: number | null
metadata?: Record<string, unknown>
},
) {
try {
await prisma.aiCreditLedger.create({
data: {
userId,
kind: entry.kind,
amount: entry.amount,
feature: entry.feature,
balanceAfter: entry.balanceAfter,
metadata: entry.metadata ? JSON.stringify(entry.metadata) : null,
},
})
} catch (err) {
console.error('[credits] ledger write failed:', err)
}
}
// ── Balance ─────────────────────────────────────────────────────────────────
export async function getCreditBalance(userId: string): Promise<CreditBalance> {
const acc = await ensureCreditAccount(userId)
const period = acc.periodKey
if (acc.unlimited) {
return {
period,
unlimited: true,
totalRemaining: Infinity,
subscriptionRemaining: Infinity,
purchasedRemaining: acc.purchasedBalance,
subscriptionGranted: Infinity,
subscriptionSpent: acc.subscriptionSpent,
spentThisPeriod: acc.subscriptionSpent,
}
}
// Prefer Redis if present
const k = redisKeys(userId, period)
try {
const [subSpentRaw, purchasedRaw, grantedRaw] = await redis.mget(
k.subSpent,
k.purchased,
k.granted,
)
if (subSpentRaw != null || purchasedRaw != null) {
const subscriptionSpent = Math.max(0, parseInt(subSpentRaw || '0', 10) || 0)
const purchasedRemaining = Math.max(0, parseInt(purchasedRaw || '0', 10) || 0)
const subscriptionGranted = Math.max(
0,
parseInt(grantedRaw || String(acc.subscriptionGranted), 10) || acc.subscriptionGranted,
)
const subscriptionRemaining = Math.max(0, subscriptionGranted - subscriptionSpent)
return {
period,
unlimited: false,
totalRemaining: subscriptionRemaining + purchasedRemaining,
subscriptionRemaining,
purchasedRemaining,
subscriptionGranted,
subscriptionSpent,
spentThisPeriod: subscriptionSpent, // packs spend tracked separately via ledger
}
}
} catch {
/* fall through to DB */
}
const subscriptionRemaining = Math.max(0, acc.subscriptionGranted - acc.subscriptionSpent)
return {
period,
unlimited: false,
totalRemaining: subscriptionRemaining + acc.purchasedBalance,
subscriptionRemaining,
purchasedRemaining: acc.purchasedBalance,
subscriptionGranted: acc.subscriptionGranted,
subscriptionSpent: acc.subscriptionSpent,
spentThisPeriod: acc.subscriptionSpent,
}
}
// ── Reserve / release ───────────────────────────────────────────────────────
const RESERVE_CREDITS_LUA = `
local cost = tonumber(ARGV[1]) or 1
local granted = tonumber(redis.call('GET', KEYS[1]) or '0')
local subSpent = tonumber(redis.call('GET', KEYS[2]) or '0')
local purchased = tonumber(redis.call('GET', KEYS[3]) or '0')
local ttl = tonumber(ARGV[2]) or 7776000
if cost < 1 then cost = 1 end
local subRem = math.max(0, granted - subSpent)
local total = subRem + purchased
if total < cost then
return {-1, subSpent, purchased}
end
local fromSub = math.min(subRem, cost)
local fromPurch = cost - fromSub
subSpent = subSpent + fromSub
purchased = purchased - fromPurch
redis.call('SET', KEYS[2], tostring(subSpent))
redis.call('EXPIRE', KEYS[2], ttl)
redis.call('SET', KEYS[3], tostring(purchased))
redis.call('EXPIRE', KEYS[3], ttl)
if redis.call('TTL', KEYS[1]) < 0 then
redis.call('EXPIRE', KEYS[1], ttl)
end
return {cost, subSpent, purchased, fromSub, fromPurch}
`
/**
* Réserve `cost` crédits. Lance QuotaExceededError si insuffisant.
* Enterprise unlimited : no-op.
*/
export async function reserveCreditsOrThrow(
userId: string,
cost: number,
options?: { feature?: string; metadata?: Record<string, unknown> },
): Promise<{ cost: number; unlimited: boolean }> {
const units = Math.max(1, Math.floor(Number(cost) || 1))
const acc = await ensureCreditAccount(userId)
if (acc.unlimited) {
// Journal informatif (montant 0) pour le tableau si feature fournie
if (options?.feature) {
await appendLedger(userId, {
kind: 'CONSUME',
amount: 0,
feature: options.feature,
balanceAfter: null,
metadata: { ...(options.metadata || {}), unlimited: true, notionalCost: units },
})
}
return { cost: 0, unlimited: true }
}
const period = acc.periodKey
const k = redisKeys(userId, period)
// Ensure granted key is set
try {
const g = await redis.get(k.granted)
if (g == null) {
await syncRedisFromAccount(userId, {
periodKey: period,
subscriptionGranted: acc.subscriptionGranted,
subscriptionSpent: acc.subscriptionSpent,
purchasedBalance: acc.purchasedBalance,
})
}
} catch {
/* continue */
}
let fromSub = units
let fromPurch = 0
let newSubSpent = acc.subscriptionSpent + units
let newPurchased = acc.purchasedBalance
try {
const raw = (await redis.eval(
RESERVE_CREDITS_LUA,
3,
k.granted,
k.subSpent,
k.purchased,
String(units),
String(TTL_SECONDS),
)) as number[]
if (!raw || raw[0] === -1) {
const bal = await getCreditBalance(userId)
throw await makeQuotaExceeded(
options?.feature || 'credits',
bal.subscriptionGranted + bal.purchasedRemaining,
bal.spentThisPeriod,
userId,
)
}
newSubSpent = Number(raw[1]) || 0
newPurchased = Number(raw[2]) || 0
fromSub = Number(raw[3]) ?? units
fromPurch = Number(raw[4]) ?? 0
} catch (err) {
if (err instanceof QuotaExceededError) throw err
// DB fallback
const subRem = Math.max(0, acc.subscriptionGranted - acc.subscriptionSpent)
const total = subRem + acc.purchasedBalance
if (total < units) {
throw await makeQuotaExceeded(
options?.feature || 'credits',
acc.subscriptionGranted + acc.purchasedBalance,
acc.subscriptionSpent,
userId,
)
}
fromSub = Math.min(subRem, units)
fromPurch = units - fromSub
newSubSpent = acc.subscriptionSpent + fromSub
newPurchased = acc.purchasedBalance - fromPurch
}
await prisma.aiCreditAccount.update({
where: { userId },
data: {
subscriptionSpent: newSubSpent,
purchasedBalance: newPurchased,
},
})
const remaining =
Math.max(0, acc.subscriptionGranted - newSubSpent) + Math.max(0, newPurchased)
await appendLedger(userId, {
kind: 'CONSUME',
amount: -units,
feature: options?.feature ?? null,
balanceAfter: remaining,
metadata: {
...(options?.metadata || {}),
fromSubscription: fromSub,
fromPurchased: fromPurch,
},
})
return { cost: units, unlimited: false }
}
/**
* Remet des crédits après échec (best-effort).
* Simplification : recrédite d'abord l'abonnement (réduit subscriptionSpent).
*/
export async function releaseCredits(
userId: string,
cost: number,
options?: { feature?: string },
): Promise<void> {
const units = Math.max(1, Math.floor(Number(cost) || 1))
const acc = await ensureCreditAccount(userId)
if (acc.unlimited) return
const period = acc.periodKey
const k = redisKeys(userId, period)
let newSubSpent = Math.max(0, acc.subscriptionSpent - units)
// Si on a plus dépensé en abonnement que available, le reste va en packs
let refundPurch = 0
const subRefund = Math.min(units, acc.subscriptionSpent)
refundPurch = units - subRefund
newSubSpent = acc.subscriptionSpent - subRefund
const newPurchased = acc.purchasedBalance + refundPurch
try {
await redis
.multi()
.set(k.subSpent, String(newSubSpent), 'EX', TTL_SECONDS)
.set(k.purchased, String(newPurchased), 'EX', TTL_SECONDS)
.exec()
} catch (err) {
console.error('[credits] release Redis failed:', err)
}
await prisma.aiCreditAccount.update({
where: { userId },
data: {
subscriptionSpent: newSubSpent,
purchasedBalance: newPurchased,
},
})
const remaining =
Math.max(0, acc.subscriptionGranted - newSubSpent) + Math.max(0, newPurchased)
await appendLedger(userId, {
kind: 'RELEASE',
amount: units,
feature: options?.feature ?? null,
balanceAfter: remaining,
metadata: { refundSubscription: subRefund, refundPurchased: refundPurch },
})
}
export async function addPurchasedCredits(
userId: string,
amount: number,
metadata?: Record<string, unknown>,
): Promise<{ applied: boolean; units: number }> {
const units = Math.max(1, Math.floor(amount))
// Idempotence webhook : un même checkout Stripe ne crédite qu'une fois
const stripeSessionId =
typeof metadata?.stripeSessionId === 'string' ? metadata.stripeSessionId : null
if (stripeSessionId) {
const existing = await prisma.aiCreditLedger.findFirst({
where: {
userId,
kind: 'PURCHASE_PACK',
metadata: { contains: stripeSessionId },
},
select: { id: true },
})
if (existing) {
return { applied: false, units }
}
}
const acc = await ensureCreditAccount(userId)
const newPurchased = acc.purchasedBalance + units
await prisma.aiCreditAccount.update({
where: { userId },
data: { purchasedBalance: newPurchased },
})
const period = acc.periodKey
const k = redisKeys(userId, period)
try {
await redis.set(k.purchased, String(newPurchased), 'EX', TTL_SECONDS)
} catch {
/* ignore */
}
const bal = await getCreditBalance(userId)
await appendLedger(userId, {
kind: 'PURCHASE_PACK',
amount: units,
feature: null,
balanceAfter: bal.unlimited ? null : bal.totalRemaining,
metadata,
})
return { applied: true, units }
}
/** Reset admin : remet conso à 0 et re-grant allocation, packs optionnels conservés. */
export async function adminResetCredits(
userId: string,
options?: { clearPurchased?: boolean },
): Promise<CreditBalance> {
const periodKey = getCurrentPeriodKey()
const tier = await getEffectiveTier(userId)
const allocation = CREDIT_ALLOCATIONS[tier]
const unlimited = allocation === 'unlimited'
const grant = unlimited ? 0 : (allocation as number)
const existing = await prisma.aiCreditAccount.findUnique({ where: { userId } })
const purchased = options?.clearPurchased ? 0 : (existing?.purchasedBalance ?? 0)
const account = await prisma.aiCreditAccount.upsert({
where: { userId },
create: {
userId,
periodKey,
subscriptionGranted: grant,
subscriptionSpent: 0,
purchasedBalance: purchased,
},
update: {
periodKey,
subscriptionGranted: grant,
subscriptionSpent: 0,
purchasedBalance: purchased,
},
})
await syncRedisFromAccount(userId, account)
await appendLedger(userId, {
kind: 'ADMIN_RESET',
amount: grant,
feature: null,
balanceAfter: grant + purchased,
metadata: { clearPurchased: !!options?.clearPurchased, tier },
})
return getCreditBalance(userId)
}
// ── Breakdown for usage table ───────────────────────────────────────────────
/**
* Date de début pour le détail d'usage : après le dernier ADMIN_RESET du mois,
* sinon début de période. Évite « 300 restants + 16 utilisés » après un reset admin.
*/
async function usageWindowStart(userId: string): Promise<Date> {
const period = getCurrentPeriodKey()
const periodStart = new Date(`${period}-01T00:00:00.000Z`)
const lastReset = await prisma.aiCreditLedger.findFirst({
where: {
userId,
kind: 'ADMIN_RESET',
createdAt: { gte: periodStart },
},
orderBy: { createdAt: 'desc' },
select: { createdAt: true },
})
return lastReset?.createdAt ?? periodStart
}
export async function getCreditBreakdown(userId: string): Promise<CreditBreakdownRow[]> {
const since = await usageWindowStart(userId)
const rows = await prisma.aiCreditLedger.groupBy({
by: ['feature'],
where: {
userId,
kind: 'CONSUME',
createdAt: { gte: since },
feature: { not: null },
},
_sum: { amount: true },
_count: { _all: true },
})
// Toutes les fonctionnalités connues (comme l'ancien tableau) — pas seulement slides
const usedMap = new Map<string, { creditsUsed: number; actionsCount: number }>()
for (const feature of VALID_FEATURES) {
usedMap.set(feature, { creditsUsed: 0, actionsCount: 0 })
}
for (const r of rows) {
if (!r.feature) continue
const creditsUsed = Math.abs(r._sum.amount ?? 0)
usedMap.set(r.feature, {
creditsUsed,
actionsCount: r._count._all,
})
}
const mapped: CreditBreakdownRow[] = []
let totalSpent = 0
for (const [feature, v] of usedMap) {
totalSpent += v.creditsUsed
mapped.push({
feature,
creditsUsed: v.creditsUsed,
actionsCount: v.actionsCount,
percentOfSpent: 0,
})
}
for (const r of mapped) {
r.percentOfSpent =
totalSpent > 0 ? Math.round((r.creditsUsed / totalSpent) * 1000) / 10 : 0
}
// Tri : d'abord celles avec usage, puis ordre alphabétique du feature key
mapped.sort((a, b) => {
if (b.creditsUsed !== a.creditsUsed) return b.creditsUsed - a.creditsUsed
if (b.actionsCount !== a.actionsCount) return b.actionsCount - a.actionsCount
return a.feature.localeCompare(b.feature)
})
return mapped
}
export async function getCreditUsageSummary(userId: string): Promise<CreditUsageSummary> {
const tier = await getEffectiveTier(userId)
const balance = await getCreditBalance(userId)
const breakdown = await getCreditBreakdown(userId)
// Conso affichée = somme réelle des CONSUME dans la fenêtre (après dernier reset)
const spentFromBreakdown = breakdown.reduce((s, r) => s + r.creditsUsed, 0)
balance.spentThisPeriod = spentFromBreakdown
return {
tier,
period: balance.period,
balance,
breakdown,
}
}
/** Compat : fabrique un objet quotas-like pour l'ancien UI le temps de la migration. */
export async function getQuotasCompatFromCredits(
userId: string,
): Promise<Record<string, { remaining: number; limit: number; used: number }>> {
const summary = await getCreditUsageSummary(userId)
const result: Record<string, { remaining: number; limit: number; used: number }> = {}
// Lignes du breakdown
const usedByFeature = new Map(summary.breakdown.map((b) => [b.feature, b.creditsUsed]))
for (const feature of VALID_FEATURES) {
const used = usedByFeature.get(feature) ?? 0
if (summary.balance.unlimited) {
result[feature] = { remaining: Infinity, limit: Infinity, used }
} else {
// Pas de plafond par feature : on expose used + remaining global pour info
result[feature] = {
used,
limit: summary.balance.subscriptionGranted + summary.balance.purchasedRemaining + used,
remaining: summary.balance.totalRemaining,
}
}
}
return result
}
export function isCreditFeature(feature: string): feature is FeatureName {
return (VALID_FEATURES as readonly string[]).includes(feature as FeatureName)
}

View File

@@ -6,6 +6,7 @@ import {
getRedisKey,
parseRedisInt,
isValidFeature,
VALID_FEATURES,
} from './quota-utils';
import {
getLimitAsync,
@@ -138,15 +139,17 @@ async function reserveUsageInDatabase(
userId: string,
feature: string,
limit: number,
amount: number = 1,
): Promise<number> {
const { periodStart, periodEnd } = getPeriodDates();
const units = Math.max(1, Math.floor(amount));
return prisma.$transaction(async (tx) => {
const existing = await tx.usageLog.findUnique({
where: { userId_feature_periodStart: { userId, feature, periodStart } },
});
const current = existing?.requestsCount ?? 0;
if (current >= limit) return -1;
if (current + units > limit) return -1;
const updated = await tx.usageLog.upsert({
where: { userId_feature_periodStart: { userId, feature, periodStart } },
@@ -155,12 +158,12 @@ async function reserveUsageInDatabase(
feature,
periodStart,
periodEnd,
requestsCount: 1,
requestsCount: units,
tokensUsed: 0,
syncedAt: new Date(),
},
update: {
requestsCount: { increment: 1 },
requestsCount: { increment: units },
syncedAt: new Date(),
},
});
@@ -191,53 +194,37 @@ return newCount
`;
const RELEASE_LUA = `
local amount = tonumber(ARGV[1]) or 1
if amount < 1 then amount = 1 end
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
if current <= 0 then return 0 end
redis.call('DECR', KEYS[1])
local dec = math.min(amount, current)
redis.call('DECRBY', KEYS[1], dec)
return tonumber(redis.call('GET', KEYS[1]) or '0')
`;
export async function releaseUsage(userId: string, feature: string): Promise<void> {
/** Libère des crédits précédemment réservés (modèle A). */
export async function releaseUsage(
userId: string,
feature: string,
amount: number = 1,
): Promise<void> {
if (!isValidFeature(feature)) return;
const tier = await getEffectiveTier(userId);
const limit = await getLimitAsync(tier, feature);
if (limit === undefined || limit === Infinity) return;
try {
const key = getRedisKey(userId, feature);
const raw = await redis.eval(RELEASE_LUA, 1, key);
const newCount = parseReserveResult(raw);
if (Number.isFinite(newCount) && newCount >= 0) {
await mirrorUsageCountToDatabase(userId, feature, newCount);
}
} catch (err) {
console.error('[entitlements] Redis release failed, trying DB fallback:', err);
try {
const { periodStart } = getPeriodDates();
const existing = await prisma.usageLog.findUnique({
where: { userId_feature_periodStart: { userId, feature, periodStart } },
});
const current = existing?.requestsCount ?? 0;
if (current <= 0) return;
await prisma.usageLog.update({
where: { userId_feature_periodStart: { userId, feature, periodStart } },
data: { requestsCount: current - 1, syncedAt: new Date() },
});
} catch (dbErr) {
console.error('[entitlements] DB release fallback failed:', dbErr);
}
}
const units = Math.max(1, Math.floor(Number(amount) || 1));
const { releaseCredits } = await import('@/lib/credits');
await releaseCredits(userId, units, { feature });
}
const RESERVE_LUA = `
local limit = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
local amount = tonumber(ARGV[3]) or 1
if amount < 1 then amount = 1 end
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
if current >= limit then
if current + amount > limit then
return -1
end
redis.call('INCRBY', KEYS[1], 1)
redis.call('INCRBY', KEYS[1], amount)
local ttlResult = redis.call('TTL', KEYS[1])
if ttlResult == -1 then
redis.call('EXPIRE', KEYS[1], ttl)
@@ -370,86 +357,47 @@ export async function checkEntitlementOrThrow(
}
}
/** Mappe une feature de quota vers la voie IA (pour le BYOK). */
function featureToAiLane(feature: string): 'chat' | 'tags' | 'embedding' {
if (feature === 'auto_tag' || feature === 'auto_title') return 'tags';
if (feature === 'semantic_search') return 'embedding';
return 'chat';
}
/**
* Réserve des crédits globaux (modèle A).
* `amount` = coût en crédits (ex. slides multi-étapes : 1+N).
* Si l'utilisateur a une clé perso (BYOK) pour la voie concernée, aucun débit.
* Conserve le nom historique pour ne pas casser tous les appelants.
*/
export async function reserveUsageOrThrow(
userId: string,
feature: string,
amount: number = 1,
): Promise<void> {
if (!isValidFeature(feature)) {
throw new QuotaExceededError('PRO', feature, 0, 0, false, { currentTier: 'BASIC' });
}
const tier = await getEffectiveTier(userId);
const limit = await getLimitAsync(tier, feature);
const { getSystemConfig } = await import('@/lib/config');
const { willUseByokForLane } = await import('@/lib/ai/provider-for-user');
const config = await getSystemConfig();
const lane = featureToAiLane(feature);
const { usedByok } = await willUseByokForLane(lane, config, userId);
if (usedByok) return;
if (limit === undefined) {
throw new QuotaExceededError(
tier === 'BASIC' ? 'PRO' : 'BUSINESS',
feature,
0,
0,
false,
{ currentTier: tier },
);
}
const { reserveCreditsOrThrow, resolveCreditCost } = await import('@/lib/credits');
const cost = resolveCreditCost(feature, { amount });
await reserveCreditsOrThrow(userId, cost, { feature });
}
if (limit === Infinity) return;
try {
const key = getRedisKey(userId, feature);
const raw = await redis.eval(
RESERVE_LUA,
1,
key,
String(limit),
String(TTL_SECONDS),
);
const newCount = parseReserveResult(raw);
if (newCount === -1) {
const byokConfigured = await hasAnyActiveByok(userId);
throw new QuotaExceededError(
tier === 'BASIC' ? 'PRO' : 'BUSINESS',
feature,
limit,
limit,
byokConfigured,
{ currentTier: tier },
);
}
if (!Number.isFinite(newCount) || newCount < 1) {
throw new Error(`Invalid Redis reserve result: ${String(raw)}`);
}
await mirrorUsageCountToDatabase(userId, feature, newCount);
} catch (err) {
if (err instanceof QuotaExceededError) throw err;
console.error('[entitlements] Redis reserve failed, trying DB fallback:', err);
try {
const dbCount = await reserveUsageInDatabase(userId, feature, limit);
if (dbCount === -1) {
const byokConfigured = await hasAnyActiveByok(userId);
throw new QuotaExceededError(
tier === 'BASIC' ? 'PRO' : 'BUSINESS',
feature,
limit,
limit,
byokConfigured,
{ currentTier: tier },
);
}
return;
} catch (dbErr) {
if (dbErr instanceof QuotaExceededError) throw dbErr;
console.error('[entitlements] DB reserve fallback failed:', dbErr);
}
if (shouldFailClosedOnRedisError()) {
throw new QuotaServiceUnavailableError();
}
}
/** Units charged for one multi-step slide generation (outline + per-slide expand). */
export function slideGenerateQuotaUnits(slideCount?: number | null): number {
// 1 outline + N expand calls (clamped). Reflects real multi-call cost.
const n = typeof slideCount === 'number' && Number.isFinite(slideCount)
? Math.min(8, Math.max(3, Math.round(slideCount)))
: 6;
return 1 + n;
}
/** Host-pays: bill session owner, attach actor metadata for 402 responses (Story 3.4). */
@@ -480,6 +428,68 @@ export function incrementUsageAsync(userId: string, feature: string, count: numb
});
}
export type ResetUserUsageOptions = {
/** Si fourni, ne remet à zéro que ces fonctionnalités. Sinon : toutes. */
features?: string[];
};
/**
* Remet à zéro la consommation IA dun utilisateur pour la période mensuelle en cours
* (compteurs Redis + lignes UsageLog en base).
* Réservé aux actions admin.
*/
export async function resetUserUsageForCurrentPeriod(
userId: string,
options?: ResetUserUsageOptions,
): Promise<{ period: string; featuresReset: string[]; redisDeleted: number; dbUpdated: number }> {
if (!userId?.trim()) {
throw new Error('userId required');
}
const period = getCurrentPeriodKey();
const { periodStart } = getPeriodDates();
const features = (options?.features?.length
? options.features.filter((f) => isValidFeature(f))
: [...VALID_FEATURES]) as string[];
if (features.length === 0) {
return { period, featuresReset: [], redisDeleted: 0, dbUpdated: 0 };
}
const keys = features.map((f) => getRedisKey(userId, f));
let redisDeleted = 0;
try {
if (keys.length === 1) {
redisDeleted = await redis.del(keys[0]);
} else {
redisDeleted = await redis.del(...keys);
}
} catch (err) {
console.error('[entitlements] resetUserUsage Redis del failed:', err);
}
const dbResult = await prisma.usageLog.updateMany({
where: {
userId,
periodStart,
feature: { in: features },
},
data: {
requestsCount: 0,
tokensUsed: 0,
syncedAt: new Date(),
},
});
return {
period,
featuresReset: features,
redisDeleted,
dbUpdated: dbResult.count,
};
}
export async function getUserQuotas(
userId: string,
): Promise<Record<string, { remaining: number; limit: number; used: number }>> {

View File

@@ -0,0 +1,58 @@
/**
* Scripts boot inline dans le <head> (évite next/script + warning React 19
* « Scripts inside React components are never executed… »).
* Doivent rester synchrones et sans import.
*/
export const THEME_INIT_SCRIPT = `(function () {
try {
var root = document.documentElement;
var fallback = root.getAttribute('data-server-theme') || 'light';
var defaultAccent = root.getAttribute('data-server-accent') || '#A47148';
var stored = localStorage.getItem('theme-preference');
var raw = stored || fallback;
if (raw === 'slate') raw = 'light';
var allowed = {
light: 1, dark: 1, auto: 1, sepia: 1, midnight: 1,
rose: 1, green: 1, lavender: 1, sand: 1, ocean: 1, sunset: 1, blue: 1
};
var theme = allowed[raw] ? raw : 'light';
try {
document.cookie = 'memento-theme=' + encodeURIComponent(theme) + '; path=/; max-age=31536000; SameSite=Lax';
if (!stored) localStorage.setItem('theme-preference', theme);
} catch (e) {}
var wantsDark = false;
if (theme === 'auto') {
wantsDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
} else if (theme === 'dark' || theme === 'midnight') {
wantsDark = true;
}
if (wantsDark) root.classList.add('dark');
else root.classList.remove('dark');
if (theme === 'auto' || theme === 'dark' || theme === 'light') {
root.removeAttribute('data-theme');
} else {
root.setAttribute('data-theme', theme);
}
root.setAttribute('data-applied-theme', theme);
var accentStored = localStorage.getItem('accent-color');
root.style.setProperty('--color-brand-accent', accentStored || defaultAccent);
} catch (e) {
console.error('Theme script error', e);
}
})();`
export const DIRECTION_INIT_SCRIPT = `(function () {
try {
var lang = localStorage.getItem('user-language');
if (!lang) {
var c = document.cookie.split(';').map(function (s) { return s.trim(); })
.find(function (s) { return s.startsWith('user-language='); });
if (c) lang = c.split('=')[1];
}
if (lang === 'fa' || lang === 'ar') {
document.documentElement.dir = 'rtl';
document.documentElement.lang = lang;
}
} catch (e) {}
})();`

View File

@@ -19,6 +19,9 @@ export const FALLBACK_TIER_LIMITS: Record<
brainstorm_expand: 10,
brainstorm_enrich: 20,
suggest_charts: 5,
// Multi-step pipeline charges ~49 units per deck → low monthly budget
slide_generate: 10,
excalidraw_generate: 5,
ai_flashcard: 5,
voice_transcribe: 20,
publish_enhance: 2,
@@ -33,8 +36,9 @@ export const FALLBACK_TIER_LIMITS: Record<
brainstorm_expand: 100,
brainstorm_enrich: 200,
suggest_charts: 50,
slide_generate: 20,
excalidraw_generate: 20,
// ~7 units/deck → ~20 decks/month at 140 units
slide_generate: 140,
excalidraw_generate: 40,
ai_flashcard: 100,
voice_transcribe: 500,
publish_enhance: 15,
@@ -49,8 +53,9 @@ export const FALLBACK_TIER_LIMITS: Record<
brainstorm_expand: 500,
brainstorm_enrich: 1000,
suggest_charts: 200,
slide_generate: 100,
excalidraw_generate: 100,
// ~7 units/deck → ~70 decks/month
slide_generate: 500,
excalidraw_generate: 200,
ai_flashcard: 'unlimited',
voice_transcribe: 'unlimited',
publish_enhance: 100,

View File

@@ -23,27 +23,29 @@ export const REWRITE_SHARED_CSS = `
${KATEX_PUBLISH_CSS}
/* ─── Résumé introductif ─────────────────────────────── */
.pub-rewrite-summary {
font-size: 1.2em;
font-size: 1.18em;
line-height: 1.7;
font-style: italic;
color: var(--pub-summary-color, #444);
margin-bottom: 2em;
padding-bottom: 1.5em;
padding: 0 0 1.5em;
border-bottom: 2px solid var(--pub-accent, #A47148);
text-wrap: pretty;
}
/* ─── Exercices ──────────────────────────────────────── */
.pub-rewrite-body .pub-exercise {
border: 2px solid var(--pub-exercise-border, #e0d4c3);
border-radius: 12px;
border: 1px solid var(--pub-exercise-border, #e0d4c3);
border-radius: 14px;
margin: 1.75em 0;
overflow: hidden;
box-shadow: 0 8px 28px rgba(0,0,0,0.04);
}
.pub-rewrite-body .pub-exercise-header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 18px;
padding: 12px 18px;
background: var(--pub-exercise-header-bg, #f6f1ea);
border-bottom: 1px solid var(--pub-exercise-border, #e0d4c3);
}

View File

@@ -16,18 +16,18 @@ export function getThemeScript(serverTheme: string = 'light', serverAccentColor:
var allowed = { light:1, dark:1, auto:1, sepia:1, midnight:1, rose:1, green:1, lavender:1, sand:1, ocean:1, sunset:1, blue:1 };
var theme = allowed[raw] ? raw : 'light';
var root = document.documentElement;
root.classList.remove('dark');
root.removeAttribute('data-theme');
if (theme === 'auto') {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) root.classList.add('dark');
} else if (theme === 'dark') {
root.classList.add('dark');
} else if (theme === 'light') {
/* :root papier */
} else {
try {
document.cookie = 'memento-theme=' + encodeURIComponent(theme) + '; path=/; max-age=31536000; SameSite=Lax';
if (!stored) localStorage.setItem('theme-preference', theme);
} catch (e) {}
var wantsDark = theme === 'dark' || theme === 'midnight' || (theme === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (wantsDark) root.classList.add('dark');
else root.classList.remove('dark');
if (theme === 'auto' || theme === 'dark' || theme === 'light') root.removeAttribute('data-theme');
else {
root.setAttribute('data-theme', theme);
if (theme === 'midnight') root.classList.add('dark');
}
root.setAttribute('data-applied-theme', theme);
var accentStored = localStorage.getItem('accent-color');
var effectiveAccent = accentStored || ${JSON.stringify(defaultAccent)};
root.style.setProperty('--color-brand-accent', effectiveAccent);