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

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