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

@@ -1,8 +1,17 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
import { QuotaExceededError } from '@/lib/entitlements'
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
import { slideGenerateCreditCost, resolveCreditCost } from '@/lib/credits'
import type { FeatureName } from '@/lib/quota-utils'
import { logAuditEvent, getClientIp } from '@/lib/audit-log'
import {
encodeSlideIntentDescription,
normalizeSlideIntent,
type SlideAudience,
type SlidePurpose,
} from '@/lib/ai/services/slide-intent'
type GenerateType = 'slide-generator' | 'excalidraw-generator'
@@ -12,9 +21,10 @@ const TYPE_DEFAULTS: Record<GenerateType, {
maxSteps: number
}> = {
'slide-generator': {
role: 'Génère une présentation professionnelle à partir du contenu de la note fournie. Appelle generate_slides avec un objet JSON structuré {title, theme, slides:[...]}.',
tools: ['note_search', 'note_read', 'generate_slides'],
maxSteps: 6,
// tools unused: slide-generator runs a structured-output pipeline (no function calling)
role: 'Génère un deck exécutif de haute qualité (titres d\'action, une idée par slide, pas de filler, graphiques uniquement avec vrais chiffres).',
tools: [],
maxSteps: 1,
},
'excalidraw-generator': {
role: 'Génère un diagramme Excalidraw clair et professionnel à partir du contenu de la note fournie.',
@@ -32,26 +42,46 @@ export async function POST(req: NextRequest) {
const userId = session.user.id
const body = await req.json()
const { noteId, type, theme, style, language, template } = body as {
const { noteId, type, theme, style, language, template, purpose, audience, slideCount } = body as {
noteId: string
type: GenerateType
theme?: string
style?: string
language?: string
template?: string
purpose?: SlidePurpose
audience?: SlideAudience
slideCount?: number
}
if (!noteId || !type || !TYPE_DEFAULTS[type]) {
return NextResponse.json({ error: 'Paramètres invalides' }, { status: 400 })
}
// Quota check — feature key depends on generation type
const featureKey = type === 'slide-generator' ? 'slide_generate' : 'excalidraw_generate'
// Crédits globaux (respecte le BYOK) — slides = 1 + N
const featureKey = (type === 'slide-generator' ? 'slide_generate' : 'excalidraw_generate') as FeatureName
const creditCost =
type === 'slide-generator'
? slideGenerateCreditCost(slideCount)
: resolveCreditCost(featureKey)
try {
await reserveUsageOrThrow(userId, featureKey)
await reserveAiUsageOrThrow(userId, featureKey, {
amount: creditCost,
slideCount: type === 'slide-generator' ? slideCount : undefined,
metadata: { noteId, type, slideCount: slideCount ?? null },
lane: 'chat',
})
} catch (e) {
if (e instanceof QuotaExceededError) {
return NextResponse.json({ error: e.message }, { status: 402 })
return NextResponse.json(
{
error: e.message,
code: 'QUOTA_EXCEEDED',
feature: featureKey,
creditCost,
},
{ status: 402 },
)
}
throw e
}
@@ -71,25 +101,50 @@ export async function POST(req: NextRequest) {
if (isEn) {
if (type === 'slide-generator') {
const recipeHint = (theme && theme !== 'auto') ? ` Use theme:"${theme}".` : ''
role = `Generate a professional presentation from the provided note content.${recipeHint} Call generate_slides with structured JSON {title, theme, slides:[...]}.`
role = `Generate a high-quality executive deck (action titles, one idea per slide, no filler, charts only with real numbers).${recipeHint}`
} else {
role = 'Generate a clear and professional Excalidraw diagram from the provided note content.'
}
} else if (type === 'slide-generator') {
const recipeHint = (theme && theme !== 'auto') ? ` Utilise le thème "${theme}".` : ''
role = `Génère une présentation professionnelle à partir du contenu de la note fournie.${recipeHint} Appelle generate_slides avec le JSON structuré {title, theme, slides:[...]}.`
role = `Génère un deck exécutif de haute qualité (titres d'action, une idée par slide, pas de filler, graphiques uniquement avec vrais chiffres).${recipeHint}`
}
const agentName = type === 'slide-generator'
? `${isEn ? 'Slides' : 'Présentation'}${(note.title || 'Note').substring(0, 40)}`
: `${isEn ? 'Diagram' : 'Diagramme'}${(note.title || 'Note').substring(0, 40)}`
const slideIntent =
type === 'slide-generator'
? normalizeSlideIntent({
purpose,
audience,
slideCount,
template: template || 'auto',
})
: null
// Map purpose → legacy template when user picks intent without template
if (slideIntent && (!slideIntent.template || slideIntent.template === 'auto')) {
const purposeToTemplate: Record<string, string> = {
board: 'board-update',
project: 'project-status',
strategy: 'strategy-review',
}
if (slideIntent.purpose && purposeToTemplate[slideIntent.purpose]) {
slideIntent.template = purposeToTemplate[slideIntent.purpose]
}
}
const agent = await prisma.agent.create({
data: {
name: agentName,
type,
role,
description: (type === 'slide-generator' && template && template !== 'auto') ? `template:${template}` : undefined,
description:
type === 'slide-generator' && slideIntent
? encodeSlideIntentDescription(slideIntent)
: undefined,
tools: JSON.stringify(defaults.tools),
maxSteps: defaults.maxSteps,
frequency: 'one-shot',
@@ -104,8 +159,9 @@ export async function POST(req: NextRequest) {
// ── Fire and forget — do NOT await so the HTTP response returns immediately ──
// In Node.js / Docker self-hosted, the process keeps running after response.
// skipQuota: units already reserved above (avoids double-charge in executeAgent).
import('@/lib/ai/services/agent-executor.service')
.then(({ executeAgent }) => executeAgent(agent.id, userId))
.then(({ executeAgent }) => executeAgent(agent.id, userId, undefined, { skipQuota: true }))
.catch(err => console.error('[run-for-note] Background agent error:', err))
logAuditEvent({